From e3d7dfe16753a06a0e2945d4daedbfd12b542a79 Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Mon, 26 Feb 2024 08:00:01 -0500 Subject: [PATCH 01/97] trim trailing slash from custom URLs --- lua/gitlab/state.lua | 2 +- lua/gitlab/utils/init.lua | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 0cd3f23d..feab6643 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -242,7 +242,7 @@ M.setPluginConfiguration = function() end M.settings.auth_token = file_properties.auth_token or os.getenv("GITLAB_TOKEN") - M.settings.gitlab_url = file_properties.gitlab_url or os.getenv("GITLAB_URL") or "https://gitlab.com" + M.settings.gitlab_url = u.trim_slash(file_properties.gitlab_url or os.getenv("GITLAB_URL") or "https://gitlab.com") if M.settings.auth_token == nil then vim.notify( diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index d181501d..63886ff7 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -753,4 +753,11 @@ M.open_in_browser = function(url) end end +---Trims the trailing slash from a URL +---@param s string +---@return string +M.trim_slash = function(s) + return (s:gsub("/+$", "")) +end + return M From 438d126883bc72bdf9301b62d95b4dd43afd6455 Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Mon, 26 Feb 2024 08:07:02 -0500 Subject: [PATCH 02/97] Updated .github/CONTRIBUTING.md --- .github/CONTRIBUTING.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b8403069..eab6cb31 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -45,8 +45,10 @@ $ stylua . $ luacheck --globals vim busted --no-max-line-length -- . ``` -4. Make the merge request to the `main` branch of `.gitlab.nvim` +4. Make the merge request to the `develop` branch of `.gitlab.nvim` Please provide a description of the feature, and links to any relevant issues. -That's it! I'll try to respond to any incoming merge request in a few days. Once we've reviewed it and it's been merged into main, the pipeline will detect whether we're merging in a patch, minor, or major change, and create a new tag (e.g. 1.0.12) and release. +That's it! I'll try to respond to any incoming merge request in a few days. Once we've reviewed it, it will be merged into the develop branc, it will be merged into the develop branch. + +After some time, if the develop branch is found to be stable, that branch will be merged into `main` and released. When merged into `main` the pipeline will detect whether we're merging in a patch, minor, or major change, and create a new tag (e.g. 1.0.12) and release. From d29bd0b367982c1806c4c00259d34825602a6934 Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Mon, 26 Feb 2024 08:09:59 -0500 Subject: [PATCH 03/97] Updated .github/ISSUE_TEMPLATE/bug_report.md --- .github/ISSUE_TEMPLATE/bug_report.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 04a0e136..6f173b45 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -3,15 +3,17 @@ name: Bug report about: Create a report to help improve gitlab.nvim! title: '' labels: '' -assignees: '' - --- ## Prerequsities -- [ ] The "Troubleshooting" section of the README did not help -- [ ] I've installed the required dependencies - [ ] I'm on the latest version of the plugin +- [ ] I've installed the required dependencies +- [ ] I've run `:h gitlab.nvim.troubleshooting` and followed the steps there + +## Setup Configuration and Environment + +Please post here the options you're passing to configure `gitlab.nvim` and specify any environment variables you're relying on. ## Bug Description @@ -26,5 +28,3 @@ A clear and concise description of what the bug is. ## Screenshots If applicable, add screenshots to help explain your problem. - -## Other Details From a6401d9e6820d76cfbba855d719de1441cad71d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Tue, 27 Feb 2024 14:39:07 +0100 Subject: [PATCH 04/97] Feat: Improve discussion tree toggling (#192) * Fix: Toggle modified notes (#188) * Fix: Toggle discussion nodes correctly * Feat: Show Help keymap in discussion tree winbar * Fix: Enable toggling nodes from the note body Previously, the cursor had to be placed on the first line of the note (the one with the author name) for toggling to work. * Fix: Enable toggling resolved status from child nodes Previously, the cursor had to be placed on the root node of the discussion for toggling resolved status to work. * Fix: Only try to show emoji popup on note nodes Without this fix, there was an error whenever the cursor moved to a file_name or path node in the "by_file_name" discussion tree. * Feat: Add keymap for toggling tree type Previously, tree type could only be set in the plugin configuration. Now it can be toggled within a session. The keymap "i" is chosen based on a similar functionality in DiffView. * Fix: Disable tree type toggling in Notes Tree type toggling only makes sense in the (linked) Discussions and making it possible in the Notes could confuse users. --- README.md | 1 + doc/gitlab.nvim.txt | 1 + .../actions/discussions/annotations.lua | 1 + lua/gitlab/actions/discussions/init.lua | 73 ++++++++++++++++--- lua/gitlab/actions/discussions/winbar.lua | 3 +- lua/gitlab/emoji.lua | 2 +- lua/gitlab/state.lua | 14 +++- 7 files changed, 83 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c4ffbd40..efafc835 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ require("gitlab").setup({ resolved = '✓', -- Symbol to show next to resolved discussions unresolved = '-', -- Symbol to show next to unresolved discussions tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file + toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name" winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) -- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar. }, diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 24a9652a..65e6e10d 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -182,6 +182,7 @@ you call this function with no values the defaults will be used: resolved = '✓', -- Symbol to show next to resolved discussions unresolved = '-', -- Symbol to show next to unresolved discussions tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file + toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name" winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) -- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar. }, diff --git a/lua/gitlab/actions/discussions/annotations.lua b/lua/gitlab/actions/discussions/annotations.lua index 341a57c9..72c6db7b 100644 --- a/lua/gitlab/actions/discussions/annotations.lua +++ b/lua/gitlab/actions/discussions/annotations.lua @@ -84,6 +84,7 @@ ---@field resolved_discussions number ---@field resolvable_notes number ---@field resolved_notes number +---@field help_keymap string --- ---@class SignTable ---@field name string diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 92b3c1d0..682971aa 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -75,6 +75,21 @@ M.refresh_discussion_data = function() end) end +---Toggle Discussions tree type between "simple" and "by_file_name" +---@param unlinked boolean True if selected view type is Notes (unlinked discussions) +M.toggle_tree_type = function(unlinked) + if unlinked then + u.notify("Toggling tree type is only possible in Discussions", vim.log.levels.INFO) + return + end + if state.settings.discussion_tree.tree_type == "simple" then + state.settings.discussion_tree.tree_type = "by_file_name" + else + state.settings.discussion_tree.tree_type = "simple" + end + M.rebuild_discussion_tree() +end + ---Opens the discussion tree, sets the keybindings. It also ---creates the tree for notes (which are not linked to specific lines of code) ---@param callback function? @@ -327,7 +342,15 @@ end -- This function (settings.discussion_tree.toggle_discussion_resolved) will toggle the resolved status of the current discussion and send the change to the Go server M.toggle_discussion_resolved = function(tree) local note = tree:get_node() - if not note or not note.resolvable then + if note == nil then + return + end + + -- Switch to the root node to enable toggling from child nodes and note bodies + if not note.resolvable and M.is_node_note(note) then + note = M.get_root_node(tree, note) + end + if note == nil then return end @@ -370,6 +393,15 @@ M.toggle_node = function(tree) if node == nil then return end + + -- Switch to the "note" node from "note_body" nodes to enable toggling discussions inside comments + if node.type == "note_body" then + node = tree:get_node(node:get_parent_id()) + end + if node == nil then + return + end + local children = node:get_child_ids() if node == nil then return @@ -401,7 +433,7 @@ end ---This function (settings.discussion_tree.toggle_nodes) expands/collapses all nodes and their children according to the opts. ---@param tree NuiTree ---@param opts ToggleNodesOptions -M.toggle_nodes = function(tree, opts) +M.toggle_nodes = function(tree, unlinked, opts) local current_node = tree:get_node() if current_node == nil then return @@ -409,25 +441,41 @@ M.toggle_nodes = function(tree, opts) local root_node = M.get_root_node(tree, current_node) for _, node in ipairs(tree:get_nodes()) do if opts.toggle_resolved then - if state.resolved_expanded then + if + (unlinked and state.unlinked_discussion_tree.resolved_expanded) + or (not unlinked and state.discussion_tree.resolved_expanded) + then M.collapse_recursively(tree, node, root_node, opts.keep_current_open, true) else M.expand_recursively(tree, node, true) end end if opts.toggle_unresolved then - if state.unresolved_expanded then + if + (unlinked and state.unlinked_discussion_tree.unresolved_expanded) + or (not unlinked and state.discussion_tree.unresolved_expanded) + then M.collapse_recursively(tree, node, root_node, opts.keep_current_open, false) else M.expand_recursively(tree, node, false) end end end + -- Reset states of resolved discussions after toggling if opts.toggle_resolved then - state.resolved_expanded = not state.resolved_expanded + if unlinked then + state.unlinked_discussion_tree.resolved_expanded = not state.unlinked_discussion_tree.resolved_expanded + else + state.discussion_tree.resolved_expanded = not state.discussion_tree.resolved_expanded + end end + -- Reset states of unresolved discussions after toggling if opts.toggle_unresolved then - state.unresolved_expanded = not state.unresolved_expanded + if unlinked then + state.unlinked_discussion_tree.unresolved_expanded = not state.unlinked_discussion_tree.unresolved_expanded + else + state.discussion_tree.unresolved_expanded = not state.discussion_tree.unresolved_expanded + end end tree:render() M.restore_cursor_position(tree, current_node, root_node) @@ -543,6 +591,8 @@ M.rebuild_discussion_tree = function() M.discussion_tree = discussion_tree M.switch_can_edit_bufs(false) vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_bufnr }) + state.discussion_tree.resolved_expanded = false + state.discussion_tree.unresolved_expanded = false end M.rebuild_unlinked_discussion_tree = function() @@ -561,6 +611,8 @@ M.rebuild_unlinked_discussion_tree = function() M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_bufnr, true) M.unlinked_discussion_tree = unlinked_discussion_tree M.switch_can_edit_bufs(false) + state.unlinked_discussion_tree.resolved_expanded = false + state.unlinked_discussion_tree.unresolved_expanded = false end M.switch_can_edit_bufs = function(bool) @@ -643,6 +695,9 @@ M.is_current_node_note = function(tree) end M.set_tree_keymaps = function(tree, bufnr, unlinked) + vim.keymap.set("n", state.settings.discussion_tree.toggle_tree_type, function() + M.toggle_tree_type(unlinked) + end, { buffer = bufnr, desc = "Toggle tree type between `simple` and `by_file_name`" }) vim.keymap.set("n", state.settings.discussion_tree.edit_comment, function() if M.is_current_node_note(tree) then M.edit_comment(tree, unlinked) @@ -662,21 +717,21 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) M.toggle_node(tree) end, { buffer = bufnr, desc = "Toggle node" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_all_discussions, function() - M.toggle_nodes(tree, { + M.toggle_nodes(tree, unlinked, { toggle_resolved = true, toggle_unresolved = true, keep_current_open = state.settings.discussion_tree.keep_current_open, }) end, { buffer = bufnr, desc = "Toggle all nodes" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_resolved_discussions, function() - M.toggle_nodes(tree, { + M.toggle_nodes(tree, unlinked, { toggle_resolved = true, toggle_unresolved = false, keep_current_open = state.settings.discussion_tree.keep_current_open, }) end, { buffer = bufnr, desc = "Toggle resolved nodes" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_unresolved_discussions, function() - M.toggle_nodes(tree, { + M.toggle_nodes(tree, unlinked, { toggle_resolved = false, toggle_unresolved = true, keep_current_open = state.settings.discussion_tree.keep_current_open, diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index c7291b50..da325eb5 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -40,12 +40,13 @@ local function content(discussions, unlinked_discussions, file_name) resolved_discussions = resolved_discussions, resolvable_notes = resolvable_notes, resolved_notes = resolved_notes, + help_keymap = state.settings.help, } return state.settings.discussion_tree.winbar(t) end ----This function sends the edited comment to the Go server +---This function updates the winbar ---@param discussions Discussion[] ---@param unlinked_discussions UnlinkedDiscussion[] ---@param base_title string diff --git a/lua/gitlab/emoji.lua b/lua/gitlab/emoji.lua index 617ffabf..1d61dafd 100644 --- a/lua/gitlab/emoji.lua +++ b/lua/gitlab/emoji.lua @@ -70,7 +70,7 @@ M.init_popup = function(tree, bufnr) vim.api.nvim_create_autocmd({ "CursorHold" }, { callback = function() local node = tree:get_node() - if node == nil then + if node == nil or not require("gitlab.actions.discussions").is_node_note(node) then return end diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index feab6643..992e66ba 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -64,6 +64,7 @@ M.settings = { resolved = "✓", unresolved = "-", tree_type = "simple", + toggle_tree_type = "i", ---@param t WinbarTable winbar = function(t) local discussions_content = t.resolvable_discussions ~= 0 @@ -79,7 +80,8 @@ M.settings = { discussions_content = "%#Comment#" .. discussions_content notes_content = "%#Text#" .. notes_content end - return " " .. discussions_content .. " %#Comment#| " .. notes_content + local help = "%#Comment#%=Help: " .. t.help_keymap:gsub(" ", "") .. " " + return " " .. discussions_content .. " %#Comment#| " .. notes_content .. help end, }, merge = { @@ -169,6 +171,16 @@ M.settings = { }, } +-- These are the initial states of the discussion trees +M.discussion_tree = { + resolved_expanded = false, + unresolved_expanded = false, +} +M.unlinked_discussion_tree = { + resolved_expanded = false, + unresolved_expanded = false, +} + -- Merges user settings into the default settings, overriding them M.merge_settings = function(args) M.settings = u.merge(M.settings, args) From d985d71110532bebafd7d83bae1b0b307bad17bf Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Tue, 27 Feb 2024 18:39:52 -0500 Subject: [PATCH 05/97] Fix Multi Line Issues (Large Refactor) (#197) Fix: Multi-line discussions. The calculation of a range for a multiline comment has been consolidated and moved into the location.lua file. This does not attempt to fix diagnostics. Refactor: It refactors the discussions code to split hunk parsing and management into a separate module Fix: Don't allow comments on modified buffers #194 by preventing comments on the reviewer when using --imply-local and when the working tree is dirty entirely. Refactor: It introduces a new List class for data aggregation, filtering, etc. Fix: It removes redundant API calls and refreshes from the discussion pane --- .github/CONTRIBUTING.md | 2 +- .github/workflows/go.yaml | 1 + .github/workflows/lua.yaml | 1 + .../actions/assignees_and_reviewers.lua | 9 +- lua/gitlab/actions/comment.lua | 60 +-- lua/gitlab/actions/create_mr.lua | 3 +- .../actions/discussions/annotations.lua | 19 + lua/gitlab/actions/discussions/init.lua | 115 +++-- lua/gitlab/actions/discussions/winbar.lua | 31 +- lua/gitlab/actions/help.lua | 9 +- lua/gitlab/actions/labels.lua | 17 +- lua/gitlab/actions/summary.lua | 10 +- lua/gitlab/git.lua | 11 + lua/gitlab/hunks/init.lua | 281 ++++++++++++ lua/gitlab/init.lua | 1 + lua/gitlab/reviewer/diffview.lua | 432 ------------------ lua/gitlab/reviewer/init.lua | 344 +++++++++++--- lua/gitlab/reviewer/location.lua | 206 +++++++++ lua/gitlab/server.lua | 9 +- lua/gitlab/utils/init.lua | 192 ++------ lua/gitlab/utils/list.lua | 66 +++ 21 files changed, 1046 insertions(+), 773 deletions(-) create mode 100644 lua/gitlab/git.lua create mode 100644 lua/gitlab/hunks/init.lua delete mode 100755 lua/gitlab/reviewer/diffview.lua create mode 100755 lua/gitlab/reviewer/location.lua create mode 100644 lua/gitlab/utils/list.lua diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index eab6cb31..0a7679fd 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -49,6 +49,6 @@ $ luacheck --globals vim busted --no-max-line-length -- . Please provide a description of the feature, and links to any relevant issues. -That's it! I'll try to respond to any incoming merge request in a few days. Once we've reviewed it, it will be merged into the develop branc, it will be merged into the develop branch. +That's it! I'll try to respond to any incoming merge request in a few days. Once we've reviewed it, it will be merged into the develop branch. After some time, if the develop branch is found to be stable, that branch will be merged into `main` and released. When merged into `main` the pipeline will detect whether we're merging in a patch, minor, or major change, and create a new tag (e.g. 1.0.12) and release. diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index e1c997d7..ce68a779 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -3,6 +3,7 @@ on: pull_request: branches: - main + - develop jobs: go_lint: name: Lint Go 💅 diff --git a/.github/workflows/lua.yaml b/.github/workflows/lua.yaml index 12d5b785..71598d94 100644 --- a/.github/workflows/lua.yaml +++ b/.github/workflows/lua.yaml @@ -3,6 +3,7 @@ on: pull_request: branches: - main + - develop jobs: lua_lint: name: Lint Lua 💅 diff --git a/lua/gitlab/actions/assignees_and_reviewers.lua b/lua/gitlab/actions/assignees_and_reviewers.lua index 8c99cbca..329f3825 100644 --- a/lua/gitlab/actions/assignees_and_reviewers.lua +++ b/lua/gitlab/actions/assignees_and_reviewers.lua @@ -2,6 +2,7 @@ -- and assignees in Gitlab, those who must review an MR. local u = require("gitlab.utils") local job = require("gitlab.job") +local List = require("gitlab.utils.list") local state = require("gitlab.state") local M = {} @@ -67,13 +68,11 @@ end M.filter_eligible = function(current, to_remove) local ids = u.extract(to_remove, "id") - local res = {} - for _, member in ipairs(current) do + return List.new(current):filter(function(member) if not u.contains(ids, member.id) then - table.insert(res, member) + return true end - end - return res + end) end return M diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 580fb00e..e81b4639 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -5,9 +5,11 @@ local Popup = require("nui.popup") 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 miscellaneous = require("gitlab.actions.miscellaneous") local reviewer = require("gitlab.reviewer") +local Location = require("gitlab.reviewer.location") local M = {} -- Popup creation is wrapped in a function so that it is performed *after* user @@ -20,6 +22,14 @@ 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 = git.has_clean_tree() + if state.settings.reviewer_settings.diffview.imply_local and not has_clean_tree then + u.notify( + "Cannot leave comments on a dirty tree. \n Please stash all local changes or push them up.", + vim.log.levels.WARN + ) + return + end local comment_popup = create_comment_popup() comment_popup:mount() state.set_popup_keymaps(comment_popup, function(text) @@ -95,30 +105,11 @@ M.create_note = function() end, miscellaneous.attach_file) end ----@class LineRange ----@field start_line integer ----@field end_line integer - ----@class ReviewerLineInfo ----@field old_line integer ----@field new_line integer ----@field type string either "new" or "old" - ----@class ReviewerRangeInfo ----@field start ReviewerLineInfo ----@field end ReviewerLineInfo - ----@class ReviewerInfo ----@field file_name string ----@field old_line integer | nil ----@field new_line integer | nil ----@field range_info ReviewerRangeInfo|nil - ---This function (settings.popup.perform_action) will send the comment to the Go server ---@param text string comment text ----@param range LineRange | nil range of visuel selection or nil +---@param visual_range LineRange | nil range of visual selection or nil ---@param unlinked boolean | nil if true, the comment is not linked to a line -M.confirm_create_comment = function(text, range, unlinked) +M.confirm_create_comment = function(text, visual_range, unlinked) if text == nil then u.notify("Reviewer did not provide text of change", vim.log.levels.ERROR) return @@ -129,33 +120,42 @@ M.confirm_create_comment = function(text, range, unlinked) job.run_job("/mr/comment", "POST", body, function(data) u.notify("Note created!", vim.log.levels.INFO) discussions.add_discussion({ data = data, unlinked = true }) - discussions.refresh_discussion_data() + discussions.refresh() end) return end - local reviewer_info = reviewer.get_location(range) - if not reviewer_info then + 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 body = { + type = "text", comment = text, - file_name = reviewer_info.file_name, - old_line = reviewer_info.old_line, - new_line = reviewer_info.new_line, + 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, - type = "text", - line_range = reviewer_info.range_info, + old_line = location_data.old_line, + new_line = location_data.new_line, + line_range = location_data.line_range, } job.run_job("/mr/comment", "POST", body, function(data) u.notify("Comment created!", vim.log.levels.INFO) discussions.add_discussion({ data = data, unlinked = false }) - discussions.refresh_discussion_data() + discussions.refresh() end) end diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua index 423b8c8e..9e51dc74 100644 --- a/lua/gitlab/actions/create_mr.lua +++ b/lua/gitlab/actions/create_mr.lua @@ -5,6 +5,7 @@ local Input = require("nui.input") local Popup = require("nui.popup") local job = require("gitlab.job") local u = require("gitlab.utils") +local git = require("gitlab.git") local state = require("gitlab.state") local miscellaneous = require("gitlab.actions.miscellaneous") @@ -124,7 +125,7 @@ M.pick_target = function(args) end local function make_template_path(t) - local base_dir = vim.fn.trim(vim.fn.system({ "git", "rev-parse", "--show-toplevel" })) + local base_dir = git.base_dir() return base_dir .. state.settings.file_separator .. ".gitlab" diff --git a/lua/gitlab/actions/discussions/annotations.lua b/lua/gitlab/actions/discussions/annotations.lua index 72c6db7b..92bbf4e5 100644 --- a/lua/gitlab/actions/discussions/annotations.lua +++ b/lua/gitlab/actions/discussions/annotations.lua @@ -101,3 +101,22 @@ ---@field user_data table ---@field source string ---@field code string? + +---@class LineRange +---@field start_line integer +---@field end_line integer + +---@class DiffviewInfo +---@field modification_type string +---@field file_name string +---@field current_bufnr integer +---@field new_sha_win_id integer +---@field old_sha_win_id integer +---@field opposite_bufnr integer +---@field new_line_from_buf integer +---@field old_line_from_buf integer + +---@class LocationData +---@field old_line integer | nil +---@field new_line integer | nil +---@field line_range ReviewerRangeInfo|nil diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 682971aa..17df478f 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -9,8 +9,10 @@ local job = require("gitlab.job") local u = require("gitlab.utils") local state = require("gitlab.state") local reviewer = require("gitlab.reviewer") +local List = require("gitlab.utils.list") local miscellaneous = require("gitlab.actions.miscellaneous") local discussions_tree = require("gitlab.actions.discussions.tree") +local diffview_lib = require("diffview.lib") local signs = require("gitlab.actions.discussions.signs") local winbar = require("gitlab.actions.discussions.winbar") local help = require("gitlab.actions.help") @@ -53,28 +55,55 @@ end ---Initialize everything for discussions like setup of signs, callbacks for reviewer, etc. M.initialize_discussions = function() signs.setup_signs() - -- Setup callback to refresh discussion data, discussion signs and diagnostics whenever the reviewed file changes. - reviewer.set_callback_for_file_changed(M.refresh_discussion_data) - -- Setup callback to clear signs and diagnostics whenever reviewer is left. - reviewer.set_callback_for_reviewer_leave(signs.clear_signs_and_diagnostics) + reviewer.set_callback_for_file_changed(function() + M.refresh_view() + M.modifiable(false) + end) + reviewer.set_callback_for_reviewer_enter(function() + M.modifiable(false) + end) + reviewer.set_callback_for_reviewer_leave(function() + signs.clear_signs_and_diagnostics() + M.modifiable(true) + end) +end + +--- Ensures that the both buffers in the reviewer are/not modifiable. Relevant if the user is using +--- the --imply-local setting +M.modifiable = function(bool) + local view = diffview_lib.get_current_view() + local a = view.cur_layout.a.file.bufnr + local b = view.cur_layout.b.file.bufnr + if vim.api.nvim_buf_is_loaded(a) then + vim.api.nvim_buf_set_option(a, "modifiable", bool) + end + if vim.api.nvim_buf_is_loaded(b) then + vim.api.nvim_buf_set_option(b, "modifiable", bool) + end end ---Refresh discussion data, signs, diagnostics, and winbar with new data from API -M.refresh_discussion_data = function() +--- and rebuild the entire view +M.refresh = function() M.load_discussions(function() - if state.settings.discussion_sign.enabled then - signs.refresh_signs(M.discussions) - end - if state.settings.discussion_diagnostic.enabled then - signs.refresh_diagnostics(M.discussions) - end - if M.split_visible then - local linked_is_focused = M.linked_bufnr == M.focused_bufnr - winbar.update_winbar(M.discussions, M.unlinked_discussions, linked_is_focused and "Discussions" or "Notes") - end + M.refresh_view() end) end +--- Take existing data and refresh the diagnostics, the winbar, and the signs +M.refresh_view = function() + if state.settings.discussion_sign.enabled then + signs.refresh_signs(M.discussions) + end + if state.settings.discussion_diagnostic.enabled then + signs.refresh_diagnostics(M.discussions) + end + if M.split_visible then + local linked_is_focused = M.linked_bufnr == M.focused_bufnr + winbar.update_winbar(M.discussions, M.unlinked_discussions, linked_is_focused and "Discussions" or "Notes") + end +end + ---Toggle Discussions tree type between "simple" and "by_file_name" ---@param unlinked boolean True if selected view type is Notes (unlinked discussions) M.toggle_tree_type = function(unlinked) @@ -148,6 +177,7 @@ M.toggle = function(callback) end) end +-- Change between views in the discussion panel, either notes or discussions local switch_view_type = function() local change_to_unlinked = M.linked_bufnr == M.focused_bufnr local new_bufnr = change_to_unlinked and M.unlinked_bufnr or M.linked_bufnr @@ -254,36 +284,23 @@ end -- This function will actually send the deletion to Gitlab -- when you make a selection, and re-render the tree -M.send_deletion = function(tree, unlinked) +M.send_deletion = function(tree) 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) local note_id = note_node.is_root and root_node.root_note_id or note_node.id - local body = { discussion_id = root_node.id, note_id = tonumber(note_id) } - job.run_job("/mr/comment", "DELETE", body, function(data) u.notify(data.message, vim.log.levels.INFO) - if not note_node.is_root then - tree:remove_node("-" .. note_id) -- Note is not a discussion root, safe to remove - tree:render() + if note_node.is_root then + -- Replace root node w/ current node's contents... + tree:remove_node("-" .. root_node.id) else - 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 - M.add_empty_titles({ - { M.linked_bufnr, M.discussions, "No Discussions for this MR" }, - { M.unlinked_bufnr, M.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" }, - }) - M.switch_can_edit_bufs(false) + tree:remove_node("-" .. note_id) end - - M.refresh_discussion_data() + tree:render() + M.refresh() end) end @@ -293,18 +310,22 @@ 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) + if note_node == nil or root_node == nil then + u.notify("Could not get root or note node", vim.log.levels.ERROR) + return + end 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 + -- Gather all lines from immediate children that aren't note nodes + local lines = List.new(note_node:get_child_ids()):reduce(function(agg, child_id) local child_node = tree:get_node(child_id) if not child_node:has_children() then local line = tree:get_node(child_id).text - table.insert(lines, line) + table.insert(agg, line) end - end + return agg + end, {}) local currentBuffer = vim.api.nvim_get_current_buf() vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines) @@ -362,7 +383,7 @@ M.toggle_discussion_resolved = function(tree) job.run_job("/mr/discussions/resolve", "PUT", body, function(data) u.notify(data.message, vim.log.levels.INFO) M.redraw_resolved_status(tree, note, not note.resolved) - M.refresh_discussion_data() + M.refresh() end) end @@ -373,7 +394,17 @@ M.jump_to_reviewer = function(tree) u.notify(error, vim.log.levels.ERROR) return end - reviewer.jump(file_name, new_line, old_line, { is_undefined_type = is_undefined_type }) + + local new_line_int = tonumber(new_line) + local old_line_int = tonumber(old_line) + + if new_line_int == nil and old_line_int == nil then + u.notify("Could not get new or old line", vim.log.levels.ERROR) + return + end + + reviewer.jump(file_name, new_line_int, old_line_int, { is_undefined_type = is_undefined_type }) + M.refresh_view() end -- This function (settings.discussion_tree.jump_to_file) will jump to the file changed in a new tab diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index da325eb5..ada616c9 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -1,28 +1,31 @@ local M = {} local state = require("gitlab.state") +local List = require("gitlab.utils.list") ---@param nodes Discussion[]|UnlinkedDiscussion[]|nil +---@return number, number local get_data = function(nodes) - if nodes == nil then - return 0, 0 - end local total_resolvable = 0 local total_resolved = 0 - if nodes == vim.NIL then - return "" + if nodes == nil or nodes == vim.NIL then + return total_resolvable, total_resolved end - for _, d in ipairs(nodes) do + total_resolvable = List.new(nodes):reduce(function(agg, d) local first_child = d.notes[1] - if first_child ~= nil then - if first_child.resolvable then - total_resolvable = total_resolvable + 1 - end - if first_child.resolved then - total_resolved = total_resolved + 1 - end + if first_child and first_child.resolvable then + agg = agg + 1 end - end + return agg + end, 0) + + total_resolved = List.new(nodes):reduce(function(agg, d) + local first_child = d.notes[1] + if first_child and first_child.resolved then + agg = agg + 1 + end + return agg + end, 0) return total_resolvable, total_resolved end diff --git a/lua/gitlab/actions/help.lua b/lua/gitlab/actions/help.lua index d588ba28..1db04489 100644 --- a/lua/gitlab/actions/help.lua +++ b/lua/gitlab/actions/help.lua @@ -2,18 +2,19 @@ local M = {} local u = require("gitlab.utils") local state = require("gitlab.state") +local List = require("gitlab.utils.list") local Popup = require("nui.popup") M.open = function() local bufnr = vim.api.nvim_get_current_buf() local keymaps = vim.api.nvim_buf_get_keymap(bufnr, "n") - local help_content_lines = {} - for _, keymap in ipairs(keymaps) do + local help_content_lines = List.new(keymaps):reduce(function(agg, keymap) if keymap.desc ~= nil then local new_line = string.format("%s: %s", keymap.lhs:gsub(" ", ""), keymap.desc) - table.insert(help_content_lines, new_line) + table.insert(agg, new_line) end - end + return agg + end, {}) local longest_line = u.get_longest_string(help_content_lines) local help_popup = Popup(u.create_popup_state("Help", state.settings.popup.help, longest_line + 3, #help_content_lines + 3, 60)) diff --git a/lua/gitlab/actions/labels.lua b/lua/gitlab/actions/labels.lua index 0ef1c26b..3b25016d 100644 --- a/lua/gitlab/actions/labels.lua +++ b/lua/gitlab/actions/labels.lua @@ -3,6 +3,7 @@ local u = require("gitlab.utils") local job = require("gitlab.job") local state = require("gitlab.state") +local List = require("gitlab.utils.list") local M = {} M.add_label = function() @@ -14,11 +15,9 @@ M.delete_label = function() end local refresh_label_state = function(labels) - local new_labels = "" - for _, label in ipairs(labels) do - new_labels = new_labels .. "," .. label - end - state.INFO.labels = new_labels + state.INFO.labels = List.new(labels):reduce(function(agg, label) + return agg .. "," .. label + end, "") end local get_current_labels = function() @@ -31,11 +30,9 @@ local get_current_labels = function() end local get_all_labels = function() - local labels = {} - for _, label in ipairs(state.LABELS) do -- How can we use the colors?? - table.insert(labels, label.Name) - end - return labels + return List.new(state.LABELS):map(function(label) + return label.Name + end) end M.add_popup = function(type) diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 55cb87ad..ac10ce7e 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -5,6 +5,7 @@ local Layout = require("nui.layout") local Popup = require("nui.popup") local job = require("gitlab.job") local u = require("gitlab.utils") +local List = require("gitlab.utils.list") local state = require("gitlab.state") local miscellaneous = require("gitlab.actions.miscellaneous") local pipeline = require("gitlab.actions.pipeline") @@ -159,8 +160,7 @@ M.build_info_lines = function() return string.rep(" ", offset + 3) end - local lines = {} - for _, v in ipairs(state.settings.info.fields) do + return List.new(state.settings.info.fields):map(function(v) if v == "merge_status" then v = "detailed_merge_status" end @@ -174,10 +174,8 @@ M.build_info_lines = function() else line = line .. row.content end - table.insert(lines, line) - end - - return lines + return line + end) end -- This function will PUT the new description to the Go server diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua new file mode 100644 index 00000000..8d48fb45 --- /dev/null +++ b/lua/gitlab/git.lua @@ -0,0 +1,11 @@ +local M = {} + +M.has_clean_tree = function() + return vim.fn.trim(vim.fn.system({ "git", "status", "--short", "--untracked-files=no" })) == "" +end + +M.base_dir = function() + return vim.fn.trim(vim.fn.system({ "git", "rev-parse", "--show-toplevel" })) +end + +return M diff --git a/lua/gitlab/hunks/init.lua b/lua/gitlab/hunks/init.lua new file mode 100644 index 00000000..90af06eb --- /dev/null +++ b/lua/gitlab/hunks/init.lua @@ -0,0 +1,281 @@ +local List = require("gitlab.utils.list") +local u = require("gitlab.utils") +local state = require("gitlab.state") +local M = {} + +---@class Hunk +---@field old_line integer +---@field old_range integer +---@field new_line integer +---@field new_range integer + +---@class HunksAndDiff +---@field hunks Hunk[] list of hunks +---@field all_diff_output table The data from the git diff command + +---Turn hunk line into Lua table +---@param line table +---@return Hunk|nil +M.parse_possible_hunk_headers = function(line) + if line:sub(1, 2) == "@@" then + -- match: + -- @@ -23 +23 @@ ... + -- @@ -23,0 +23 @@ ... + -- @@ -41,0 +42,4 @@ ... + local old_start, old_range, new_start, new_range = line:match("@@+ %-(%d+),?(%d*) %+(%d+),?(%d*) @@+") + + return { + old_line = tonumber(old_start), + old_range = tonumber(old_range) or 0, + new_line = tonumber(new_start), + new_range = tonumber(new_range) or 0, + } + end +end +---@param linnr number +---@param hunk Hunk +---@param all_diff_output table +local line_was_removed = function(linnr, hunk, all_diff_output) + for matching_line_index, line in ipairs(all_diff_output) do + local found_hunk = M.parse_possible_hunk_headers(line) + if found_hunk ~= nil and vim.deep_equal(found_hunk, hunk) then + -- We found a matching hunk, now we need to iterate over the lines from the raw diff output + -- at that hunk until we reach the line we are looking for. When the indexes match we check + -- to see if that line is deleted or not. + for hunk_line_index = found_hunk.old_line, hunk.old_line + hunk.old_range - 1, 1 do + local line_content = all_diff_output[matching_line_index + 1] + if hunk_line_index == linnr then + if string.match(line_content, "^%-") then + return "deleted" + end + end + end + end + end +end + +---@param linnr number +---@param hunk Hunk +---@param all_diff_output table +local line_was_added = function(linnr, hunk, all_diff_output) + for matching_line_index, line in ipairs(all_diff_output) do + local found_hunk = M.parse_possible_hunk_headers(line) + if found_hunk ~= nil and vim.deep_equal(found_hunk, hunk) then + -- For added lines, we only want to iterate over the part of the diff that has has new lines, + -- so we skip over the old range. We then keep track of the increment to the original new line index, + -- and iterate until we reach the end of the total range of this hunk. If we arrive at the matching + -- index for the line number, we check to see if the line was added. + local i = 0 + local old_range = (found_hunk.old_range == 0 and found_hunk.old_line ~= 0) and 1 or found_hunk.old_range + for hunk_line_index = matching_line_index + old_range + 1, matching_line_index + old_range + found_hunk.new_range, 1 do + local line_content = all_diff_output[hunk_line_index] + if (found_hunk.new_line + i) == linnr then + if string.match(line_content, "^%+") then + return "added" + end + end + i = i + 1 + end + end + end +end + +---Parse git diff hunks. +---@param file_path string Path to file. +---@param base_branch string Git base branch of merge request. +---@return HunksAndDiff +local parse_hunks_and_diff = function(file_path, base_branch) + local hunks = {} + local all_diff_output = {} + + local Job = require("plenary.job") + + local diff_job = Job:new({ + command = "git", + args = { "diff", "--minimal", "--unified=0", "--no-color", base_branch, "--", file_path }, + on_exit = function(j, return_code) + if return_code == 0 then + all_diff_output = j:result() + for _, line in ipairs(all_diff_output) do + local hunk = M.parse_possible_hunk_headers(line) + if hunk ~= nil then + table.insert(hunks, hunk) + end + end + else + M.notify("Failed to get git diff: " .. j:stderr(), vim.log.levels.WARN) + end + end, + }) + + diff_job:sync() + + return { hunks = hunks, all_diff_output = all_diff_output } +end + +-- Parses the lines from a diff and returns the +-- index of the next hunk, when provided an initial index +---@param lines table +---@param i integer +---@return integer|nil +local next_hunk_index = function(lines, i) + for j, line in ipairs(lines) do + local hunk = M.parse_possible_hunk_headers(line) + if hunk ~= nil and j > i then + return j + end + end + return nil +end + +--- Processes the number of changes until the target is reached. This returns +--- a negative or positive number indicating the number of lines in the hunk +--that have been added or removed prior to the target line +---comment +---@param line_number number +---@param hunk Hunk +---@param lines table +---@return integer +local net_changed_in_hunk_before_line = function(line_number, hunk, lines) + local net_lines = 0 + local current_line_old = hunk.old_line + + for _, line in ipairs(lines) do + if line:sub(1, 1) == "-" then + if current_line_old < line_number then + net_lines = net_lines - 1 + end + current_line_old = current_line_old + 1 + elseif line:sub(1, 1) == "+" then + if current_line_old < line_number then + net_lines = net_lines + 1 + end + else + current_line_old = current_line_old + 1 + end + end + + return net_lines +end + +---Counts the total number of changes in a set of lines, can be positive if added lines or negative if removed lines +---@param lines table +---@return integer +local count_changes = function(lines) + local total = 0 + for _, line in ipairs(lines) do + if line:match("^%+") then + total = total + 1 + else + total = total - 1 + end + end + return total +end + +---Returns whether the comment is on a deleted line, added line, or unmodified line. +---This is in order to build the payload for Gitlab correctly by setting the old line and new line. +---@param old_line number|nil +---@param new_line number|nil +---@param current_file string +---@return string|nil +function M.get_modification_type(old_line, new_line, current_file) + local hunk_and_diff_data = parse_hunks_and_diff(current_file, state.INFO.target_branch) + if hunk_and_diff_data.hunks == nil then + return + end + + local hunks = hunk_and_diff_data.hunks + local all_diff_output = hunk_and_diff_data.all_diff_output + + local is_current_sha = require("gitlab.reviewer").is_current_sha() + + for _, hunk in ipairs(hunks) do + local old_line_end = hunk.old_line + hunk.old_range + local new_line_end = hunk.new_line + hunk.new_range + + if is_current_sha then + -- If it is a single line change and neither hunk has a range, then it's added + if new_line >= hunk.new_line and new_line <= new_line_end then + if hunk.new_range == 0 and hunk.old_range == 0 then + return "added" + end + -- If leaving a comment on the new window, we may be commenting on an added line + -- or on an unmodified line. To tell, we have to check whether the line itself is + -- prefixed with "+" and only return "added" if it is. + if line_was_added(new_line, hunk, all_diff_output) then + return "added" + end + end + else + -- It's a deletion if it's in the range of the hunks and the new + -- range is zero, since that is only a deletion hunk, or if we find + -- a match in another hunk with a range, and the corresponding line is prefixed + -- with a "-" only. If it is, then it's a deletion. + if old_line >= hunk.old_line and old_line <= old_line_end and hunk.old_range == 0 then + return "deleted" + end + if + (old_line >= hunk.old_line and old_line <= old_line_end) + or (old_line >= hunk.new_line and new_line <= new_line_end) + then + if line_was_removed(old_line, hunk, all_diff_output) then + return "deleted" + end + end + end + end + + -- If we can't find the line, this means the user is either trying to leave + -- a comment on an unchanged line in the new or old file SHA. This is only + -- allowed in the old file + return is_current_sha and "bad_file_unmodified" or "unmodified" +end + +---Returns the matching line number of a line in the new/old version of the file compared to the current SHA. +---@param old_sha string +---@param new_sha string +---@param file_path string +---@param line_number number +---@return number|nil +M.calculate_matching_line_new = function(old_sha, new_sha, file_path, line_number) + local net_change = 0 + local diff_cmd = string.format("git diff --minimal --unified=0 --no-color %s %s -- %s", old_sha, new_sha, file_path) + local handle = io.popen(diff_cmd) + if handle == nil then + u.notify(string.format("Error running git diff command for %s", file_path), vim.log.levels.ERROR) + return nil + end + + local all_lines = List.new({}) + for line in handle:lines() do + table.insert(all_lines, line) + end + + for i, line in ipairs(all_lines) do + local hunk = M.parse_possible_hunk_headers(line) + if hunk ~= nil then + if line_number <= hunk.old_line then + -- We have reached a hunk which starts after our target, return the changed total lines + return line_number + net_change + end + + local n = next_hunk_index(all_lines, i) or #all_lines + local diff_lines = all_lines:slice(i + 1, n - 1) + + -- If the line is IN the hunk, process the hunk and return the change until that line + if line_number >= hunk.old_line and line_number < hunk.old_line + hunk.old_range then + net_change = line_number + net_change + net_changed_in_hunk_before_line(line_number, hunk, diff_lines) + return net_change + end + + -- If it's not it's after this hunk, just add all the changes and keep iterating + net_change = net_change + count_changes(diff_lines) + end + end + + -- TODO: Possibly handle lines that are out of range in the new files + return line_number +end + +return M diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index ab91d9dc..4a2ee718 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -1,3 +1,4 @@ +require("gitlab.utils.list") local u = require("gitlab.utils") local async = require("gitlab.async") local server = require("gitlab.server") diff --git a/lua/gitlab/reviewer/diffview.lua b/lua/gitlab/reviewer/diffview.lua deleted file mode 100755 index fe62e4e5..00000000 --- a/lua/gitlab/reviewer/diffview.lua +++ /dev/null @@ -1,432 +0,0 @@ --- This Module contains all of the reviewer code for diffview -local u = require("gitlab.utils") -local state = require("gitlab.state") -local async_ok, async = pcall(require, "diffview.async") -local diffview_lib = require("diffview.lib") - -local M = { - bufnr = nil, - tabnr = nil, -} - -local all_git_manged_files_unmodified = function() - -- check local managed files are unmodified, matching the state in the MR - -- TODO: ensure correct CWD? - return vim.fn.trim(vim.fn.system({ "git", "status", "--short", "--untracked-files=no" })) == "" -end - -M.open = function() - local diff_refs = state.INFO.diff_refs - if diff_refs == nil then - u.notify("Gitlab did not provide diff refs required to review this MR", vim.log.levels.ERROR) - return - end - - if diff_refs.base_sha == "" or diff_refs.head_sha == "" then - u.notify("Merge request contains no changes", vim.log.levels.ERROR) - return - end - - local diffview_open_command = "DiffviewOpen" - local diffview_feature_imply_local = { - user_requested = state.settings.reviewer_settings.diffview.imply_local, - usable = all_git_manged_files_unmodified(), - } - if diffview_feature_imply_local.user_requested and diffview_feature_imply_local.usable then - diffview_open_command = diffview_open_command .. " --imply-local" - end - - vim.api.nvim_command(string.format("%s %s..%s", diffview_open_command, diff_refs.base_sha, diff_refs.head_sha)) - M.tabnr = vim.api.nvim_get_current_tabpage() - - if diffview_feature_imply_local.user_requested and not diffview_feature_imply_local.usable then - u.notify( - "There are uncommited changes in the working tree, cannot use 'imply_local' setting for gitlab reviews. Stash or commit all changes to use.", - vim.log.levels.WARN - ) - end - - if state.INFO.has_conflicts then - u.notify("This merge request has conflicts!", vim.log.levels.WARN) - end - - -- Register Diffview hook for close event to set tab page # to nil - local on_diffview_closed = function(view) - if view.tabpage == M.tabnr then - M.tabnr = nil - end - end - require("diffview.config").user_emitter:on("view_closed", function(_, ...) - on_diffview_closed(...) - end) - - if state.settings.discussion_tree.auto_open then - local discussions = require("gitlab.actions.discussions") - discussions.close() - discussions.toggle() - end -end - -M.close = function() - vim.cmd("DiffviewClose") - local discussions = require("gitlab.actions.discussions") - discussions.close() -end - -M.jump = function(file_name, new_line, old_line, opts) - if M.tabnr == nil then - u.notify("Can't jump to Diffvew. Is it open?", vim.log.levels.ERROR) - return - end - vim.api.nvim_set_current_tabpage(M.tabnr) - vim.cmd("DiffviewFocusFiles") - local view = diffview_lib.get_current_view() - if view == nil then - u.notify("Could not find Diffview view", vim.log.levels.ERROR) - return - end - local files = view.panel:ordered_file_list() - local layout = view.cur_layout - for _, file in ipairs(files) do - if file.path == file_name then - if not async_ok then - u.notify("Could not load Diffview async", vim.log.levels.ERROR) - return - end - async.await(view:set_file(file)) - -- TODO: Ranged comments on unchanged lines will have both a - -- new line and a old line. - -- - -- The same is true when the user leaves a single-line comment - -- on an unchanged line in the "b" buffer. - -- - -- We need to distinguish them somehow from - -- range comments (which also have this) so that we can know - -- which buffer to jump to. Right now, we jump to the wrong - -- buffer for ranged comments on unchanged lines. - if new_line ~= nil and not opts.is_undefined_type then - layout.b:focus() - vim.api.nvim_win_set_cursor(0, { tonumber(new_line), 0 }) - elseif old_line ~= nil then - layout.a:focus() - vim.api.nvim_win_set_cursor(0, { tonumber(old_line), 0 }) - end - break - end - end -end - ----Get the location of a line within the diffview. If range is specified, then also the location ----of the lines in range. ----@param range LineRange | nil Line range to get location for ----@return ReviewerInfo | nil nil is returned only if error was encountered -M.get_location = function(range) - if M.tabnr == nil then - u.notify("Diffview reviewer must be initialized first", vim.log.levels.ERROR) - return - end - - -- If there's a range, use the start of the visual selection, not the current line - local current_line = range and range.start_line or vim.api.nvim_win_get_cursor(0)[1] - - -- Check if we are in the diffview tab - local tabnr = vim.api.nvim_get_current_tabpage() - if tabnr ~= M.tabnr then - u.notify("Line location can only be determined within reviewer window", vim.log.levels.ERROR) - return - end - - -- Check if we are in the diffview buffer - local view = diffview_lib.get_current_view() - if view == nil then - u.notify("Could not find Diffview view", vim.log.levels.ERROR) - return - end - - local layout = view.cur_layout - - ---@type ReviewerInfo - local reviewer_info = { - file_name = layout.a.file.path, - new_line = nil, - old_line = nil, - range_info = nil, - } - - local a_win = u.get_window_id_by_buffer_id(layout.a.file.bufnr) - local b_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr) - local current_win = vim.fn.win_getid() - local is_current_sha = current_win == b_win - - if a_win == nil or b_win == nil then - u.notify("Error retrieving window IDs for current files", vim.log.levels.ERROR) - return - end - - local current_file = M.get_current_file() - if current_file == nil then - u.notify("Error retrieving current file from Diffview", vim.log.levels.ERROR) - return - end - - local a_linenr = vim.api.nvim_win_get_cursor(a_win)[1] - local b_linenr = vim.api.nvim_win_get_cursor(b_win)[1] - - local data = u.parse_hunk_headers(current_file, state.INFO.target_branch) - - if data.hunks == nil then - u.notify("Could not parse hunks", vim.log.levels.ERROR) - return - end - - -- Will be different depending on focused window. - local modification_type = - M.get_modification_type(a_linenr, b_linenr, is_current_sha, data.hunks, data.all_diff_output) - - if modification_type == "bad_file_unmodified" then - u.notify("Comments on unmodified lines will be placed in the old file", vim.log.levels.WARN) - end - - -- Comment on new line: Include only new_line in payload. - if modification_type == "added" then - reviewer_info.old_line = nil - reviewer_info.new_line = b_linenr - -- Comment on deleted line: Include only new_line in payload. - elseif modification_type == "deleted" then - reviewer_info.old_line = a_linenr - reviewer_info.new_line = nil - -- The line was not found in any hunks, only send the old line number - elseif modification_type == "unmodified" or modification_type == "bad_file_unmodified" then - reviewer_info.old_line = a_linenr - reviewer_info.new_line = b_linenr - end - - if range == nil then - return reviewer_info - end - - -- If leaving a multi-line comment, we want to also add range_info to the payload. - local is_new = reviewer_info.new_line ~= nil - local current_line_info = is_new and u.get_lines_from_hunks(data.hunks, reviewer_info.new_line, is_new) - or u.get_lines_from_hunks(data.hunks, reviewer_info.old_line, is_new) - local type = is_new and "new" or "old" - - ---@type ReviewerRangeInfo - local range_info = { start = {}, ["end"] = {} } - - if current_line == range.start_line then - range_info.start.old_line = current_line_info.old_line - range_info.start.new_line = current_line_info.new_line - range_info.start.type = type - else - local start_line_info = u.get_lines_from_hunks(data.hunks, range.start_line, is_new) - range_info.start.old_line = start_line_info.old_line - range_info.start.new_line = start_line_info.new_line - range_info.start.type = type - end - if current_line == range.end_line then - range_info["end"].old_line = current_line_info.old_line - range_info["end"].new_line = current_line_info.new_line - range_info["end"].type = type - else - local end_line_info = u.get_lines_from_hunks(data.hunks, range.end_line, is_new) - range_info["end"].old_line = end_line_info.old_line - range_info["end"].new_line = end_line_info.new_line - range_info["end"].type = type - end - - reviewer_info.range_info = range_info - return reviewer_info -end - ----Return content between start_line and end_line ----@param start_line integer ----@param end_line integer ----@return string[] -M.get_lines = function(start_line, end_line) - return vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false) -end - ----Checks whether the lines in the two buffers are the same ----@return boolean -M.lines_are_same = function(layout, a_cursor, b_cursor) - local line_a = u.get_line_content(layout.a.file.bufnr, a_cursor) - local line_b = u.get_line_content(layout.b.file.bufnr, b_cursor) - return line_a == line_b -end - ----Get currently shown file -M.get_current_file = function() - local view = diffview_lib.get_current_view() - if not view then - return - end - return view.panel.cur_file.path -end - ----Place a sign in currently reviewed file. Use new line for identifing lines after changes, old ----line for identifing lines before changes and both if line was not changed. ----@param signs SignTable[] table of signs. See :h sign_placelist ----@param type string "new" if diagnostic should be in file after changes else "old" -M.place_sign = function(signs, type) - local view = diffview_lib.get_current_view() - if not view then - return - end - if type == "new" then - for _, sign in ipairs(signs) do - sign.buffer = view.cur_layout.b.file.bufnr - end - elseif type == "old" then - for _, sign in ipairs(signs) do - sign.buffer = view.cur_layout.a.file.bufnr - end - end - vim.fn.sign_placelist(signs) -end - ----Set diagnostics in currently reviewed file. ----@param namespace integer namespace for diagnostics ----@param diagnostics table see :h vim.diagnostic.set ----@param type string "new" if diagnostic should be in file after changes else "old" ----@param opts table? see :h vim.diagnostic.set -M.set_diagnostics = function(namespace, diagnostics, type, opts) - local view = diffview_lib.get_current_view() - if not view then - return - end - if type == "new" and view.cur_layout.b.file.bufnr then - vim.diagnostic.set(namespace, view.cur_layout.b.file.bufnr, diagnostics, opts) - elseif type == "old" and view.cur_layout.a.file.bufnr then - vim.diagnostic.set(namespace, view.cur_layout.a.file.bufnr, diagnostics, opts) - end -end - ----Diffview exposes events which can be used to setup autocommands. ----@param callback fun(opts: table) - for more information about opts see callback in :h nvim_create_autocmd -M.set_callback_for_file_changed = function(callback) - local group = vim.api.nvim_create_augroup("gitlab.diffview.autocommand.file_changed", {}) - vim.api.nvim_create_autocmd("User", { - pattern = { "DiffviewDiffBufWinEnter", "DiffviewViewEnter" }, - group = group, - callback = function(...) - if M.tabnr == vim.api.nvim_get_current_tabpage() then - callback(...) - end - end, - }) -end - ----Diffview exposes events which can be used to setup autocommands. ----@param callback fun(opts: table) - for more information about opts see callback in :h nvim_create_autocmd -M.set_callback_for_reviewer_leave = function(callback) - local group = vim.api.nvim_create_augroup("gitlab.diffview.autocommand.leave", {}) - vim.api.nvim_create_autocmd("User", { - pattern = { "DiffviewViewLeave", "DiffviewViewClosed" }, - group = group, - callback = function(...) - if M.tabnr == vim.api.nvim_get_current_tabpage() then - callback(...) - end - end, - }) -end - ----Returns whether the comment is on a deleted line, added line, or unmodified line. ----This is in order to build the payload for Gitlab correctly by setting the old line and new line. ----@param a_linenr number ----@param b_linenr number ----@param is_current_sha boolean ----@param hunks Hunk[] A list of hunks ----@param all_diff_output table The raw diff output -function M.get_modification_type(a_linenr, b_linenr, is_current_sha, hunks, all_diff_output) - for _, hunk in ipairs(hunks) do - local old_line_end = hunk.old_line + hunk.old_range - local new_line_end = hunk.new_line + hunk.new_range - - if is_current_sha then - -- If it is a single line change and neither hunk has a range, then it's added - if b_linenr >= hunk.new_line and b_linenr <= new_line_end then - if hunk.new_range == 0 and hunk.old_range == 0 then - return "added" - end - -- If leaving a comment on the new window, we may be commenting on an added line - -- or on an unmodified line. To tell, we have to check whether the line itself is - -- prefixed with "+" and only return "added" if it is. - if M.line_was_added(b_linenr, hunk, all_diff_output) then - return "added" - end - end - else - -- It's a deletion if it's in the range of the hunks and the new - -- range is zero, since that is only a deletion hunk, or if we find - -- a match in another hunk with a range, and the corresponding line is prefixed - -- with a "-" only. If it is, then it's a deletion. - if a_linenr >= hunk.old_line and a_linenr <= old_line_end and hunk.old_range == 0 then - return "deleted" - end - if - (a_linenr >= hunk.old_line and a_linenr <= old_line_end) - or (a_linenr >= hunk.new_line and b_linenr <= new_line_end) - then - if M.line_was_removed(a_linenr, hunk, all_diff_output) then - return "deleted" - end - end - end - end - - -- If we can't find the line, this means the user is either trying to leave - -- a comment on an unchanged line in the new or old file SHA. This is only - -- allowed in the old file - return is_current_sha and "bad_file_unmodified" or "unmodified" -end - ----@param linnr number ----@param hunk Hunk ----@param all_diff_output table -M.line_was_removed = function(linnr, hunk, all_diff_output) - for matching_line_index, line in ipairs(all_diff_output) do - local found_hunk = u.parse_possible_hunk_headers(line) - if found_hunk ~= nil and vim.deep_equal(found_hunk, hunk) then - -- We found a matching hunk, now we need to iterate over the lines from the raw diff output - -- at that hunk until we reach the line we are looking for. When the indexes match we check - -- to see if that line is deleted or not. - for hunk_line_index = found_hunk.old_line, hunk.old_line + hunk.old_range - 1, 1 do - local line_content = all_diff_output[matching_line_index + 1] - if hunk_line_index == linnr then - if string.match(line_content, "^%-") then - return "deleted" - end - end - end - end - end -end - ----@param linnr number ----@param hunk Hunk ----@param all_diff_output table -M.line_was_added = function(linnr, hunk, all_diff_output) - for matching_line_index, line in ipairs(all_diff_output) do - local found_hunk = u.parse_possible_hunk_headers(line) - if found_hunk ~= nil and vim.deep_equal(found_hunk, hunk) then - -- For added lines, we only want to iterate over the part of the diff that has has new lines, - -- so we skip over the old range. We then keep track of the increment to the original new line index, - -- and iterate until we reach the end of the total range of this hunk. If we arrive at the matching - -- index for the line number, we check to see if the line was added. - local i = 0 - local old_range = (found_hunk.old_range == 0 and found_hunk.old_line ~= 0) and 1 or found_hunk.old_range - for hunk_line_index = matching_line_index + old_range + 1, matching_line_index + old_range + found_hunk.new_range, 1 do - local line_content = all_diff_output[hunk_line_index] - if (found_hunk.new_line + i) == linnr then - if string.match(line_content, "^%+") then - return "added" - end - end - i = i + 1 - end - end - end -end -return M diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 297ef2f7..3c184908 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -1,74 +1,302 @@ +-- This Module contains all of the reviewer code for diffview +local u = require("gitlab.utils") local state = require("gitlab.state") -local diffview = require("gitlab.reviewer.diffview") +local git = require("gitlab.git") +local hunks = require("gitlab.hunks") +local async_ok, async = pcall(require, "diffview.async") +local diffview_lib = require("diffview.lib") local M = { - reviewer = nil, -} - -local reviewer_map = { - diffview = diffview, + bufnr = nil, + tabnr = nil, } +-- Checks for legacy installations, only Diffview is supported. M.init = function() - local reviewer = reviewer_map[state.settings.reviewer] - if reviewer == nil then + if state.settings.reviewer ~= "diffview" then vim.notify( string.format("gitlab.nvim could not find reviewer %s, only diffview is supported", state.settings.reviewer), vim.log.levels.ERROR ) + end +end + +-- Opens the reviewer window. +M.open = function() + local diff_refs = state.INFO.diff_refs + if diff_refs == nil then + u.notify("Gitlab did not provide diff refs required to review this MR", vim.log.levels.ERROR) + return + end + + if diff_refs.base_sha == "" or diff_refs.head_sha == "" then + u.notify("Merge request contains no changes", vim.log.levels.ERROR) + return + end + + local diffview_open_command = "DiffviewOpen" + local has_clean_tree = git.has_clean_tree() + if state.settings.reviewer_settings.diffview.imply_local and has_clean_tree then + diffview_open_command = diffview_open_command .. " --imply-local" + end + + vim.api.nvim_command(string.format("%s %s..%s", diffview_open_command, diff_refs.base_sha, diff_refs.head_sha)) + M.tabnr = vim.api.nvim_get_current_tabpage() + + if state.settings.reviewer_settings.diffview.imply_local and not has_clean_tree then + u.notify( + "There are uncommited changes in the working tree, cannot use 'imply_local' setting for gitlab reviews.\n Stash or commit all changes to use.", + vim.log.levels.WARN + ) + end + + if state.INFO.has_conflicts then + u.notify("This merge request has conflicts!", vim.log.levels.WARN) + end + + -- Register Diffview hook for close event to set tab page # to nil + local on_diffview_closed = function(view) + if view.tabpage == M.tabnr then + M.tabnr = nil + end + end + require("diffview.config").user_emitter:on("view_closed", function(_, ...) + on_diffview_closed(...) + end) + + if state.settings.discussion_tree.auto_open then + local discussions = require("gitlab.actions.discussions") + discussions.close() + discussions.toggle() + end +end + +-- Closes the reviewer and cleans up +M.close = function() + vim.cmd("DiffviewClose") + local discussions = require("gitlab.actions.discussions") + discussions.close() +end + +-- Jumps to the location provided in the reviewer window +---@param file_name string +---@param new_line number|nil +---@param old_line number|nil +---@param opts table +M.jump = function(file_name, new_line, old_line, opts) + if M.tabnr == nil then + u.notify("Can't jump to Diffvew. Is it open?", vim.log.levels.ERROR) + return + end + vim.api.nvim_set_current_tabpage(M.tabnr) + vim.cmd("DiffviewFocusFiles") + local view = diffview_lib.get_current_view() + if view == nil then + u.notify("Could not find Diffview view", vim.log.levels.ERROR) + return + end + local files = view.panel:ordered_file_list() + local layout = view.cur_layout + for _, file in ipairs(files) do + if file.path == file_name then + if not async_ok then + u.notify("Could not load Diffview async", vim.log.levels.ERROR) + return + end + async.await(view:set_file(file)) + -- TODO: Ranged comments on unchanged lines will have both a + -- new line and a old line. + -- + -- The same is true when the user leaves a single-line comment + -- on an unchanged line in the "b" buffer. + -- + -- We need to distinguish them somehow from + -- range comments (which also have this) so that we can know + -- which buffer to jump to. Right now, we jump to the wrong + -- buffer for ranged comments on unchanged lines. + if new_line ~= nil and not opts.is_undefined_type then + layout.b:focus() + vim.api.nvim_win_set_cursor(0, { new_line, 0 }) + elseif old_line ~= nil then + layout.a:focus() + vim.api.nvim_win_set_cursor(0, { old_line, 0 }) + end + break + end + end +end + +---Get the data from diffview, such as line information and file name. May be used by +---other modules such as the comment module to create line codes or set diagnostics +---@return DiffviewInfo | nil +M.get_reviewer_data = function() + if M.tabnr == nil then + u.notify("Diffview reviewer must be initialized first", vim.log.levels.ERROR) + return + end + + -- Check if we are in the diffview tab + local tabnr = vim.api.nvim_get_current_tabpage() + if tabnr ~= M.tabnr then + u.notify("Line location can only be determined within reviewer window", vim.log.levels.ERROR) + return + end + + -- Check if we are in the diffview buffer + local view = diffview_lib.get_current_view() + if view == nil then + u.notify("Could not find Diffview view", vim.log.levels.ERROR) + return + end + + local layout = view.cur_layout + local old_win = u.get_window_id_by_buffer_id(layout.a.file.bufnr) + local new_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr) + + if old_win == nil or new_win == nil then + u.notify("Error getting window IDs for current files", vim.log.levels.ERROR) + return + end + + local current_file = M.get_current_file() + if current_file == nil then + u.notify("Error getting current file from Diffview", vim.log.levels.ERROR) + return + end + + local new_line = vim.api.nvim_win_get_cursor(new_win)[1] + local old_line = vim.api.nvim_win_get_cursor(old_win)[1] + local modification_type = hunks.get_modification_type(old_line, new_line, current_file) + if modification_type == nil then + u.notify("Error parsing hunks for modification type", vim.log.levels.ERROR) + return + end + + if modification_type == "bad_file_unmodified" then + u.notify("Comments on unmodified lines will be placed in the old file", vim.log.levels.WARN) + end + + local current_bufnr = M.is_current_sha() and layout.b.file.bufnr or layout.a.file.bufnr + local opposite_bufnr = M.is_current_sha() and layout.a.file.bufnr or layout.b.file.bufnr + local old_sha_win_id = u.get_window_id_by_buffer_id(layout.a.file.bufnr) + local new_sha_win_id = u.get_window_id_by_buffer_id(layout.b.file.bufnr) + + return { + file_name = layout.a.file.path, + old_line_from_buf = old_line, + new_line_from_buf = new_line, + modification_type = modification_type, + new_sha_win_id = new_sha_win_id, + current_bufnr = current_bufnr, + old_sha_win_id = old_sha_win_id, + opposite_bufnr = opposite_bufnr, + } +end + +---Return whether user is focused on the new version of the file +---@return boolean +M.is_current_sha = function() + local view = diffview_lib.get_current_view() + local layout = view.cur_layout + local b_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr) + local current_win = vim.fn.win_getid() + return current_win == b_win +end + +---Checks whether the lines in the two buffers are the same +---@return boolean +M.lines_are_same = function(layout, a_cursor, b_cursor) + local line_a = u.get_line_content(layout.a.file.bufnr, a_cursor) + local line_b = u.get_line_content(layout.b.file.bufnr, b_cursor) + return line_a == line_b +end + +---Get currently shown file +---@return string|nil +M.get_current_file = function() + local view = diffview_lib.get_current_view() + if not view then + return + end + return view.panel.cur_file.path +end + +-- Places a sign on the line for currently reviewed file. +---@param signs SignTable[] table of signs. See :h sign_placelist +---@param type string "new" if diagnostic should be in file after changes else "old" +M.place_sign = function(signs, type) + local view = diffview_lib.get_current_view() + if not view then return end + if type == "new" then + for _, sign in ipairs(signs) do + sign.buffer = view.cur_layout.b.file.bufnr + end + elseif type == "old" then + for _, sign in ipairs(signs) do + sign.buffer = view.cur_layout.a.file.bufnr + end + end + vim.fn.sign_placelist(signs) +end + +---Set diagnostics in currently reviewed file. +---@param namespace integer namespace for diagnostics +---@param diagnostics table see :h vim.diagnostic.set +---@param type string "new" if diagnostic should be in file after changes else "old" +---@param opts table? see :h vim.diagnostic.set +M.set_diagnostics = function(namespace, diagnostics, type, opts) + local view = diffview_lib.get_current_view() + if not view then + return + end + if type == "new" and view.cur_layout.b.file.bufnr then + vim.diagnostic.set(namespace, view.cur_layout.b.file.bufnr, diagnostics, opts) + elseif type == "old" and view.cur_layout.a.file.bufnr then + vim.diagnostic.set(namespace, view.cur_layout.a.file.bufnr, diagnostics, opts) + end +end + +---Diffview exposes events which can be used to setup autocommands. +---@param callback fun(opts: table) - for more information about opts see callback in :h nvim_create_autocmd +M.set_callback_for_file_changed = function(callback) + local group = vim.api.nvim_create_augroup("gitlab.diffview.autocommand.file_changed", {}) + vim.api.nvim_create_autocmd("User", { + pattern = { "DiffviewDiffBufWinEnter" }, + group = group, + callback = function(...) + if M.tabnr == vim.api.nvim_get_current_tabpage() then + callback(...) + end + end, + }) +end + +---Diffview exposes events which can be used to setup autocommands. +---@param callback fun(opts: table) - for more information about opts see callback in :h nvim_create_autocmd +M.set_callback_for_reviewer_leave = function(callback) + local group = vim.api.nvim_create_augroup("gitlab.diffview.autocommand.leave", {}) + vim.api.nvim_create_autocmd("User", { + pattern = { "DiffviewViewLeave", "DiffviewViewClosed" }, + group = group, + callback = function(...) + if M.tabnr == vim.api.nvim_get_current_tabpage() then + callback(...) + end + end, + }) +end - M.open = reviewer.open - -- Opens the reviewer window - - M.close = reviewer.close - -- Closes the reviewer and cleans up - - M.jump = reviewer.jump - -- Jumps to the location provided in the reviewer window - -- Parameters: - -- • {file_name} The name of the file to jump to - -- • {new_line} The new_line of the change - -- • {interval} The old_line of the change - - M.get_location = reviewer.get_location - -- Parameters: - -- • {range} LineRange if function was triggered from visual selection - -- Returns the current location (based on cursor) from the reviewer window as ReviewerInfo class - - M.get_lines = reviewer.get_lines - -- Returns the content of the file in the current location in the reviewer window - - M.get_current_file = reviewer.get_current_file - -- Get currently loaded file - - M.place_sign = reviewer.place_sign - -- Places a sign on the line for currently reviewed file. - -- Parameters: - -- • {id} The sign id - -- • {sign} The sign to place - -- • {group} The sign group to place on - -- • {new_line} The line to place the sign on - -- • {old_line} The buffer number to place the sign on - - M.set_callback_for_file_changed = reviewer.set_callback_for_file_changed - -- Call callback whenever the file changes - -- Parameters: - -- • {callback} The callback to call - - M.set_callback_for_reviewer_leave = reviewer.set_callback_for_reviewer_leave - -- Call callback whenever the reviewer is left - -- Parameters: - -- • {callback} The callback to call - - M.set_diagnostics = reviewer.set_diagnostics - -- Set diagnostics for currently reviewed file - -- Parameters: - -- • {namespace} The namespace for diagnostics - -- • {diagnostics} The diagnostics to set - -- • {type} "new" if diagnostic should be in file after changes else "old" - -- • {opts} see opts in :h vim.diagnostic.set +M.set_callback_for_reviewer_enter = function(callback) + local group = vim.api.nvim_create_augroup("gitlab.diffview.autocommand.enter", {}) + vim.api.nvim_create_autocmd("User", { + pattern = { "DiffviewViewOpened" }, + group = group, + callback = function(...) + callback(...) + end, + }) end return M diff --git a/lua/gitlab/reviewer/location.lua b/lua/gitlab/reviewer/location.lua new file mode 100755 index 00000000..95e85c76 --- /dev/null +++ b/lua/gitlab/reviewer/location.lua @@ -0,0 +1,206 @@ +local u = require("gitlab.utils") +local hunks = require("gitlab.hunks") +local state = require("gitlab.state") + +---@class Location +---@field location_data LocationData +---@field reviewer_data DiffviewInfo +---@field run function +---@field build_location_data function + +---@class ReviewerLineInfo +---@field old_line integer|nil +---@field new_line integer|nil +---@field type "new"|"old" + +---@class ReviewerRangeInfo +---@field start ReviewerLineInfo +---@field end ReviewerLineInfo + +local Location = {} +Location.__index = Location +---@param reviewer_data DiffviewInfo +---@param visual_range LineRange | nil +---@return Location +function Location.new(reviewer_data, visual_range) + local instance = setmetatable({}, Location) + instance.reviewer_data = reviewer_data + instance.visual_range = visual_range + instance.base_sha = state.INFO.diff_refs.base_sha + instance.head_sha = state.INFO.diff_refs.head_sha + return instance +end + +---Takes in information about the current changes, such as the file name, modification type of the diff, and the line numbers +---and builds the appropriate payload when creating a comment. +function Location:build_location_data() + ---@type DiffviewInfo + local reviewer_data = self.reviewer_data + ---@type LineRange | nil + local visual_range = self.visual_range + + ---@type LocationData + local location_data = { + old_line = nil, + new_line = nil, + line_range = nil, + } + + -- Comment on new line: Include only new_line in payload. + -- Comment on deleted line: Include only old_line in payload. + -- The line was not found in any hunks, send both lines. + if reviewer_data.modification_type == "added" then + location_data.old_line = nil + location_data.new_line = reviewer_data.new_line_from_buf + elseif reviewer_data.modification_type == "deleted" then + location_data.old_line = reviewer_data.old_line_from_buf + location_data.new_line = nil + elseif + reviewer_data.modification_type == "unmodified" or reviewer_data.modification_type == "bad_file_unmodified" + then + location_data.old_line = reviewer_data.old_line_from_buf + location_data.new_line = reviewer_data.new_line_from_buf + end + + self.location_data = location_data + if visual_range == nil then + return + else + self.location_data.line_range = {} + end + + self:set_start_range(visual_range) + self:set_end_range(visual_range) + + -- Ranged comments should always use the end of the range. + -- Otherwise they will not highlight the full comment in Gitlab. + self.location_data.old_line = self.location_data.line_range["end"].old_line + self.location_data.new_line = self.location_data.line_range["end"].new_line +end + +-- Helper methods 🤝 + +-- Returns the matching line from the new SHA. +-- For instance, line 12 in the new SHA may be scroll-linked +-- to line 10 in the old SHA. +---@param line number +---@return number|nil +function Location:get_line_number_from_new_sha(line) + local reviewer = require("gitlab.reviewer") + local is_current_sha = reviewer.is_current_sha() + if is_current_sha then + return line + end + -- Otherwise we want to get the matching line in the opposite buffer + return hunks.calculate_matching_line_new(self.base_sha, self.head_sha, self.reviewer_data.file_name, line) +end + +-- Returns the matching line from the old SHA. +-- For instance, line 12 in the new SHA may be scroll-linked +-- to line 10 in the old SHA. +---@param line number +---@return number|nil +function Location:get_line_number_from_old_sha(line) + local reviewer = require("gitlab.reviewer") + local is_current_sha = reviewer.is_current_sha() + if not is_current_sha then + return line + end + + -- Otherwise we want to get the matching line in the opposite buffer + return hunks.calculate_matching_line_new(self.head_sha, self.base_sha, self.reviewer_data.file_name, line) +end + +-- Returns the current line number from whatever SHA (new or old) +-- the reviewer is focused in. +---@return number|nil +function Location:get_current_line() + local reviewer = require("gitlab.reviewer") + local win_id = reviewer.is_current_sha() and self.reviewer_data.new_sha_win_id or self.reviewer_data.old_sha_win_id + if win_id == nil then + return + end + + local current_line = vim.api.nvim_win_get_cursor(win_id)[1] + return current_line +end + +-- Given a new_line and old_line from the start of a ranged comment, returns the start +-- range information for the Gitlab payload +---@param visual_range LineRange +---@return ReviewerLineInfo|nil +function Location:set_start_range(visual_range) + local current_file = require("gitlab.reviewer").get_current_file() + if current_file == nil then + u.notify("Error getting current file from Diffview", vim.log.levels.ERROR) + return + end + + local reviewer = require("gitlab.reviewer") + local win_id = reviewer.is_current_sha() and self.reviewer_data.new_sha_win_id or self.reviewer_data.old_sha_win_id + if win_id == nil then + u.notify("Error getting window number of SHA for start range", vim.log.levels.ERROR) + return + end + + local current_line = self:get_current_line() + if current_line == nil then + u.notify("Error getting window number of SHA for start range", vim.log.levels.ERROR) + return + end + + local new_line = self:get_line_number_from_new_sha(visual_range.start_line) + local old_line = self:get_line_number_from_old_sha(visual_range.start_line) + if + (new_line == nil and self.reviewer_data.modification_type ~= "deleted") + or (old_line == nil and self.reviewer_data.modification_type ~= "added") + then + u.notify("Error getting new or old line for start range", vim.log.levels.ERROR) + return + end + + local modification_type = hunks.get_modification_type(old_line, new_line, current_file) + + self.location_data.line_range.start = { + new_line = modification_type ~= "deleted" and new_line or nil, + old_line = modification_type ~= "added" and old_line or nil, + type = modification_type == "added" and "new" or "old", + } +end + +-- Given a modification type, a range, and the hunk data, returns the end range information +-- for the Gitlab payload +---@param visual_range LineRange +function Location:set_end_range(visual_range) + local current_file = require("gitlab.reviewer").get_current_file() + if current_file == nil then + u.notify("Error getting current file from Diffview", vim.log.levels.ERROR) + return + end + + local current_line = self:get_current_line() + if current_line == nil then + u.notify("Error getting window number of SHA for start range", vim.log.levels.ERROR) + return + end + + local new_line = self:get_line_number_from_new_sha(visual_range.end_line) + local old_line = self:get_line_number_from_old_sha(visual_range.end_line) + + if + (new_line == nil and self.reviewer_data.modification_type ~= "deleted") + or (old_line == nil and self.reviewer_data.modification_type ~= "added") + then + u.notify("Error getting new or old line for end range", vim.log.levels.ERROR) + return + end + + local modification_type = hunks.get_modification_type(old_line, new_line, current_file) + self.location_data.line_range["end"] = { + new_line = modification_type ~= "deleted" and new_line or nil, + old_line = modification_type ~= "added" and old_line or nil, + type = modification_type == "added" and "new" or "old", + } +end + +return Location diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua index 53f9ac02..def61af9 100644 --- a/lua/gitlab/server.lua +++ b/lua/gitlab/server.lua @@ -1,6 +1,7 @@ -- This module contains the logic responsible for building and starting -- the Golang server. The Go server is responsible for making API calls -- to Gitlab and returning the data +local List = require("gitlab.utils.list") local state = require("gitlab.state") local u = require("gitlab.utils") local job = require("gitlab.job") @@ -49,12 +50,12 @@ M.start = function(callback) end end, on_stderr = function(_, errors) - local err_msg = "" - for _, err in ipairs(errors) do + local err_msg = List.new(errors):reduce(function(agg, err) if err ~= "" and err ~= nil then - err_msg = err_msg .. err .. "\n" + agg = agg .. err .. "\n" end - end + return agg + end, "") if err_msg ~= "" then u.notify(err_msg, vim.log.levels.ERROR) diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 63886ff7..fe05ccbc 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -1,3 +1,4 @@ +local List = require("gitlab.utils.list") local has_devicons, devicons = pcall(require, "nvim-web-devicons") local M = {} @@ -484,14 +485,10 @@ M.get_window_id_by_buffer_id = function(buffer_id) local tabpage = vim.api.nvim_get_current_tabpage() local windows = vim.api.nvim_tabpage_list_wins(tabpage) - for _, win_id in ipairs(windows) do + return List.new(windows):find(function(win_id) local buf_id = vim.api.nvim_win_get_buf(win_id) - if buf_id == buffer_id then - return win_id - end - end - - return nil -- Buffer ID not found in any window + return buf_id == buffer_id + end) end M.list_files_in_folder = function(folder_path) @@ -507,166 +504,21 @@ M.list_files_in_folder = function(folder_path) local files = {} if folder ~= nil then - for _, file in ipairs(folder) do - local file_path = folder_path .. M.path_separator .. file - local timestamp = vim.fn.getftime(file_path) - table.insert(files, { name = file, timestamp = timestamp }) - end + files = List.new(folder) + :map(function(file) + local file_path = folder_path .. M.path_separator .. file + local timestamp = vim.fn.getftime(file_path) + return { name = file, timestamp = timestamp } + end) + :sort(function(a, b) + return a.timestamp > b.timestamp + end) + :map(function(file) + return file.name + end) end - -- Sort the table by timestamp in descending order (newest first) - table.sort(files, function(a, b) - return a.timestamp > b.timestamp - end) - - local result = {} - for _, file in ipairs(files) do - table.insert(result, file.name) - end - - return result -end - ----@class Hunk ----@field old_line integer ----@field old_range integer ----@field new_line integer ----@field new_range integer - ----@class HunksAndDiff ----@field hunks Hunk[] list of hunks ----@field all_diff_output table The data from the git diff command - ----Turn hunk line into Lua table ----@param line table ----@return Hunk|nil -M.parse_possible_hunk_headers = function(line) - if line:sub(1, 2) == "@@" then - -- match: - -- @@ -23 +23 @@ ... - -- @@ -23,0 +23 @@ ... - -- @@ -41,0 +42,4 @@ ... - local old_start, old_range, new_start, new_range = line:match("@@+ %-(%d+),?(%d*) %+(%d+),?(%d*) @@+") - - return { - old_line = tonumber(old_start), - old_range = tonumber(old_range) or 0, - new_line = tonumber(new_start), - new_range = tonumber(new_range) or 0, - } - end -end - ----Parse git diff hunks. ----@param file_path string Path to file. ----@param base_branch string Git base branch of merge request. ----@return HunksAndDiff -M.parse_hunk_headers = function(file_path, base_branch) - local hunks = {} - local all_diff_output = {} - - local Job = require("plenary.job") - - local diff_job = Job:new({ - command = "git", - args = { "diff", "--minimal", "--unified=0", "--no-color", base_branch, "--", file_path }, - on_exit = function(j, return_code) - if return_code == 0 then - all_diff_output = j:result() - for _, line in ipairs(all_diff_output) do - local hunk = M.parse_possible_hunk_headers(line) - if hunk ~= nil then - table.insert(hunks, hunk) - end - end - else - M.notify("Failed to get git diff: " .. j:stderr(), vim.log.levels.WARN) - end - end, - }) - - diff_job:sync() - - return { hunks = hunks, all_diff_output = all_diff_output } -end - ----@class LineDiffInfo ----@field old_line integer ----@field new_line integer ----@field in_hunk boolean - ----Search git diff hunks to find old and new line number corresponding to target line. ----This function does not check if target line is outside of boundaries of file. ----@param hunks Hunk[] git diff parsed hunks. ----@param target_line integer line number to search for - based on is_new paramter the search is ----either in new lines or old lines of hunks. ----@param is_new boolean whether to search for new line or old line ----@return LineDiffInfo -M.get_lines_from_hunks = function(hunks, target_line, is_new) - if #hunks == 0 then - -- If there are zero hunks, return target_line for both old and new lines - return { old_line = target_line, new_line = target_line, in_hunk = false } - end - local current_new_line = 0 - local current_old_line = 0 - if is_new then - for _, hunk in ipairs(hunks) do - -- target line is before current hunk - if target_line < hunk.new_line then - return { - old_line = current_old_line + (target_line - current_new_line), - new_line = target_line, - in_hunk = false, - } - -- target line is within the current hunk - elseif hunk.new_line <= target_line and target_line <= (hunk.new_line + hunk.new_range) then - -- this is interesting magic of gitlab calculation - return { - old_line = hunk.old_line + hunk.old_range + 1, - new_line = target_line, - in_hunk = true, - } - -- target line is after the current hunk - else - current_new_line = hunk.new_line + hunk.new_range - current_old_line = hunk.old_line + hunk.old_range - end - end - -- target line is after last hunk - return { - old_line = current_old_line + (target_line - current_new_line), - new_line = target_line, - in_hunk = false, - } - else - for _, hunk in ipairs(hunks) do - -- target line is before current hunk - if target_line < hunk.old_line then - return { - old_line = target_line, - new_line = current_new_line + (target_line - current_old_line), - in_hunk = false, - } - -- target line is within the current hunk - elseif hunk.old_line <= target_line and target_line <= (hunk.old_line + hunk.old_range) then - return { - old_line = target_line, - new_line = hunk.new_line, - in_hunk = true, - } - -- target line is after the current hunk - else - current_new_line = hunk.new_line + hunk.new_range - current_old_line = hunk.old_line + hunk.old_range - end - end - -- target line is after last hunk - return { - old_line = current_old_line + (target_line - current_new_line), - new_line = target_line, - in_hunk = false, - } - end + return files end ---Check if current mode is visual mode @@ -707,6 +559,14 @@ M.get_icon = function(filename) end end +---Return content between start_line and end_line +---@param start_line integer +---@param end_line integer +---@return string[] +M.get_lines = function(start_line, end_line) + return vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false) +end + M.make_comma_separated_readable = function(str) return string.gsub(str, ",", ", ") end @@ -731,7 +591,7 @@ M.get_all_git_branches = function(remote) end handle:close() else - print("Error running 'git branch' command.") + M.notify("Error running 'git branch' command.", vim.log.levels.ERROR) end return branches diff --git a/lua/gitlab/utils/list.lua b/lua/gitlab/utils/list.lua new file mode 100644 index 00000000..27180388 --- /dev/null +++ b/lua/gitlab/utils/list.lua @@ -0,0 +1,66 @@ +local List = {} +List.__index = List + +function List.new(t) + local list = t or {} + setmetatable(list, List) + return list +end + +---Mutates a given list +---@generic T +---@param func fun(v: T):T +---@return List @Returns a new list of elements mutated by func +function List:map(func) + local result = List.new() + for i, v in ipairs(self) do + result[i] = func(v) + end + return result +end + +---Filters a given list +---@generic T +---@param func fun(v: T):boolean +---@return List @Returns a new list of elements for which func returns true +function List:filter(func) + local result = List.new() + for i, v in ipairs(self) do + if func(v) == true then + result[i] = v + end + end + return result +end + +function List:reduce(func, agg) + for i, v in ipairs(self) do + agg = func(agg, v, i) + end + return agg +end + +function List:sort(func) + local result = List.new(self) + table.sort(result, func) + return result +end + +function List:find(func) + for _, v in ipairs(self) do + if func(v) == true then + return v + end + end + return nil +end + +function List:slice(first, last, step) + local sliced = {} + for i = first or 1, last or #self, step or 1 do + sliced[#sliced + 1] = self[i] + end + return sliced +end + +return List From 8859c47bbce352c15b341978a2294fb11134ae72 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Tue, 27 Feb 2024 19:07:13 -0500 Subject: [PATCH 06/97] Fix: location provider (#198) --- lua/gitlab/actions/comment.lua | 2 +- lua/gitlab/reviewer/location.lua | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index e81b4639..23d4d259 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -131,7 +131,7 @@ M.confirm_create_comment = function(text, visual_range, unlinked) return end - local location = Location:new(reviewer_data, visual_range) + local location = Location.new(reviewer_data, visual_range) location:build_location_data() local location_data = location.location_data if location_data == nil then diff --git a/lua/gitlab/reviewer/location.lua b/lua/gitlab/reviewer/location.lua index 95e85c76..782a68e8 100755 --- a/lua/gitlab/reviewer/location.lua +++ b/lua/gitlab/reviewer/location.lua @@ -23,7 +23,8 @@ Location.__index = Location ---@param visual_range LineRange | nil ---@return Location function Location.new(reviewer_data, visual_range) - local instance = setmetatable({}, Location) + local location = {} + local instance = setmetatable(location, Location) instance.reviewer_data = reviewer_data instance.visual_range = visual_range instance.base_sha = state.INFO.diff_refs.base_sha @@ -66,7 +67,10 @@ function Location:build_location_data() if visual_range == nil then return else - self.location_data.line_range = {} + self.location_data.line_range = { + start = {}, + ["end"] = {}, + } end self:set_start_range(visual_range) @@ -145,7 +149,7 @@ function Location:set_start_range(visual_range) local current_line = self:get_current_line() if current_line == nil then - u.notify("Error getting window number of SHA for start range", vim.log.levels.ERROR) + u.notify("Error getting current line for start range", vim.log.levels.ERROR) return end @@ -180,7 +184,7 @@ function Location:set_end_range(visual_range) local current_line = self:get_current_line() if current_line == nil then - u.notify("Error getting window number of SHA for start range", vim.log.levels.ERROR) + u.notify("Error getting current line for end range", vim.log.levels.ERROR) return end From 00e3e24be4646cde672775cd9e6720a684e7654f Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Wed, 28 Feb 2024 09:13:09 -0500 Subject: [PATCH 07/97] fix: add nil check for diffview performance issue (#199) --- lua/gitlab/actions/discussions/init.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 17df478f..6d614c61 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -74,10 +74,10 @@ M.modifiable = function(bool) local view = diffview_lib.get_current_view() local a = view.cur_layout.a.file.bufnr local b = view.cur_layout.b.file.bufnr - if vim.api.nvim_buf_is_loaded(a) then + if a ~= nil and vim.api.nvim_buf_is_loaded(a) then vim.api.nvim_buf_set_option(a, "modifiable", bool) end - if vim.api.nvim_buf_is_loaded(b) then + if b ~= nil and vim.api.nvim_buf_is_loaded(b) then vim.api.nvim_buf_set_option(b, "modifiable", bool) end end From 8937783040f91fbe036b1655faac31c20ac72ffd Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Wed, 28 Feb 2024 13:44:28 -0500 Subject: [PATCH 08/97] Fix: Switch Tabs During Comment Creation (#200) Fix: Allow users to switch tabs while creating comments --- lua/gitlab/reviewer/init.lua | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 3c184908..a04727fb 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -9,6 +9,7 @@ local diffview_lib = require("diffview.lib") local M = { bufnr = nil, tabnr = nil, + stored_win = nil, } -- Checks for legacy installations, only Diffview is supported. @@ -199,7 +200,14 @@ M.is_current_sha = function() local view = diffview_lib.get_current_view() local layout = view.cur_layout local b_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr) + local a_win = u.get_window_id_by_buffer_id(layout.a.file.bufnr) local current_win = vim.fn.win_getid() + + -- Handle cases where user navigates tabs in the middle of making a comment + if a_win ~= current_win and b_win ~= current_win then + current_win = M.stored_win + M.stored_win = nil + end return current_win == b_win end @@ -266,6 +274,7 @@ M.set_callback_for_file_changed = function(callback) pattern = { "DiffviewDiffBufWinEnter" }, group = group, callback = function(...) + M.stored_win = vim.api.nvim_get_current_win() if M.tabnr == vim.api.nvim_get_current_tabpage() then callback(...) end From 7a3e761aa3deddddbc80214e04007a27db49d20d Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Wed, 28 Feb 2024 13:55:04 -0500 Subject: [PATCH 09/97] Fix: Check if file is modified (#201) --- lua/gitlab/actions/comment.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 23d4d259..cee4094d 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -23,9 +23,10 @@ end -- line in the current MR M.create_comment = function() local has_clean_tree = git.has_clean_tree() - if state.settings.reviewer_settings.diffview.imply_local and not has_clean_tree then + local is_modified = vim.api.nvim_buf_get_option(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 a dirty tree. \n Please stash all local changes or push them up.", + "Cannot leave comments on changed files. \n Please stash all local changes or push them to the feature branch.", vim.log.levels.WARN ) return From 4c4f4b5230e030557df62e015c6fef3d07785f99 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Wed, 28 Feb 2024 16:29:49 -0500 Subject: [PATCH 10/97] Fix: Off by 1 Error in Old Sha (#202) Fixes an issue in the old sha where the index when iterating through the lines of the hunk in the old SHA was off by one --- lua/gitlab/hunks/init.lua | 93 ++++++++++++++++---------------- lua/gitlab/reviewer/init.lua | 10 ++-- lua/gitlab/reviewer/location.lua | 18 +++++-- 3 files changed, 67 insertions(+), 54 deletions(-) diff --git a/lua/gitlab/hunks/init.lua b/lua/gitlab/hunks/init.lua index 90af06eb..1f17f5b4 100644 --- a/lua/gitlab/hunks/init.lua +++ b/lua/gitlab/hunks/init.lua @@ -35,6 +35,7 @@ end ---@param linnr number ---@param hunk Hunk ---@param all_diff_output table +---@return boolean local line_was_removed = function(linnr, hunk, all_diff_output) for matching_line_index, line in ipairs(all_diff_output) do local found_hunk = M.parse_possible_hunk_headers(line) @@ -42,21 +43,23 @@ local line_was_removed = function(linnr, hunk, all_diff_output) -- We found a matching hunk, now we need to iterate over the lines from the raw diff output -- at that hunk until we reach the line we are looking for. When the indexes match we check -- to see if that line is deleted or not. - for hunk_line_index = found_hunk.old_line, hunk.old_line + hunk.old_range - 1, 1 do + for hunk_line_index = found_hunk.old_line, hunk.old_line + hunk.old_range, 1 do local line_content = all_diff_output[matching_line_index + 1] if hunk_line_index == linnr then if string.match(line_content, "^%-") then - return "deleted" + return true end end end end end + return false end ---@param linnr number ---@param hunk Hunk ---@param all_diff_output table +---@return boolean local line_was_added = function(linnr, hunk, all_diff_output) for matching_line_index, line in ipairs(all_diff_output) do local found_hunk = M.parse_possible_hunk_headers(line) @@ -71,13 +74,14 @@ local line_was_added = function(linnr, hunk, all_diff_output) local line_content = all_diff_output[hunk_line_index] if (found_hunk.new_line + i) == linnr then if string.match(line_content, "^%+") then - return "added" + return true end end i = i + 1 end end end + return false end ---Parse git diff hunks. @@ -173,13 +177,49 @@ local count_changes = function(lines) return total end +---@param new_line number|nil +---@param hunks Hunk[] +---@param all_diff_output table +---@return string|nil +local function get_modification_type_from_new_sha(new_line, hunks, all_diff_output) + if new_line == nil then + return nil + end + return List.new(hunks):find(function(hunk) + local new_line_end = hunk.new_line + hunk.new_range + local in_new_range = new_line >= hunk.new_line and new_line <= new_line_end + local is_range_zero = hunk.new_range == 0 and hunk.old_range == 0 + return in_new_range and (is_range_zero or line_was_added(new_line, hunk, all_diff_output)) + end) and "added" or "bad_file_unmodified" +end + +---@param old_line number|nil +---@param new_line number|nil +---@param hunks Hunk[] +---@param all_diff_output table +---@return string|nil +local function get_modification_type_from_old_sha(old_line, new_line, hunks, all_diff_output) + if old_line == nil then + return nil + end + + return List.new(hunks):find(function(hunk) + local old_line_end = hunk.old_line + hunk.old_range + local new_line_end = hunk.new_line + hunk.new_range + local in_old_range = old_line >= hunk.old_line and old_line <= old_line_end + local in_new_range = old_line >= hunk.new_line and new_line <= new_line_end + return (in_old_range or in_new_range) and line_was_removed(old_line, hunk, all_diff_output) + end) and "deleted" or "unmodified" +end + ---Returns whether the comment is on a deleted line, added line, or unmodified line. ---This is in order to build the payload for Gitlab correctly by setting the old line and new line. ---@param old_line number|nil ---@param new_line number|nil ---@param current_file string +---@param is_current_sha boolean ---@return string|nil -function M.get_modification_type(old_line, new_line, current_file) +function M.get_modification_type(old_line, new_line, current_file, is_current_sha) local hunk_and_diff_data = parse_hunks_and_diff(current_file, state.INFO.target_branch) if hunk_and_diff_data.hunks == nil then return @@ -187,49 +227,8 @@ function M.get_modification_type(old_line, new_line, current_file) local hunks = hunk_and_diff_data.hunks local all_diff_output = hunk_and_diff_data.all_diff_output - - local is_current_sha = require("gitlab.reviewer").is_current_sha() - - for _, hunk in ipairs(hunks) do - local old_line_end = hunk.old_line + hunk.old_range - local new_line_end = hunk.new_line + hunk.new_range - - if is_current_sha then - -- If it is a single line change and neither hunk has a range, then it's added - if new_line >= hunk.new_line and new_line <= new_line_end then - if hunk.new_range == 0 and hunk.old_range == 0 then - return "added" - end - -- If leaving a comment on the new window, we may be commenting on an added line - -- or on an unmodified line. To tell, we have to check whether the line itself is - -- prefixed with "+" and only return "added" if it is. - if line_was_added(new_line, hunk, all_diff_output) then - return "added" - end - end - else - -- It's a deletion if it's in the range of the hunks and the new - -- range is zero, since that is only a deletion hunk, or if we find - -- a match in another hunk with a range, and the corresponding line is prefixed - -- with a "-" only. If it is, then it's a deletion. - if old_line >= hunk.old_line and old_line <= old_line_end and hunk.old_range == 0 then - return "deleted" - end - if - (old_line >= hunk.old_line and old_line <= old_line_end) - or (old_line >= hunk.new_line and new_line <= new_line_end) - then - if line_was_removed(old_line, hunk, all_diff_output) then - return "deleted" - end - end - end - end - - -- If we can't find the line, this means the user is either trying to leave - -- a comment on an unchanged line in the new or old file SHA. This is only - -- allowed in the old file - return is_current_sha and "bad_file_unmodified" or "unmodified" + return is_current_sha and get_modification_type_from_new_sha(new_line, hunks, all_diff_output) + or get_modification_type_from_old_sha(old_line, new_line, hunks, all_diff_output) end ---Returns the matching line number of a line in the new/old version of the file compared to the current SHA. diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index a04727fb..1745d121 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -167,9 +167,11 @@ M.get_reviewer_data = function() local new_line = vim.api.nvim_win_get_cursor(new_win)[1] local old_line = vim.api.nvim_win_get_cursor(old_win)[1] - local modification_type = hunks.get_modification_type(old_line, new_line, current_file) + + local is_current_sha = M.is_current_sha() + local modification_type = hunks.get_modification_type(old_line, new_line, current_file, is_current_sha) if modification_type == nil then - u.notify("Error parsing hunks for modification type", vim.log.levels.ERROR) + u.notify("Error getting modification type", vim.log.levels.ERROR) return end @@ -177,8 +179,8 @@ M.get_reviewer_data = function() u.notify("Comments on unmodified lines will be placed in the old file", vim.log.levels.WARN) end - local current_bufnr = M.is_current_sha() and layout.b.file.bufnr or layout.a.file.bufnr - local opposite_bufnr = M.is_current_sha() and layout.a.file.bufnr or layout.b.file.bufnr + local current_bufnr = is_current_sha and layout.b.file.bufnr or layout.a.file.bufnr + local opposite_bufnr = is_current_sha and layout.a.file.bufnr or layout.b.file.bufnr local old_sha_win_id = u.get_window_id_by_buffer_id(layout.a.file.bufnr) local new_sha_win_id = u.get_window_id_by_buffer_id(layout.b.file.bufnr) diff --git a/lua/gitlab/reviewer/location.lua b/lua/gitlab/reviewer/location.lua index 782a68e8..d84eeacb 100755 --- a/lua/gitlab/reviewer/location.lua +++ b/lua/gitlab/reviewer/location.lua @@ -141,7 +141,8 @@ function Location:set_start_range(visual_range) end local reviewer = require("gitlab.reviewer") - local win_id = reviewer.is_current_sha() and self.reviewer_data.new_sha_win_id or self.reviewer_data.old_sha_win_id + local is_current_sha = reviewer.is_current_sha() + local win_id = is_current_sha and self.reviewer_data.new_sha_win_id or self.reviewer_data.old_sha_win_id if win_id == nil then u.notify("Error getting window number of SHA for start range", vim.log.levels.ERROR) return @@ -163,7 +164,11 @@ function Location:set_start_range(visual_range) return end - local modification_type = hunks.get_modification_type(old_line, new_line, current_file) + local modification_type = hunks.get_modification_type(old_line, new_line, current_file, is_current_sha) + if modification_type == nil then + u.notify("Error getting modification type for start of range", vim.log.levels.ERROR) + return + end self.location_data.line_range.start = { new_line = modification_type ~= "deleted" and new_line or nil, @@ -199,7 +204,14 @@ function Location:set_end_range(visual_range) return end - local modification_type = hunks.get_modification_type(old_line, new_line, current_file) + local reviewer = require("gitlab.reviewer") + local is_current_sha = reviewer.is_current_sha() + local modification_type = hunks.get_modification_type(old_line, new_line, current_file, is_current_sha) + if modification_type == nil then + u.notify("Error getting modification type for end of range", vim.log.levels.ERROR) + return + end + self.location_data.line_range["end"] = { new_line = modification_type ~= "deleted" and new_line or nil, old_line = modification_type ~= "added" and old_line or nil, From 2010c247a327cf9f16c9d1ee1465b17a2597a124 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sat, 2 Mar 2024 15:43:16 -0500 Subject: [PATCH 11/97] Fix: Rebuild Diagnostics + Signs (#203) * Updated lua/gitlab/actions/discussions/signs.lua * Initial commit of changes * Updated lua/gitlab/actions/discussions/signs.lua * Updated lua/gitlab/actions/discussions/signs.lua * Updated lua/gitlab/actions/discussions/init.lua * Updated lua/gitlab/actions/discussions/signs.lua * Split up signs and diagnostics * Moved common to common file * Continued modularization * added formatting * Moved signs/diagnostic lines into appropriate files * Updated lua/gitlab/reviewer/init.lua * Renamed is_current_sha => is_current_sha_focused * Updated lua/gitlab/reviewer/init.lua * Moved actions.indicators => indicators * Split discussions/signs apart into separate modules * Fixed filter function, fixed discussions * Updated lua/gitlab/indicators/diagnostics.lua * Setting diagnostics, handling and catching errors * Greatly simplified diagnostics setup * Re-split multi-line * Updated lua/gitlab/indicators/diagnostics.lua * Setting up signs * Updated lua/gitlab/indicators/signs.lua * Updated signs + display options * Updated lua/gitlab/state.lua * Updated doc/gitlab.nvim.txt * Updated readme * Added deprecation warning * formatting --- README.md | 37 +-- doc/gitlab.nvim.txt | 82 ++---- lua/gitlab/actions/discussions/init.lua | 43 ++- lua/gitlab/actions/discussions/signs.lua | 336 ----------------------- lua/gitlab/actions/discussions/tree.lua | 4 +- lua/gitlab/hunks/init.lua | 6 +- lua/gitlab/indicators/common.lua | 73 +++++ lua/gitlab/indicators/diagnostics.lua | 152 ++++++++++ lua/gitlab/indicators/signs.lua | 86 ++++++ lua/gitlab/reviewer/init.lua | 114 +++----- lua/gitlab/reviewer/location.lua | 21 +- lua/gitlab/state.lua | 35 +-- lua/gitlab/utils/init.lua | 15 + lua/gitlab/utils/list.lua | 18 +- script.lua | 12 + 15 files changed, 455 insertions(+), 579 deletions(-) delete mode 100644 lua/gitlab/actions/discussions/signs.lua create mode 100644 lua/gitlab/indicators/common.lua create mode 100644 lua/gitlab/indicators/diagnostics.lua create mode 100644 lua/gitlab/indicators/signs.lua create mode 100644 script.lua diff --git a/README.md b/README.md index efafc835..c80326df 100644 --- a/README.md +++ b/README.md @@ -170,35 +170,16 @@ require("gitlab").setup({ "pipeline", }, }, - discussion_sign_and_diagnostic = { + discussion_signs = { + enabled = true, -- Show diagnostics for gitlab comments in the reviewer skip_resolved_discussion = false, - skip_old_revision_discussion = true, - }, - discussion_sign = { - -- See :h sign_define for details about sign configuration. - enabled = true, - text = "💬", - linehl = nil, - texthl = nil, - culhl = nil, - numhl = nil, - priority = 20, -- Priority of sign, the lower the number the higher the priority - helper_signs = { - -- For multiline comments the helper signs are used to indicate the whole context - -- Priority of helper signs is lower than the main sign (-1). - enabled = true, - start = "↑", - mid = "|", - ["end"] = "↓", - }, - }, - discussion_diagnostic = { - -- If you want to customize diagnostics for discussions you can make special config - -- for namespace `gitlab_discussion`. See :h vim.diagnostic.config - enabled = true, severity = vim.diagnostic.severity.INFO, - code = nil, -- see :h diagnostic-structure - display_opts = {}, -- see opts in vim.diagnostic.set + virtual_text = false, + icons = { + comment = "→|", + range = " |", + }, + skip_old_revision_discussion = false, }, pipeline = { created = "", @@ -231,7 +212,7 @@ require("gitlab").setup({ directory = "Directory", directory_icon = "DiffviewFolderSign", file_name = "Normal", - } + } } }) ``` diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 65e6e10d..817f080e 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -204,35 +204,16 @@ you call this function with no values the defaults will be used: "labels", }, }, - discussion_sign_and_diagnostic = { - skip_resolved_discussion = false, - skip_old_revision_discussion = true, - }, - discussion_sign = { - -- See :h sign_define for details about sign configuration. - enabled = true, - text = "💬", - linehl = nil, - texthl = nil, - culhl = nil, - numhl = nil, - priority = 20, -- Priority of sign, the lower the number the higher the priority - helper_signs = { - -- For multiline comments the helper signs are used to indicate the whole context - -- Priority of helper signs is lower than the main sign (-1). - enabled = true, - start = "↑", - mid = "|", - ["end"] = "↓", - }, - }, - discussion_diagnostic = { - -- If you want to customize diagnostics for discussions you can make special config - -- for namespace `gitlab_discussion`. See :h vim.diagnostic.config - enabled = true, - severity = vim.diagnostic.severity.INFO, - code = nil, -- see :h diagnostic-structure - display_opts = {}, -- see opts in vim.diagnostic.set + discussion_signs = { + enabled = true, + skip_resolved_discussion = false, + skip_old_revision_discussion = false, + severity = vim.diagnostic.severity.INFO, + virtual_text = false, + icons = { + comment = "→|", + range = " |", + }, }, pipeline = { created = "", @@ -386,46 +367,17 @@ These labels will be visible in the summary panel, as long as you provide the SIGNS AND DIAGNOSTICS *gitlab.nvim.signs-and-diagnostics* -By default when reviewing files you will see signs and diagnostics (if enabled -in configuration). When cursor is on diagnostic line you can view discussion -thread by using `vim.diagnostic.show`. You can also jump to discussion tree -where you can reply, edit or delete discussion. +By default when reviewing files you will see diagnostics in the reviewer +for comments that have been added to a review. When the cursor is on +diagnostic line you can view discussion thread by using `vim.diagnostic.show`. +You can also jump to discussion tree for the given comment: >lua require("gitlab").move_to_discussion_tree_from_diagnostic() -< -The `discussion_sign` configuration controls the display of signs for -discussions in the reviewer pane. This allows users to jump to comments in the -current buffer in the reviewer pane directly. Keep in mind that the highlights -provided here can be overridden by other highlights (for example from -`diffview.nvim`). - -These diagnostics are configurable in the same way that diagnostics are -typically configurable in Neovim. For instance, the `severity` key sets the -diagnostic severity level and should be set to one of -`vim.diagnostic.severity.ERROR`, `vim.diagnostic.severity.WARN`, -`vim.diagnostic.severity.INFO`, or `vim.diagnostic.severity.HINT`. The -`display_opts` option configures the diagnostic display options (this is -directly used as opts in vim.diagnostic.set). Here you can configure values -like: - -- `virtual_text` - Show virtual text for diagnostics. -- `underline` - Underline text for diagnostics. - -Diagnostics for discussions use the `gitlab_discussion` namespace. See -|vim.diagnostic.config| and |diagnostic-structure| for more details. Signs and -diagnostics have common settings in `discussion_sign_and_diagnostic`. This -allows customizing if discussions that are resolved or no longer relevant -should still display visual indicators in the editor. The -`skip_resolved_discussion` Boolean will control visibility of resolved -discussions, and `skip_old_revision_discussion` whether to show signs and -diagnostics for discussions on outdated diff revisions. - -When interacting with multiline comments, the cursor must be on the "main" line -of diagnostic, where the `discussion_sign.text` is shown, otherwise -`vim.diagnostic.show` and `move_to_discussion_tree_from_diagnostic` will not -work. +You may skip resolved discussions by toggling `discussion_signs.skip_resolved_discussion` +in your setup function to true. By default, discussions from this plugin +are shown at the INFO severity level (see :h vim.diagnostic.severity). EMOJIS *gitlab.nvim.emojis* diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 6d614c61..dde14832 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -13,7 +13,8 @@ local List = require("gitlab.utils.list") local miscellaneous = require("gitlab.actions.miscellaneous") local discussions_tree = require("gitlab.actions.discussions.tree") local diffview_lib = require("diffview.lib") -local signs = require("gitlab.actions.discussions.signs") +local signs = require("gitlab.indicators.signs") +local diagnostics = require("gitlab.indicators.diagnostics") local winbar = require("gitlab.actions.discussions.winbar") local help = require("gitlab.actions.help") local emoji = require("gitlab.emoji") @@ -63,7 +64,8 @@ M.initialize_discussions = function() M.modifiable(false) end) reviewer.set_callback_for_reviewer_leave(function() - signs.clear_signs_and_diagnostics() + signs.clear_signs() + diagnostics.clear_diagnostics() M.modifiable(true) end) end @@ -92,11 +94,8 @@ end --- Take existing data and refresh the diagnostics, the winbar, and the signs M.refresh_view = function() - if state.settings.discussion_sign.enabled then - signs.refresh_signs(M.discussions) - end - if state.settings.discussion_diagnostic.enabled then - signs.refresh_diagnostics(M.discussions) + if state.settings.discussion_signs.enabled then + diagnostics.refresh_diagnostics(M.discussions) end if M.split_visible then local linked_is_focused = M.linked_bufnr == M.focused_bufnr @@ -168,7 +167,7 @@ M.toggle = function(callback) M.focused_bufnr = default_buffer M.switch_can_edit_bufs(false) - winbar.update_winbar(M.discussions, M.unlinked_discussions, default_discussions and "Discussions" or "Notes") + M.refresh_view() vim.api.nvim_set_current_win(current_window) if type(callback) == "function" then @@ -198,7 +197,7 @@ end ---Move to the discussion tree at the discussion from diagnostic on current line. M.move_to_discussion_tree = function() local current_line = vim.api.nvim_win_get_cursor(0)[1] - local diagnostics = vim.diagnostic.get(0, { namespace = signs.diagnostics_namespace, lnum = current_line - 1 }) + local d = vim.diagnostic.get(0, { namespace = diagnostics.diagnostics_namespace, lnum = current_line - 1 }) ---Function used to jump to the discussion tree after the menu selection. local jump_after_menu_selection = function(diagnostic) @@ -229,11 +228,11 @@ M.move_to_discussion_tree = function() end end - if #diagnostics == 0 then + if #d == 0 then u.notify("No diagnostics for this line", vim.log.levels.WARN) return - elseif #diagnostics > 1 then - vim.ui.select(diagnostics, { + elseif #d > 1 then + vim.ui.select(d, { prompt = "Choose discussion to jump to", format_item = function(diagnostic) return diagnostic.message @@ -245,7 +244,7 @@ M.move_to_discussion_tree = function() jump_after_menu_selection(diagnostic) end) else - jump_after_menu_selection(diagnostics[1]) + jump_after_menu_selection(d[1]) end end @@ -389,7 +388,7 @@ 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(tree) - local file_name, new_line, old_line, is_undefined_type, error = M.get_note_location(tree) + local file_name, new_line, old_line, error = M.get_note_location(tree) if error ~= nil then u.notify(error, vim.log.levels.ERROR) return @@ -403,13 +402,13 @@ M.jump_to_reviewer = function(tree) return end - reviewer.jump(file_name, new_line_int, old_line_int, { is_undefined_type = is_undefined_type }) + reviewer.jump(file_name, new_line_int, old_line_int) M.refresh_view() end -- This function (settings.discussion_tree.jump_to_file) will jump to the file changed in a new tab M.jump_to_file = function(tree) - local file_name, new_line, old_line, _, error = M.get_note_location(tree) + local file_name, new_line, old_line, error = M.get_note_location(tree) if error ~= nil then u.notify(error, vim.log.levels.ERROR) return @@ -911,21 +910,17 @@ end ---Get note location ---@param tree NuiTree ----@return string, string, string, boolean, string? +---@return string, string, string, string? M.get_note_location = function(tree) local node = tree:get_node() if node == nil then - return "", "", "", false, "Could not get node" + return "", "", "", "Could not get node" end local discussion_node = M.get_root_node(tree, node) if discussion_node == nil then - return "", "", "", false, "Could not get discussion node" + return "", "", "", "Could not get discussion node" end - return discussion_node.file_name, - discussion_node.new_line, - discussion_node.old_line, - discussion_node.undefined_type or false, - nil + return discussion_node.file_name, discussion_node.new_line, discussion_node.old_line, nil end ---@param tree NuiTree diff --git a/lua/gitlab/actions/discussions/signs.lua b/lua/gitlab/actions/discussions/signs.lua deleted file mode 100644 index e1c5747b..00000000 --- a/lua/gitlab/actions/discussions/signs.lua +++ /dev/null @@ -1,336 +0,0 @@ -local state = require("gitlab.state") -local u = require("gitlab.utils") -local reviewer = require("gitlab.reviewer") -local discussion_sign_name = "gitlab_discussion" -local discussion_helper_sign_start = "gitlab_discussion_helper_start" -local discussion_helper_sign_mid = "gitlab_discussion_helper_mid" -local discussion_helper_sign_end = "gitlab_discussion_helper_end" -local diagnostics_namespace = vim.api.nvim_create_namespace(discussion_sign_name) - -local M = {} -M.diagnostics_namespace = diagnostics_namespace - ----Clear all signs and diagnostics -M.clear_signs_and_diagnostics = function() - vim.fn.sign_unplace(discussion_sign_name) - vim.diagnostic.reset(diagnostics_namespace) -end - ----Refresh the discussion signs for currently loaded file in reviewer For convinience we use same ----string for sign name and sign group ( currently there is only one sign needed) ----@param discussions Discussion[] -M.refresh_signs = function(discussions) - local filtered_discussions = M.filter_discussions_for_signs_and_diagnostics(discussions) - if filtered_discussions == nil then - vim.diagnostic.reset(diagnostics_namespace) - return - end - - local new_signs, old_signs, error = M.parse_signs_from_discussions(filtered_discussions) - if error ~= nil then - vim.notify(error, vim.log.levels.ERROR) - return - end - - vim.fn.sign_unplace(discussion_sign_name) - reviewer.place_sign(old_signs, "old") - reviewer.place_sign(new_signs, "new") -end - ----Refresh the diagnostics for the currently reviewed file ----@param discussions Discussion[] -M.refresh_diagnostics = function(discussions) - -- Keep in mind that diagnostic line numbers use 0-based indexing while line numbers use - -- 1-based indexing - local filtered_discussions = M.filter_discussions_for_signs_and_diagnostics(discussions) - if filtered_discussions == nil then - vim.diagnostic.reset(diagnostics_namespace) - return - end - - local new_diagnostics, old_diagnostics = M.parse_diagnostics_from_discussions(filtered_discussions) - - vim.diagnostic.reset(diagnostics_namespace) - reviewer.set_diagnostics( - diagnostics_namespace, - new_diagnostics, - "new", - state.settings.discussion_diagnostic.display_opts - ) - reviewer.set_diagnostics( - diagnostics_namespace, - old_diagnostics, - "old", - state.settings.discussion_diagnostic.display_opts - ) -end - ----Filter all discussions which are relevant for currently visible signs and diagnostscs. ----@return Discussion[]? -M.filter_discussions_for_signs_and_diagnostics = function(all_discussions) - if type(all_discussions) ~= "table" then - return - end - local file = reviewer.get_current_file() - if not file then - return - end - local discussions = {} - for _, discussion in ipairs(all_discussions) do - local first_note = discussion.notes[1] - if - type(first_note.position) == "table" - and (first_note.position.new_path == file or first_note.position.old_path == file) - then - if - --Skip resolved discussions - not ( - state.settings.discussion_sign_and_diagnostic.skip_resolved_discussion - and first_note.resolvable - and first_note.resolved - ) - --Skip discussions from old revisions - and not ( - state.settings.discussion_sign_and_diagnostic.skip_old_revision_discussion - and u.from_iso_format_date_to_timestamp(first_note.created_at) - <= u.from_iso_format_date_to_timestamp(state.MR_REVISIONS[1].created_at) - ) - then - table.insert(discussions, discussion) - end - end - end - return discussions -end - ----Define signs for discussions if not already defined -M.setup_signs = function() - local discussion_sign = state.settings.discussion_sign - local signs = { - [discussion_sign_name] = discussion_sign.text, - [discussion_helper_sign_start] = discussion_sign.helper_signs.start, - [discussion_helper_sign_mid] = discussion_sign.helper_signs.mid, - [discussion_helper_sign_end] = discussion_sign.helper_signs["end"], - } - for sign_name, sign_text in pairs(signs) do - if #vim.fn.sign_getdefined(sign_name) == 0 then - vim.fn.sign_define(sign_name, { - text = sign_text, - linehl = discussion_sign.linehl, - texthl = discussion_sign.texthl, - culhl = discussion_sign.culhl, - numhl = discussion_sign.numhl, - }) - end - end -end - ----Iterates over each discussion and returns a list of tables with sign ----data, for instance group, priority, line number etc. ----@param discussions Discussion[] ----@return DiagnosticTable[], DiagnosticTable[], string? -M.parse_diagnostics_from_discussions = function(discussions) - local new_diagnostics = {} - local old_diagnostics = {} - for _, discussion in ipairs(discussions) do - local first_note = discussion.notes[1] - local message = "" - for _, note in ipairs(discussion.notes) do - message = message .. M.build_note_header(note) .. "\n" .. note.body .. "\n" - end - - local diagnostic = { - message = message, - col = 0, - severity = state.settings.discussion_diagnostic.severity, - user_data = { discussion_id = discussion.id, header = M.build_note_header(discussion.notes[1]) }, - source = "gitlab", - code = state.settings.discussion_diagnostic.code, - } - - -- Diagnostics for line range discussions are tricky - you need to set lnum to be the - -- line number equal to note.position.new_line or note.position.old_line because that is the - -- only line where you can trigger the diagnostic to show. This also needs to be in sync - -- with the sign placement. - local line_range = first_note.position.line_range - if line_range ~= nil then - local start_old_line, start_new_line = M.parse_line_code(line_range.start.line_code) - local end_old_line, end_new_line = M.parse_line_code(line_range["end"].line_code) - - local start_type = line_range.start.type - if start_type == "new" then - local new_diagnostic - if first_note.position.new_line == start_new_line then - new_diagnostic = { - lnum = start_new_line - 1, - end_lnum = end_new_line - 1, - } - else - new_diagnostic = { - lnum = end_new_line - 1, - end_lnum = start_new_line - 1, - } - end - new_diagnostic = vim.tbl_deep_extend("force", new_diagnostic, diagnostic) - table.insert(new_diagnostics, new_diagnostic) - elseif start_type == "old" or start_type == "expanded" or start_type == "" then - local old_diagnostic - if first_note.position.old_line == start_old_line then - old_diagnostic = { - lnum = start_old_line - 1, - end_lnum = end_old_line - 1, - } - else - old_diagnostic = { - lnum = end_old_line - 1, - end_lnum = start_old_line - 1, - } - end - old_diagnostic = vim.tbl_deep_extend("force", old_diagnostic, diagnostic) - table.insert(old_diagnostics, old_diagnostic) - else -- Comments on expanded, non-changed lines - return {}, {}, string.format("Unsupported line range type found for discussion %s", discussion.id) - end - else -- Diagnostics for single line discussions. - if first_note.position.new_line ~= nil and first_note.position.old_line == nil then - local new_diagnostic = { - lnum = first_note.position.new_line - 1, - } - new_diagnostic = vim.tbl_deep_extend("force", new_diagnostic, diagnostic) - table.insert(new_diagnostics, new_diagnostic) - end - if first_note.position.old_line ~= nil then - local old_diagnostic = { - lnum = first_note.position.old_line - 1, - } - old_diagnostic = vim.tbl_deep_extend("force", old_diagnostic, diagnostic) - table.insert(old_diagnostics, old_diagnostic) - end - end - end - - return new_diagnostics, old_diagnostics -end - -local base_sign = { - name = discussion_sign_name, - group = discussion_sign_name, - priority = state.settings.discussion_sign.priority, - buffer = nil, -} -local base_helper_sign = { - name = discussion_sign_name, - group = discussion_sign_name, - priority = state.settings.discussion_sign.priority - 1, - buffer = nil, -} - ----Iterates over each discussion and returns a list of tables with sign ----data, for instance group, priority, line number etc. ----@param discussions Discussion[] ----@return SignTable[], SignTable[], string? -M.parse_signs_from_discussions = function(discussions) - local new_signs = {} - local old_signs = {} - for _, discussion in ipairs(discussions) do - local first_note = discussion.notes[1] - local line_range = first_note.position.line_range - - -- We have a line range which means we either have a multi-line comment or a comment - -- on a line in an "expanded" part of a file - if line_range ~= nil then - local start_old_line, start_new_line = M.parse_line_code(line_range.start.line_code) - local end_old_line, end_new_line = M.parse_line_code(line_range["end"].line_code) - local discussion_line, start_line, end_line - - local start_type = line_range.start.type - if start_type == "new" then - table.insert( - new_signs, - vim.tbl_deep_extend("force", { - id = first_note.id, - lnum = first_note.position.new_line, - }, base_sign) - ) - discussion_line = first_note.position.new_line - start_line = start_new_line - end_line = end_new_line - elseif start_type == "old" or start_type == "expanded" or start_type == "" then - table.insert( - old_signs, - vim.tbl_deep_extend("force", { - id = first_note.id, - lnum = first_note.position.old_line, - }, base_sign) - ) - discussion_line = first_note.position.old_line - start_line = start_old_line - end_line = end_old_line - else - return {}, {}, string.format("Unsupported line range type found for discussion %s", discussion.id) - end - - -- Helper signs does not have specific ids currently. - if state.settings.discussion_sign.helper_signs.enabled then - local helper_signs = {} - if start_line > end_line then - start_line, end_line = end_line, start_line - end - for i = start_line, end_line do - if i ~= discussion_line then - local sign_name - if i == start_line then - sign_name = discussion_helper_sign_start - elseif i == end_line then - sign_name = discussion_helper_sign_end - else - sign_name = discussion_helper_sign_mid - end - table.insert( - helper_signs, - vim.tbl_deep_extend("keep", { - name = sign_name, - lnum = i, - }, base_helper_sign) - ) - end - end - if start_type == "new" then - vim.list_extend(new_signs, helper_signs) - elseif start_type == "old" or start_type == "expanded" or start_type == "" then - vim.list_extend(old_signs, helper_signs) - end - end - else -- The note is a normal comment, not a range comment - local sign = vim.tbl_deep_extend("force", { - id = first_note.id, - }, base_sign) - if first_note.position.new_line ~= nil and first_note.position.old_line == nil then - table.insert(new_signs, vim.tbl_deep_extend("force", { lnum = first_note.position.new_line }, sign)) - end - if first_note.position.old_line ~= nil then - table.insert(old_signs, vim.tbl_deep_extend("force", { lnum = first_note.position.old_line }, sign)) - end - end - end - - return new_signs, old_signs, nil -end - ----Parse line code and return old and new line numbers ----@param line_code string gitlab line code -> 588440f66559714280628a4f9799f0c4eb880a4a_10_10 ----@return number? -M.parse_line_code = function(line_code) - local line_code_regex = "%w+_(%d+)_(%d+)" - local old_line, new_line = line_code:match(line_code_regex) - return tonumber(old_line), tonumber(new_line) -end - ----Build note header from note. ----@param note Note ----@return string -M.build_note_header = function(note) - return "@" .. note.author.username .. " " .. u.time_since(note.created_at) -end - -return M diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index e2ded22a..d52ed1a0 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -81,7 +81,7 @@ end ---Build note header from note. ---@param note Note ---@return string -local function build_note_header(note) +M.build_note_header = function(note) return "@" .. note.author.username .. " " .. u.time_since(note.created_at) end @@ -112,7 +112,7 @@ local function build_note_body(note, resolve_info) or state.settings.discussion_tree.unresolved end - local noteHeader = build_note_header(note) .. " " .. resolve_symbol + local noteHeader = M.build_note_header(note) .. " " .. resolve_symbol return noteHeader, text_nodes end diff --git a/lua/gitlab/hunks/init.lua b/lua/gitlab/hunks/init.lua index 1f17f5b4..06e9fbcf 100644 --- a/lua/gitlab/hunks/init.lua +++ b/lua/gitlab/hunks/init.lua @@ -217,9 +217,9 @@ end ---@param old_line number|nil ---@param new_line number|nil ---@param current_file string ----@param is_current_sha boolean +---@param is_current_sha_focused boolean ---@return string|nil -function M.get_modification_type(old_line, new_line, current_file, is_current_sha) +function M.get_modification_type(old_line, new_line, current_file, is_current_sha_focused) local hunk_and_diff_data = parse_hunks_and_diff(current_file, state.INFO.target_branch) if hunk_and_diff_data.hunks == nil then return @@ -227,7 +227,7 @@ function M.get_modification_type(old_line, new_line, current_file, is_current_sh local hunks = hunk_and_diff_data.hunks local all_diff_output = hunk_and_diff_data.all_diff_output - return is_current_sha and get_modification_type_from_new_sha(new_line, hunks, all_diff_output) + return is_current_sha_focused and get_modification_type_from_new_sha(new_line, hunks, all_diff_output) or get_modification_type_from_old_sha(old_line, new_line, hunks, all_diff_output) end diff --git a/lua/gitlab/indicators/common.lua b/lua/gitlab/indicators/common.lua new file mode 100644 index 00000000..b09a66bb --- /dev/null +++ b/lua/gitlab/indicators/common.lua @@ -0,0 +1,73 @@ +local u = require("gitlab.utils") +local state = require("gitlab.state") +local reviewer = require("gitlab.reviewer") +local List = require("gitlab.utils.list") + +local M = {} + +---Filter all discussions which are relevant for currently visible signs and diagnostics. +---@return Discussion[] +M.filter_placeable_discussions = function(all_discussions) + if type(all_discussions) ~= "table" then + return {} + end + local file = reviewer.get_current_file() + if not file then + return {} + end + return List.new(all_discussions):filter(function(discussion) + local first_note = discussion.notes[1] + return type(first_note.position) == "table" + --Do not include unlinked notes + and (first_note.position.new_path == file or first_note.position.old_path == file) + --Skip resolved discussions if user wants to + and not (state.settings.discussion_signs.skip_resolved_discussion and first_note.resolvable and first_note.resolved) + --Skip discussions from old revisions + and not ( + state.settings.discussion_signs.skip_old_revision_discussion + and u.from_iso_format_date_to_timestamp(first_note.created_at) + <= u.from_iso_format_date_to_timestamp(state.MR_REVISIONS[1].created_at) + ) + end) +end + +M.parse_line_code = function(line_code) + local line_code_regex = "%w+_(%d+)_(%d+)" + local old_line, new_line = line_code:match(line_code_regex) + return tonumber(old_line), tonumber(new_line) +end + +---@param discussion Discussion +---@return boolean +M.is_old_sha = function(discussion) + local first_note = discussion.notes[1] + return first_note.position.old_line ~= nil +end + +---@param discussion Discussion +---@return boolean +M.is_new_sha = function(discussion) + return not M.is_old_sha(discussion) +end + +---@param discussion Discussion +---@return boolean +M.is_single_line = function(discussion) + local first_note = discussion.notes[1] + local line_range = first_note.position.line_range + return line_range == nil +end + +---@param discussion Discussion +---@return boolean +M.is_multi_line = function(discussion) + return not M.is_single_line(discussion) +end + +---@param discussion Discussion +---@return Note +M.get_first_note = function(discussion) + return discussion.notes[1] +end + +return M diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua new file mode 100644 index 00000000..13eeba14 --- /dev/null +++ b/lua/gitlab/indicators/diagnostics.lua @@ -0,0 +1,152 @@ +local u = require("gitlab.utils") +local diffview_lib = require("diffview.lib") +local discussion_tree = require("gitlab.actions.discussions.tree") +local common = require("gitlab.indicators.common") +local List = require("gitlab.utils.list") +local state = require("gitlab.state") +local discussion_sign_name = "gitlab_discussion" + +local M = {} +local diagnostics_namespace = vim.api.nvim_create_namespace(discussion_sign_name) +M.diagnostics_namespace = diagnostics_namespace +M.discussion_sign_name = discussion_sign_name +M.clear_diagnostics = function() + vim.diagnostic.reset(diagnostics_namespace) +end + +-- Display options for the diagnostic +local display_opts = { + virtual_text = state.settings.discussion_signs.virtual_text, + severity_sort = true, + underline = false, +} + +---Takes some range information and data about a discussion +---and creates a diagnostic to be placed in the reviewer +---@param range_info table +---@param discussion Discussion +---@return Diagnostic +local function create_diagnostic(range_info, discussion) + local message = "" + for _, note in ipairs(discussion.notes) do + message = message .. discussion_tree.build_note_header(note) .. "\n" .. note.body .. "\n" + end + + local diagnostic = { + message = message, + col = 0, + severity = state.settings.discussion_signs.severity, + user_data = { discussion_id = discussion.id, header = discussion_tree.build_note_header(discussion.notes[1]) }, + source = "gitlab", + code = "gitlab.nvim", + } + return vim.tbl_deep_extend("force", diagnostic, range_info) +end + +---Creates a single line diagnostic +---@param discussion Discussion +---@return Diagnostic +local create_single_line_diagnostic = function(discussion) + local first_note = discussion.notes[1] + return create_diagnostic({ + lnum = first_note.position.new_line - 1, + }, discussion) +end + +---Creates a mutli-line line diagnostic +---@param discussion Discussion +---@return Diagnostic +local create_multiline_diagnostic = function(discussion) + local first_note = discussion.notes[1] + local line_range = first_note.position.line_range + if line_range == nil then + error("Parsing multi-line comment but note does not contain line range") + end + + local start_old_line, start_new_line = common.parse_line_code(line_range.start.line_code) + + if common.is_new_sha(discussion) then + return create_diagnostic({ + lnum = start_new_line - 1, + end_lnum = first_note.position.new_line - 1, + }, discussion) + else + return create_diagnostic({ + lnum = start_old_line - 1, + end_lnum = first_note.position.old_line - 1, + }, discussion) + end +end + +---Set diagnostics in currently new SHA. +---@param namespace number namespace for diagnostics +---@param diagnostics table see :h vim.diagnostic.set +---@param opts table? see :h vim.diagnostic.set +local set_diagnostics_in_new_sha = function(namespace, diagnostics, opts) + local view = diffview_lib.get_current_view() + if not view then + return + end + vim.diagnostic.set(namespace, view.cur_layout.b.file.bufnr, diagnostics, opts) + require("gitlab.indicators.signs").set_signs(diagnostics, view.cur_layout.b.file.bufnr) +end + +---Set diagnostics in old SHA. +---@param namespace number namespace for diagnostics +---@param diagnostics table see :h vim.diagnostic.set +---@param opts table? see :h vim.diagnostic.set +local set_diagnostics_in_old_sha = function(namespace, diagnostics, opts) + local view = diffview_lib.get_current_view() + if not view then + return + end + vim.diagnostic.set(namespace, view.cur_layout.a.file.bufnr, diagnostics, opts) + require("gitlab.indicators.signs").set_signs(diagnostics, view.cur_layout.a.file.bufnr) +end + +---Refresh the diagnostics for the currently reviewed file +---@param discussions Discussion[] +M.refresh_diagnostics = function(discussions) + local ok, err = pcall(function() + require("gitlab.indicators.signs").clear_signs() + M.clear_diagnostics() + local filtered_discussions = common.filter_placeable_discussions(discussions) + if filtered_discussions == nil then + return + end + + local new_diagnostics = M.parse_new_diagnostics(filtered_discussions) + set_diagnostics_in_new_sha(diagnostics_namespace, new_diagnostics, display_opts) + + local old_diagnostics = M.parse_old_diagnostics(filtered_discussions) + set_diagnostics_in_old_sha(diagnostics_namespace, old_diagnostics, display_opts) + end) + + if not ok then + u.notify(string.format("Error setting diagnostics: %s", err), vim.log.levels.ERROR) + end +end + +---Iterates over each discussion and returns a list of tables with sign +---data, for instance group, priority, line number etc for the new SHA +---@param discussions Discussion[] +---@return DiagnosticTable[] +M.parse_new_diagnostics = function(discussions) + local new_diagnostics = List.new(discussions):filter(common.is_new_sha) + local single_line = new_diagnostics:filter(common.is_single_line):map(create_single_line_diagnostic) + local multi_line = new_diagnostics:filter(common.is_multi_line):map(create_multiline_diagnostic) + return u.combine(single_line, multi_line) +end + +---Iterates over each discussion and returns a list of tables with sign +---data, for instance group, priority, line number etc for the old SHA +---@param discussions Discussion[] +---@return DiagnosticTable[] +M.parse_old_diagnostics = function(discussions) + local old_diagnostics = List.new(discussions):filter(common.is_old_sha) + local single_line = old_diagnostics:filter(common.is_single_line):map(create_single_line_diagnostic) + local multi_line = old_diagnostics:filter(common.is_multi_line):map(create_multiline_diagnostic) + return u.combine(single_line, multi_line) +end + +return M diff --git a/lua/gitlab/indicators/signs.lua b/lua/gitlab/indicators/signs.lua new file mode 100644 index 00000000..9b94a4e4 --- /dev/null +++ b/lua/gitlab/indicators/signs.lua @@ -0,0 +1,86 @@ +local state = require("gitlab.state") +local discussion_sign_name = require("gitlab.indicators.diagnostics").discussion_sign_name +local namespace = require("gitlab.indicators.diagnostics").diagnostics_namespace + +local M = {} +M.clear_signs = function() + vim.fn.sign_unplace(discussion_sign_name) +end + +local gitlab_comment = "GitlabComment" +local gitlab_range = "GitlabRange" + +local severity_map = { + "Error", + "Warn", + "Info", + "Hint", +} + +---Refresh the discussion signs for currently loaded file in reviewer For convinience we use same +---string for sign name and sign group ( currently there is only one sign needed) +---@param diagnostics Diagnostic[] +---@param bufnr number +M.set_signs = function(diagnostics, bufnr) + if not state.settings.discussion_sign.enabled then + return + end + + -- Filter diagnostics from the 'gitlab' source and apply custom signs + for _, diagnostic in ipairs(diagnostics) do + local sign_id = string.format("%s__%d", namespace, diagnostic.lnum) + + if diagnostic.end_lnum then + local linenr = diagnostic.lnum + 1 + while linenr < diagnostic.end_lnum do + linenr = linenr + 1 + vim.fn.sign_place( + sign_id, + discussion_sign_name, + "DiagnosticSign" .. M.severity .. gitlab_range, + bufnr, + { lnum = linenr, priority = 999998 } + ) + end + end + + vim.fn.sign_place( + sign_id, + discussion_sign_name, + "DiagnosticSign" .. M.severity .. gitlab_comment, + bufnr, + { lnum = diagnostic.lnum + 1, priority = 999999 } + ) + + -- TODO: Detect whether diagnostic is ranged and set helper signs + end +end + +---Define signs for discussions +M.setup_signs = function() + local discussion_sign_settings = state.settings.discussion_signs + local comment_icon = discussion_sign_settings.icons.comment + local range_icon = discussion_sign_settings.icons.range + M.severity = severity_map[state.settings.discussion_signs.severity] + local signs = { "Error", "Warn", "Hint", "Info" } + for _, type in ipairs(signs) do + -- Define comment highlight group + local hl = "DiagnosticSign" .. type + local comment_hl = hl .. gitlab_comment + vim.fn.sign_define(comment_hl, { + text = comment_icon, + texthl = comment_hl, + }) + vim.cmd(string.format("highlight link %s %s", comment_hl, hl)) + + -- Define range highlight group + local range_hl = hl .. gitlab_range + vim.fn.sign_define(range_hl, { + text = range_icon, + texthl = range_hl, + }) + vim.cmd(string.format("highlight link %s %s", range_hl, hl)) + end +end + +return M diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 1745d121..4e060989 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -1,4 +1,9 @@ --- This Module contains all of the reviewer code for diffview +-- This Module contains all of the reviewer code. This is the code +-- that parses or interacts with diffview directly, such as opening +-- and closing, getting metadata about the current view, and registering +-- callbacks for open/close actions. + +local List = require("gitlab.utils.list") local u = require("gitlab.utils") local state = require("gitlab.state") local git = require("gitlab.git") @@ -55,6 +60,13 @@ M.open = function() u.notify("This merge request has conflicts!", vim.log.levels.WARN) end + if state.settings.discussion_diagnostic ~= nil or state.settings.discussion_sign ~= nil then + u.notify( + "Diagnostics are now configured settings.discussion_signs, see :h gitlab.signs_and_diagnostics", + vim.log.levels.WARN + ) + end + -- Register Diffview hook for close event to set tab page # to nil local on_diffview_closed = function(view) if view.tabpage == M.tabnr then @@ -83,8 +95,7 @@ end ---@param file_name string ---@param new_line number|nil ---@param old_line number|nil ----@param opts table -M.jump = function(file_name, new_line, old_line, opts) +M.jump = function(file_name, new_line, old_line) if M.tabnr == nil then u.notify("Can't jump to Diffvew. Is it open?", vim.log.levels.ERROR) return @@ -96,34 +107,24 @@ M.jump = function(file_name, new_line, old_line, opts) u.notify("Could not find Diffview view", vim.log.levels.ERROR) return end + if not async_ok then + u.notify("Could not load Diffview async", vim.log.levels.ERROR) + return + end + local files = view.panel:ordered_file_list() + local file = List.new(files):find(function(file) + return file.path == file_name + end) + async.await(view:set_file(file)) + local layout = view.cur_layout - for _, file in ipairs(files) do - if file.path == file_name then - if not async_ok then - u.notify("Could not load Diffview async", vim.log.levels.ERROR) - return - end - async.await(view:set_file(file)) - -- TODO: Ranged comments on unchanged lines will have both a - -- new line and a old line. - -- - -- The same is true when the user leaves a single-line comment - -- on an unchanged line in the "b" buffer. - -- - -- We need to distinguish them somehow from - -- range comments (which also have this) so that we can know - -- which buffer to jump to. Right now, we jump to the wrong - -- buffer for ranged comments on unchanged lines. - if new_line ~= nil and not opts.is_undefined_type then - layout.b:focus() - vim.api.nvim_win_set_cursor(0, { new_line, 0 }) - elseif old_line ~= nil then - layout.a:focus() - vim.api.nvim_win_set_cursor(0, { old_line, 0 }) - end - break - end + if old_line == nil then + layout.b:focus() + vim.api.nvim_win_set_cursor(0, { new_line, 0 }) + else + layout.a:focus() + vim.api.nvim_win_set_cursor(0, { old_line, 0 }) end end @@ -168,8 +169,8 @@ M.get_reviewer_data = function() local new_line = vim.api.nvim_win_get_cursor(new_win)[1] local old_line = vim.api.nvim_win_get_cursor(old_win)[1] - local is_current_sha = M.is_current_sha() - local modification_type = hunks.get_modification_type(old_line, new_line, current_file, is_current_sha) + local is_current_sha_focused = M.is_current_sha_focused() + local modification_type = hunks.get_modification_type(old_line, new_line, current_file, is_current_sha_focused) if modification_type == nil then u.notify("Error getting modification type", vim.log.levels.ERROR) return @@ -179,8 +180,8 @@ M.get_reviewer_data = function() u.notify("Comments on unmodified lines will be placed in the old file", vim.log.levels.WARN) end - local current_bufnr = is_current_sha and layout.b.file.bufnr or layout.a.file.bufnr - local opposite_bufnr = is_current_sha and layout.a.file.bufnr or layout.b.file.bufnr + local current_bufnr = is_current_sha_focused and layout.b.file.bufnr or layout.a.file.bufnr + local opposite_bufnr = is_current_sha_focused and layout.a.file.bufnr or layout.b.file.bufnr local old_sha_win_id = u.get_window_id_by_buffer_id(layout.a.file.bufnr) local new_sha_win_id = u.get_window_id_by_buffer_id(layout.b.file.bufnr) @@ -198,7 +199,7 @@ end ---Return whether user is focused on the new version of the file ---@return boolean -M.is_current_sha = function() +M.is_current_sha_focused = function() local view = diffview_lib.get_current_view() local layout = view.cur_layout local b_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr) @@ -213,14 +214,6 @@ M.is_current_sha = function() return current_win == b_win end ----Checks whether the lines in the two buffers are the same ----@return boolean -M.lines_are_same = function(layout, a_cursor, b_cursor) - local line_a = u.get_line_content(layout.a.file.bufnr, a_cursor) - local line_b = u.get_line_content(layout.b.file.bufnr, b_cursor) - return line_a == line_b -end - ---Get currently shown file ---@return string|nil M.get_current_file = function() @@ -231,43 +224,6 @@ M.get_current_file = function() return view.panel.cur_file.path end --- Places a sign on the line for currently reviewed file. ----@param signs SignTable[] table of signs. See :h sign_placelist ----@param type string "new" if diagnostic should be in file after changes else "old" -M.place_sign = function(signs, type) - local view = diffview_lib.get_current_view() - if not view then - return - end - if type == "new" then - for _, sign in ipairs(signs) do - sign.buffer = view.cur_layout.b.file.bufnr - end - elseif type == "old" then - for _, sign in ipairs(signs) do - sign.buffer = view.cur_layout.a.file.bufnr - end - end - vim.fn.sign_placelist(signs) -end - ----Set diagnostics in currently reviewed file. ----@param namespace integer namespace for diagnostics ----@param diagnostics table see :h vim.diagnostic.set ----@param type string "new" if diagnostic should be in file after changes else "old" ----@param opts table? see :h vim.diagnostic.set -M.set_diagnostics = function(namespace, diagnostics, type, opts) - local view = diffview_lib.get_current_view() - if not view then - return - end - if type == "new" and view.cur_layout.b.file.bufnr then - vim.diagnostic.set(namespace, view.cur_layout.b.file.bufnr, diagnostics, opts) - elseif type == "old" and view.cur_layout.a.file.bufnr then - vim.diagnostic.set(namespace, view.cur_layout.a.file.bufnr, diagnostics, opts) - end -end - ---Diffview exposes events which can be used to setup autocommands. ---@param callback fun(opts: table) - for more information about opts see callback in :h nvim_create_autocmd M.set_callback_for_file_changed = function(callback) diff --git a/lua/gitlab/reviewer/location.lua b/lua/gitlab/reviewer/location.lua index d84eeacb..b0299be9 100755 --- a/lua/gitlab/reviewer/location.lua +++ b/lua/gitlab/reviewer/location.lua @@ -91,8 +91,8 @@ end ---@return number|nil function Location:get_line_number_from_new_sha(line) local reviewer = require("gitlab.reviewer") - local is_current_sha = reviewer.is_current_sha() - if is_current_sha then + local is_current_sha_focused = reviewer.is_current_sha_focused() + if is_current_sha_focused then return line end -- Otherwise we want to get the matching line in the opposite buffer @@ -106,8 +106,8 @@ end ---@return number|nil function Location:get_line_number_from_old_sha(line) local reviewer = require("gitlab.reviewer") - local is_current_sha = reviewer.is_current_sha() - if not is_current_sha then + local is_current_sha_focused = reviewer.is_current_sha_focused() + if not is_current_sha_focused then return line end @@ -120,7 +120,8 @@ end ---@return number|nil function Location:get_current_line() local reviewer = require("gitlab.reviewer") - local win_id = reviewer.is_current_sha() and self.reviewer_data.new_sha_win_id or self.reviewer_data.old_sha_win_id + local win_id = reviewer.is_current_sha_focused() and self.reviewer_data.new_sha_win_id + or self.reviewer_data.old_sha_win_id if win_id == nil then return end @@ -141,8 +142,8 @@ function Location:set_start_range(visual_range) end local reviewer = require("gitlab.reviewer") - local is_current_sha = reviewer.is_current_sha() - local win_id = is_current_sha and self.reviewer_data.new_sha_win_id or self.reviewer_data.old_sha_win_id + local is_current_sha_focused = reviewer.is_current_sha_focused() + local win_id = is_current_sha_focused and self.reviewer_data.new_sha_win_id or self.reviewer_data.old_sha_win_id if win_id == nil then u.notify("Error getting window number of SHA for start range", vim.log.levels.ERROR) return @@ -164,7 +165,7 @@ function Location:set_start_range(visual_range) return end - local modification_type = hunks.get_modification_type(old_line, new_line, current_file, is_current_sha) + local modification_type = hunks.get_modification_type(old_line, new_line, current_file, is_current_sha_focused) if modification_type == nil then u.notify("Error getting modification type for start of range", vim.log.levels.ERROR) return @@ -205,8 +206,8 @@ function Location:set_end_range(visual_range) end local reviewer = require("gitlab.reviewer") - local is_current_sha = reviewer.is_current_sha() - local modification_type = hunks.get_modification_type(old_line, new_line, current_file, is_current_sha) + local is_current_sha_focused = reviewer.is_current_sha_focused() + local modification_type = hunks.get_modification_type(old_line, new_line, current_file, is_current_sha_focused) if modification_type == nil then u.notify("Error getting modification type for end of range", vim.log.levels.ERROR) return diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 992e66ba..8d6acb04 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -114,35 +114,16 @@ M.settings = { "labels", }, }, - discussion_sign_and_diagnostic = { - skip_resolved_discussion = false, - skip_old_revision_discussion = false, - }, - discussion_sign = { - -- See :h sign_define for details about sign configuration. - enabled = true, - text = "💬", - linehl = nil, - texthl = nil, - culhl = nil, - numhl = nil, - priority = 20, - helper_signs = { - -- For multiline comments the helper signs are used to indicate the whole context - -- Priority of helper signs is lower than the main sign (-1). - enabled = true, - start = "↑", - mid = "|", - ["end"] = "↓", - }, - }, - discussion_diagnostic = { - -- If you want to customize diagnostics for discussions you can make special config - -- for namespace `gitlab_discussion`. See :h vim.diagnostic.config + discussion_signs = { enabled = true, + skip_resolved_discussion = false, severity = vim.diagnostic.severity.INFO, - code = nil, -- see :h diagnostic-structure - display_opts = {}, -- this is dirrectly used as opts in vim.diagnostic.set, see :h vim.diagnostic.config. + virtual_text = false, + icons = { + comment = "→|", + range = " |", + }, + skip_old_revision_discussion = false, }, pipeline = { created = "", diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index fe05ccbc..ec258666 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -59,6 +59,21 @@ M.merge = function(defaults, overrides) return vim.tbl_deep_extend("force", defaults, overrides) end +---Combines two list-like (non associative) tables, keeping values from both +---@param t1 table The first table +---@param ... table[] The first table +---@return table +M.combine = function(t1, ...) + local result = t1 + local tables = { ... } + for _, t in ipairs(tables) do + for _, v in ipairs(t) do + table.insert(result, v) + end + end + return result +end + ---Pluralizes the input word, e.g. "3 cows" ---@param num integer The count of the item/word ---@param word string The word to pluralize diff --git a/lua/gitlab/utils/list.lua b/lua/gitlab/utils/list.lua index 27180388..71db0023 100644 --- a/lua/gitlab/utils/list.lua +++ b/lua/gitlab/utils/list.lua @@ -13,8 +13,8 @@ end ---@return List @Returns a new list of elements mutated by func function List:map(func) local result = List.new() - for i, v in ipairs(self) do - result[i] = func(v) + for _, v in ipairs(self) do + table.insert(result, func(v)) end return result end @@ -25,9 +25,9 @@ end ---@return List @Returns a new list of elements for which func returns true function List:filter(func) local result = List.new() - for i, v in ipairs(self) do + for _, v in ipairs(self) do if func(v) == true then - result[i] = v + table.insert(result, v) end end return result @@ -56,11 +56,19 @@ function List:find(func) end function List:slice(first, last, step) - local sliced = {} + local sliced = List.new() for i = first or 1, last or #self, step or 1 do sliced[#sliced + 1] = self[i] end return sliced end +function List:values() + local result = {} + for _, v in ipairs(self) do + table.insert(result, v) + end + return result +end + return List diff --git a/script.lua b/script.lua new file mode 100644 index 00000000..df38c38f --- /dev/null +++ b/script.lua @@ -0,0 +1,12 @@ +local combine = function(t1, ...) + local result = t1 + local tables = { ... } + for _, t in ipairs(tables) do + for _, v in ipairs(t) do + table.insert(result, v) + end + end + return result +end + +vim.print(combine({ 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 })) From f6ada57ba106c2c6021ea55cb0aa4ad792725630 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sat, 2 Mar 2024 15:58:46 -0500 Subject: [PATCH 12/97] Fixes Off-By-One Issue w/ New SHA (#205) Fixes off-by one in new SHA comment creation, thank you @jakubbortlik for catching this --- lua/gitlab/hunks/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/hunks/init.lua b/lua/gitlab/hunks/init.lua index 06e9fbcf..2df8c044 100644 --- a/lua/gitlab/hunks/init.lua +++ b/lua/gitlab/hunks/init.lua @@ -70,7 +70,7 @@ local line_was_added = function(linnr, hunk, all_diff_output) -- index for the line number, we check to see if the line was added. local i = 0 local old_range = (found_hunk.old_range == 0 and found_hunk.old_line ~= 0) and 1 or found_hunk.old_range - for hunk_line_index = matching_line_index + old_range + 1, matching_line_index + old_range + found_hunk.new_range, 1 do + for hunk_line_index = matching_line_index + old_range, matching_line_index + old_range + found_hunk.new_range, 1 do local line_content = all_diff_output[hunk_line_index] if (found_hunk.new_line + i) == linnr then if string.match(line_content, "^%+") then From 949e0765dc65f2c30c877e8568573921f2ae45de Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sun, 3 Mar 2024 11:42:35 -0500 Subject: [PATCH 13/97] Fix: Reviewer Jump (#206) Fixes issue where jumping to the reviewer would jump to the wrong buffer (old SHA vs new SHA). Also addresses an issue in the refactor where ranged signs were being placed on top of comment signs. --- README.md | 8 +-- lua/gitlab/actions/discussions/init.lua | 95 +++++++++++++++++-------- lua/gitlab/actions/discussions/tree.lua | 16 ++--- lua/gitlab/indicators/signs.lua | 30 +++++--- lua/gitlab/reviewer/init.lua | 6 +- lua/gitlab/state.lua | 1 + lua/gitlab/utils/init.lua | 28 +++----- script.lua | 12 ---- 8 files changed, 102 insertions(+), 94 deletions(-) delete mode 100644 script.lua diff --git a/README.md b/README.md index c80326df..2f6cf8e7 100644 --- a/README.md +++ b/README.md @@ -172,14 +172,14 @@ require("gitlab").setup({ }, discussion_signs = { enabled = true, -- Show diagnostics for gitlab comments in the reviewer - skip_resolved_discussion = false, - severity = vim.diagnostic.severity.INFO, - virtual_text = false, + skip_resolved_discussion = false, -- Show diagnostics for resolved discussions + severity = vim.diagnostic.severity.INFO, -- ERROR, WARN, INFO, or HINT + virtual_text = false, -- Whether to show the comment text inline as floating virtual text + priority = 100, -- Higher will override LSP warnings, etc icons = { comment = "→|", range = " |", }, - skip_old_revision_discussion = false, }, pipeline = { created = "", diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index dde14832..eb98661d 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -13,6 +13,7 @@ local List = require("gitlab.utils.list") local miscellaneous = require("gitlab.actions.miscellaneous") local discussions_tree = require("gitlab.actions.discussions.tree") local diffview_lib = require("diffview.lib") +local common = require("gitlab.indicators.common") local signs = require("gitlab.indicators.signs") local diagnostics = require("gitlab.indicators.diagnostics") local winbar = require("gitlab.actions.discussions.winbar") @@ -386,35 +387,82 @@ M.toggle_discussion_resolved = function(tree) 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(tree) - local file_name, new_line, old_line, error = M.get_note_location(tree) - if error ~= nil then - u.notify(error, vim.log.levels.ERROR) - return +---Takes a node and returns the line where the note is positioned in the new SHA. If +---the line is not in the new SHA, returns nil +---@param node any +---@return number|nil +local function get_new_line(node) + if node.new_line == nil then + return nil end - local new_line_int = tonumber(new_line) - local old_line_int = tonumber(old_line) + ---@type GitlabLineRange|nil + local range = node.range + if range == nil then + if node.new_line == nil then + return nil + end + return node.new_line + end - if new_line_int == nil and old_line_int == nil then - u.notify("Could not get new or old line", vim.log.levels.ERROR) - return + local start_new_line, _ = common.parse_line_code(range.start.line_code) + return start_new_line +end + +---Takes a node and returns the line where the note is positioned in the old SHA. If +---the line is not in the old SHA, returns nil +---@param node any +---@return number|nil +local function get_old_line(node) + if node.old_line == nil then + return nil + end + + ---@type GitlabLineRange|nil + local range = node.range + if range == nil then + return node.old_line end - reviewer.jump(file_name, new_line_int, old_line_int) + local _, start_old_line = common.parse_line_code(range.start.line_code) + return start_old_line +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(tree) + local node = tree:get_node() + local root_node = M.get_root_node(tree, node) + if root_node == nil then + u.notify("Could not get discussion node", vim.log.levels.ERROR) + return + end + reviewer.jump(root_node.file_name, get_new_line(root_node), get_old_line(root_node)) M.refresh_view() end -- This function (settings.discussion_tree.jump_to_file) will jump to the file changed in a new tab M.jump_to_file = function(tree) - local file_name, new_line, old_line, error = M.get_note_location(tree) - if error ~= nil then - u.notify(error, vim.log.levels.ERROR) + local node = tree:get_node() + local root_node = M.get_root_node(tree, node) + if root_node == nil then + u.notify("Could not get discussion node", vim.log.levels.ERROR) return end vim.cmd.tabnew() - u.jump_to_file(file_name, (new_line or old_line)) + local line_number = get_new_line(root_node) or get_old_line(root_node) + if line_number == nil then + line_number = 1 + end + local bufnr = vim.fn.bufnr(root_node.filename) + if bufnr ~= -1 then + vim.cmd("buffer " .. bufnr) + vim.api.nvim_win_set_cursor(0, { line_number, 0 }) + return + end + + -- If buffer is not already open, open it + vim.cmd("edit " .. root_node.filename) + vim.api.nvim_win_set_cursor(0, { line_number, 0 }) end -- This function (settings.discussion_tree.toggle_node) expands/collapses the current node and its children @@ -908,21 +956,6 @@ M.add_reply_to_tree = function(tree, note, discussion_id) tree:render() end ----Get note location ----@param tree NuiTree ----@return string, string, string, string? -M.get_note_location = function(tree) - local node = tree:get_node() - if node == nil then - return "", "", "", "Could not get node" - end - local discussion_node = M.get_root_node(tree, node) - if discussion_node == nil then - return "", "", "", "Could not get discussion node" - end - return discussion_node.file_name, discussion_node.new_line, discussion_node.old_line, nil -end - ---@param tree NuiTree M.open_in_browser = function(tree) local current_node = tree:get_node() diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index d52ed1a0..50ea6763 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -158,8 +158,9 @@ M.add_discussions_to_table = function(items, unlinked) local root_id local root_text_nodes = {} local resolvable = false + ---@type GitlabLineRange|nil + local range = nil local resolved = false - local undefined_type = false local root_new_line = nil local root_old_line = nil local root_url @@ -175,16 +176,7 @@ M.add_discussions_to_table = function(items, unlinked) resolvable = note.resolvable resolved = note.resolved root_url = state.INFO.web_url .. "#note_" .. note.id - - -- This appears to be a Gitlab 🐛 where the "type" is returned as an empty string in some cases - -- We link these comments to the old file by default - if - type(note.position) == "table" - and note.position.line_range ~= nil - and note.position.line_range.start.type == "" - then - undefined_type = true - end + range = (type(note.position) == "table" and note.position.line_range or nil) else -- Otherwise insert it as a child node... local note_node = M.build_note(note) table.insert(discussion_children, note_node) @@ -194,6 +186,7 @@ M.add_discussions_to_table = function(items, unlinked) -- Creates the first node in the discussion, and attaches children local body = u.spread(root_text_nodes, discussion_children) local root_node = NuiTree.Node({ + range = range, text = root_text, type = "note", is_root = true, @@ -204,7 +197,6 @@ M.add_discussions_to_table = function(items, unlinked) old_line = root_old_line, resolvable = resolvable, resolved = resolved, - undefined_type = undefined_type, url = root_url, }, body) diff --git a/lua/gitlab/indicators/signs.lua b/lua/gitlab/indicators/signs.lua index 9b94a4e4..8631574a 100644 --- a/lua/gitlab/indicators/signs.lua +++ b/lua/gitlab/indicators/signs.lua @@ -1,4 +1,6 @@ +local u = require("gitlab.utils") local state = require("gitlab.state") +local List = require("gitlab.utils.list") local discussion_sign_name = require("gitlab.indicators.diagnostics").discussion_sign_name local namespace = require("gitlab.indicators.diagnostics").diagnostics_namespace @@ -28,19 +30,27 @@ M.set_signs = function(diagnostics, bufnr) -- Filter diagnostics from the 'gitlab' source and apply custom signs for _, diagnostic in ipairs(diagnostics) do - local sign_id = string.format("%s__%d", namespace, diagnostic.lnum) + ---@type SignTable[] + local existing_signs = + vim.fn.sign_getplaced(vim.api.nvim_get_current_buf(), { group = "gitlab_discussion" })[1].signs + local sign_id = string.format("%s__%d", namespace, diagnostic.lnum) if diagnostic.end_lnum then local linenr = diagnostic.lnum + 1 - while linenr < diagnostic.end_lnum do + while linenr <= diagnostic.end_lnum do linenr = linenr + 1 - vim.fn.sign_place( - sign_id, - discussion_sign_name, - "DiagnosticSign" .. M.severity .. gitlab_range, - bufnr, - { lnum = linenr, priority = 999998 } - ) + local conflicting_comment_sign = List.new(existing_signs):find(function(sign) + return u.ends_with(sign.name, gitlab_comment) and sign.lnum == linenr + end) + if conflicting_comment_sign == nil then + vim.fn.sign_place( + sign_id, + discussion_sign_name, + "DiagnosticSign" .. M.severity .. gitlab_range, + bufnr, + { lnum = linenr, priority = state.settings.discussion_signs.priority } + ) + end end end @@ -49,7 +59,7 @@ M.set_signs = function(diagnostics, bufnr) discussion_sign_name, "DiagnosticSign" .. M.severity .. gitlab_comment, bufnr, - { lnum = diagnostic.lnum + 1, priority = 999999 } + { lnum = diagnostic.lnum + 1, priority = state.settings.discussion_signs.priority } ) -- TODO: Detect whether diagnostic is ranged and set helper signs diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 4e060989..8a074fc6 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -8,7 +8,7 @@ local u = require("gitlab.utils") local state = require("gitlab.state") local git = require("gitlab.git") local hunks = require("gitlab.hunks") -local async_ok, async = pcall(require, "diffview.async") +local async = require("diffview.async") local diffview_lib = require("diffview.lib") local M = { @@ -107,10 +107,6 @@ M.jump = function(file_name, new_line, old_line) u.notify("Could not find Diffview view", vim.log.levels.ERROR) return end - if not async_ok then - u.notify("Could not load Diffview async", vim.log.levels.ERROR) - return - end local files = view.panel:ordered_file_list() local file = List.new(files):find(function(file) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 8d6acb04..41dda027 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -124,6 +124,7 @@ M.settings = { range = " |", }, skip_old_revision_discussion = false, + priority = 100, }, pipeline = { created = "", diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index ec258666..f641162d 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -29,6 +29,14 @@ M.get_last_word = function(sentence, divider) return words[#words] or "" end +---Returns whether a string ends with a substring +---@param str string +---@param ending string +---@return boolean +M.ends_with = function(str, ending) + return ending == "" or str:sub(-#ending) == ending +end + M.filter = function(input_table, value_to_remove) local resultTable = {} for _, v in ipairs(input_table) do @@ -389,26 +397,6 @@ M.difference = function(a, b) return not_included end -M.jump_to_file = function(filename, line_number) - if line_number == nil then - line_number = 1 - end - local bufnr = vim.fn.bufnr(filename) - if bufnr ~= -1 then - M.jump_to_buffer(bufnr, line_number) - return - end - - -- If buffer is not already open, open it - vim.cmd("edit " .. filename) - vim.api.nvim_win_set_cursor(0, { line_number, 0 }) -end - -M.jump_to_buffer = function(bufnr, line_number) - vim.cmd("buffer " .. bufnr) - vim.api.nvim_win_set_cursor(0, { line_number, 0 }) -end - ---Get the popup view_opts ---@param title string The string to appear on top of the popup ---@param settings table User defined popup settings diff --git a/script.lua b/script.lua deleted file mode 100644 index df38c38f..00000000 --- a/script.lua +++ /dev/null @@ -1,12 +0,0 @@ -local combine = function(t1, ...) - local result = t1 - local tables = { ... } - for _, t in ipairs(tables) do - for _, v in ipairs(t) do - table.insert(result, v) - end - end - return result -end - -vim.print(combine({ 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 })) From e6cc34c42a69995e5ada8cc83b8fb9b8973f2bca Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sun, 3 Mar 2024 12:14:45 -0500 Subject: [PATCH 14/97] Fix Typo In Settings Check (#209) Fixes typo in settings check, this is a #PATCH release --- lua/gitlab/indicators/signs.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/indicators/signs.lua b/lua/gitlab/indicators/signs.lua index 8631574a..2b3ac19e 100644 --- a/lua/gitlab/indicators/signs.lua +++ b/lua/gitlab/indicators/signs.lua @@ -24,7 +24,7 @@ local severity_map = { ---@param diagnostics Diagnostic[] ---@param bufnr number M.set_signs = function(diagnostics, bufnr) - if not state.settings.discussion_sign.enabled then + if not state.settings.discussion_signs.enabled then return end From d5ad0ea34df53ba1d20c3c3af9f3d86782acde53 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Mon, 4 Mar 2024 09:17:40 -0500 Subject: [PATCH 15/97] fix: Calculate new line in ranged comment after all hunks correctly (#211) fix: Calculate new line in ranged comment after all hunks correctly --- lua/gitlab/hunks/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/hunks/init.lua b/lua/gitlab/hunks/init.lua index 2df8c044..a14d5be4 100644 --- a/lua/gitlab/hunks/init.lua +++ b/lua/gitlab/hunks/init.lua @@ -274,7 +274,7 @@ M.calculate_matching_line_new = function(old_sha, new_sha, file_path, line_numbe end -- TODO: Possibly handle lines that are out of range in the new files - return line_number + return line_number + net_change + 1 end return M From ca35faa52e5f783aace1c2d271f3ba5690a95b0b Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Mon, 4 Mar 2024 10:48:12 -0500 Subject: [PATCH 16/97] fix: range in new SHA (#212) Fix: Ranged comments in new SHA --- lua/gitlab/hunks/init.lua | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/lua/gitlab/hunks/init.lua b/lua/gitlab/hunks/init.lua index a14d5be4..f90d6f1c 100644 --- a/lua/gitlab/hunks/init.lua +++ b/lua/gitlab/hunks/init.lua @@ -64,20 +64,25 @@ local line_was_added = function(linnr, hunk, all_diff_output) for matching_line_index, line in ipairs(all_diff_output) do local found_hunk = M.parse_possible_hunk_headers(line) if found_hunk ~= nil and vim.deep_equal(found_hunk, hunk) then - -- For added lines, we only want to iterate over the part of the diff that has has new lines, - -- so we skip over the old range. We then keep track of the increment to the original new line index, - -- and iterate until we reach the end of the total range of this hunk. If we arrive at the matching - -- index for the line number, we check to see if the line was added. - local i = 0 - local old_range = (found_hunk.old_range == 0 and found_hunk.old_line ~= 0) and 1 or found_hunk.old_range - for hunk_line_index = matching_line_index + old_range, matching_line_index + old_range + found_hunk.new_range, 1 do - local line_content = all_diff_output[hunk_line_index] - if (found_hunk.new_line + i) == linnr then - if string.match(line_content, "^%+") then - return true - end + -- Parse the lines from the hunk and return only the added lines + local hunk_lines = {} + local i = 1 + local line_content = all_diff_output[matching_line_index + i] + while line_content ~= nil and line_content:sub(1, 2) ~= "@@" do + if string.match(line_content, "^%+") then + table.insert(hunk_lines, line_content) end i = i + 1 + line_content = all_diff_output[matching_line_index + i] + end + + -- We are only looking at added lines in the changed hunk to see if their index + -- matches the index of a line that was added + local starting_index = found_hunk.new_line - 1 -- The "+j" will add one + for j, _ in ipairs(hunk_lines) do + if (starting_index + j) == linnr then + return true + end end end end From 1bf75e28c6453c8515dfdfa9089c4ac620c7d616 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Tue, 26 Mar 2024 05:35:05 -0400 Subject: [PATCH 17/97] fix: address mIssing get_lines function (#224) fix: This MR fixes a missing function from the reviewer that was affecting the ability to add suggestion comments --- lua/gitlab/actions/comment.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index cee4094d..28366931 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -62,7 +62,7 @@ M.create_comment_suggestion = function() local current_line = vim.api.nvim_win_get_cursor(0)[1] local range = end_line - start_line local backticks = "```" - local selected_lines = reviewer.get_lines(start_line, end_line) + local selected_lines = u.get_lines(start_line, end_line) for line in ipairs(selected_lines) do if string.match(line, "^```$") then From 6016624ef3350d56d7d4079da9f365704b660b1a Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Thu, 28 Mar 2024 09:07:15 -0400 Subject: [PATCH 18/97] fix: diagnostic creation for comments on deleted lines (#226) Fixes an issue where the diagnostics were not being created correctly on a deleted line --- lua/gitlab/indicators/diagnostics.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index 13eeba14..7548a9b0 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -49,7 +49,7 @@ end local create_single_line_diagnostic = function(discussion) local first_note = discussion.notes[1] return create_diagnostic({ - lnum = first_note.position.new_line - 1, + lnum = (first_note.position.new_line or first_note.position.old_line) - 1, }, discussion) end From 3e9b1552e4ca8047040cc6e4ca97289d680c6d18 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Fri, 29 Mar 2024 07:45:16 -0400 Subject: [PATCH 19/97] feat: Allow insecure https connection to Gitlab (#229) This MR allows you to ignore bad x509 certificates when connecting to Gitlab (e.g. make an insecure connection). Helpful for self-hosted or enterprise environments where the instance is not up to date. --- README.md | 5 +++++ cmd/client.go | 27 +++++++++++++++++++++++++-- doc/gitlab.nvim.txt | 2 ++ lua/gitlab/server.lua | 23 ++++++++++------------- lua/gitlab/state.lua | 3 +++ 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2f6cf8e7..7972a9b3 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ gitlab_url=https://my-personal-gitlab-instance.com/ The plugin will look for the `.gitlab.nvim` file in the root of the current project by default. However, you may provide a custom path to the configuration file via the `config_path` option. This must be an absolute path to the directory that holds your `.gitlab.nvim` file. +For more settings, please see `:h gitlab.nvim.connecting-to-gitlab` + ## Configuring the Plugin Here is the default setup function. All of these values are optional, and if you call this function with no values the defaults will be used: @@ -108,6 +110,9 @@ require("gitlab").setup({ imply_local = false, -- If true, will attempt to use --imply_local option when calling |:DiffviewOpen| }, }, + connection_settings = { + insecure = false, -- Like curl's --insecure option, ignore bad x509 certificates on connection + }, help = "g?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) popup = { -- The popup for comment creation, editing, and replying exit = "", diff --git a/cmd/client.go b/cmd/client.go index 46a5a7e0..26945918 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -1,6 +1,7 @@ package main import ( + "crypto/tls" "encoding/json" "errors" "fmt" @@ -18,6 +19,10 @@ type DebugSettings struct { GoResponse bool `json:"go_response"` } +type ConnectionOptions struct { + Insecure bool `json:"insecure"` +} + type ProjectInfo struct { ProjectId string MergeId int @@ -40,8 +45,8 @@ type Client struct { /* initGitlabClient parses and validates the project settings and initializes the Gitlab client. */ func initGitlabClient() (error, *Client) { - if len(os.Args) < 6 { - return errors.New("Must provide gitlab url, port, auth token, debug settings, and log path"), nil + if len(os.Args) < 7 { + return errors.New("Must provide gitlab url, port, auth token, debug settings, log path, and connection settings"), nil } gitlabInstance := os.Args[1] @@ -62,6 +67,14 @@ func initGitlabClient() (error, *Client) { return fmt.Errorf("Could not parse debug settings: %w, %s", err, debugSettings), nil } + /* Parse connection options */ + connectionSettings := os.Args[6] + var connectionObject ConnectionOptions + err = json.Unmarshal([]byte(connectionSettings), &connectionObject) + if err != nil { + return fmt.Errorf("Could not parse connection settings: %w, %s", err, connectionSettings), nil + } + var apiCustUrl = fmt.Sprintf(gitlabInstance + "/api/v4") gitlabOptions := []gitlab.ClientOptionFunc{ @@ -76,6 +89,16 @@ func initGitlabClient() (error, *Client) { gitlabOptions = append(gitlabOptions, gitlab.WithResponseLogHook(responseLogger)) } + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: connectionObject.Insecure, + }, + } + + retryClient := retryablehttp.NewClient() + retryClient.HTTPClient.Transport = tr + gitlabOptions = append(gitlabOptions, gitlab.WithHTTPClient(retryClient.HTTPClient)) + client, err := gitlab.NewClient(authToken, gitlabOptions...) if err != nil { diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 5d5ecb40..eb75a161 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -124,6 +124,8 @@ project by default. However, you may provide a custom path to the configuration file via the `config_path` option. This must be an absolute path to the directory that holds your `.gitlab.nvim` file. +The `connection_settings` block in the `state.lua` file will be used to +configure your connection to Gitlab. CONFIGURING THE PLUGIN *gitlab.nvim.configuring-the-plugin* diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua index def61af9..60047116 100644 --- a/lua/gitlab/server.lua +++ b/lua/gitlab/server.lua @@ -13,19 +13,16 @@ M.start = function(callback) local port = state.settings.port or empty_port local parsed_port = nil local callback_called = false - local command = state.settings.bin - .. " " - .. state.settings.gitlab_url - .. " " - .. port - .. " " - .. state.settings.auth_token - .. " " - .. "'" - .. vim.json.encode(state.settings.debug) - .. "'" - .. " " - .. state.settings.log_path + local command = string.format( + "%s %s %s %s '%s' %s '%s'", + state.settings.bin, + state.settings.gitlab_url, + port, + state.settings.auth_token, + vim.json.encode(state.settings.debug), + state.settings.log_path, + vim.json.encode(state.settings.connection_settings) + ) local job_id = vim.fn.jobstart(command, { on_stdout = function(_, data) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 41dda027..1b70711e 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -20,6 +20,9 @@ M.settings = { imply_local = false, }, }, + connection_settings = { + insecure = true, + }, attachment_dir = "", help = "g?", popup = { From 8b4ccaf14449857a15650ea7ef9d9f4f28808265 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Thu, 4 Apr 2024 17:59:29 -0400 Subject: [PATCH 20/97] fix: remove esc keybinding (#240) This MR removes the `` keybinding from the application for popups to avoid accidentally nuking existing changes. Addresses #239. --- README.md | 1 - doc/gitlab.nvim.txt | 1 - lua/gitlab/actions/create_mr.lua | 4 ---- lua/gitlab/state.lua | 12 +++++++----- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7972a9b3..08276162 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,6 @@ require("gitlab").setup({ }, help = "g?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) popup = { -- The popup for comment creation, editing, and replying - exit = "", perform_action = "s", -- Once in normal mode, does action (like saving comment or editing description, etc) perform_linewise_action = "l", -- Once in normal mode, does the linewise action (see logs for this job, etc) width = "40%", diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index eb75a161..6a1dfe8b 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -146,7 +146,6 @@ you call this function with no values the defaults will be used: }, help = "g?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) popup = { -- The popup for comment creation, editing, and replying - exit = "", perform_action = "s", -- Once in normal mode, does action (like saving comment or editing description, etc) perform_linewise_action = "l", -- Once in normal mode, does the linewise action (see logs for this job, etc) width = "40%", diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua index 9e51dc74..932c53bd 100644 --- a/lua/gitlab/actions/create_mr.lua +++ b/lua/gitlab/actions/create_mr.lua @@ -196,10 +196,6 @@ M.add_title = function(mr) mr.title = value end, }) - input:map("n", "", function() - input:unmount() - end, { noremap = true }) - input:mount() end diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 1b70711e..98a95831 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -26,7 +26,6 @@ M.settings = { attachment_dir = "", help = "g?", popup = { - exit = "", perform_action = "s", perform_linewise_action = "l", width = "40%", @@ -270,10 +269,6 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts) if opts == nil then opts = {} end - vim.keymap.set("n", M.settings.popup.exit, function() - exit(popup, opts) - end, { buffer = popup.bufnr, desc = "Exit popup" }) - if action ~= "Help" then -- Don't show help on the help popup vim.keymap.set("n", M.settings.help, function() local help = require("gitlab.actions.help") @@ -304,6 +299,13 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts) linewise_action(text) end, { buffer = popup.bufnr, desc = "Perform linewise action" }) end + + vim.api.nvim_create_autocmd("BufUnload", { + buffer = popup.bufnr, + callback = function() + exit(popup, opts) + end, + }) end -- Dependencies From 61291a34c35b67ff174f37d5ee93dbc5b2777a8c Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Thu, 4 Apr 2024 18:13:02 -0400 Subject: [PATCH 21/97] Removes backup register option (#242) This MR deprecates the "backup" register which was a hot-fix used to deal with bugs upon comment and note creation --- README.md | 1 - doc/gitlab.nvim.txt | 17 ----------------- lua/gitlab/state.lua | 4 ---- 3 files changed, 22 deletions(-) diff --git a/README.md b/README.md index 08276162..a9b200e6 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,6 @@ require("gitlab").setup({ pipeline = nil, reply = nil, squash_message = nil, - backup_register = nil, }, discussion_tree = { -- The discussion tree that holds all comments auto_open = true, -- Automatically open when the reviewer is opened diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 6a1dfe8b..31471756 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -158,7 +158,6 @@ you call this function with no values the defaults will be used: pipeline = nil, reply = nil, squash_message = nil, - backup_register = nil, }, discussion_tree = { -- The discussion tree that holds all comments auto_open = true, -- Automatically open when the reviewer is opened @@ -304,22 +303,6 @@ code block with prefilled code from the visual selection. Just like the summary, all the different kinds of comments are saved via the `settings.popup.perform_action` keybinding. -BACKUP REGISTER *gitlab.nvim.backup-register* - -Sometimes, the action triggered by `settings.popup.perform_action` can fail. -To prevent losing your carefully crafted note/comment/suggestion you can set -`settings.popup.backup_register` to a writable register (see |registers|) to -which the contents of the popup window will be saved just before the action is -performed. A practical setting is `settings.popup.backup_register = "+"` which -saves to the system clipboard (see |quoteplus|). This lets you easily apply -the action on Gitlab in a browser, if it keeps failing in `gitlab.nvim`. - -If you experience such problems, please first read the -|gitlab.nvim.troubleshooting| section. If it does not help, see if there are -any relevant known . If -there are none, please open one and provide any error messages that -`gitlab.nvim` may be showing. - DISCUSSIONS AND NOTES *gitlab.nvim.discussions-and-notes* Gitlab groups threads of comments together into "discussions." diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 98a95831..9f472246 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -39,7 +39,6 @@ M.settings = { help = nil, pipeline = nil, squash_message = nil, - backup_register = nil, }, discussion_tree = { auto_open = true, @@ -278,9 +277,6 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts) if action ~= nil then vim.keymap.set("n", M.settings.popup.perform_action, function() local text = u.get_buffer_text(popup.bufnr) - if M.settings.popup.backup_register ~= nil then - vim.cmd("0,$yank " .. M.settings.popup.backup_register) - end if opts.action_before_close then action(text, popup.bufnr) exit(popup, opts) From 09d5c5571a6a2d72a87ef3b5ff03dc541be497c0 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sat, 6 Apr 2024 12:13:22 -0400 Subject: [PATCH 22/97] fix: update go-gitlab and fix label code (#243) * Fixing labels and updating go gitlab * Fixing up labels.lua --- cmd/label.go | 2 +- go.mod | 6 ++++-- go.sum | 6 ++---- lua/gitlab/actions/labels.lua | 22 +++++----------------- lua/gitlab/actions/summary.lua | 2 +- 5 files changed, 13 insertions(+), 25 deletions(-) diff --git a/cmd/label.go b/cmd/label.go index 9c185f1e..ca7beb90 100644 --- a/cmd/label.go +++ b/cmd/label.go @@ -99,7 +99,7 @@ func (a *api) updateLabels(w http.ResponseWriter, r *http.Request) { return } - var labels = gitlab.Labels(labelUpdateRequest.Labels) + var labels = gitlab.LabelOptions(labelUpdateRequest.Labels) mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{ Labels: &labels, }) diff --git a/go.mod b/go.mod index 57f8fe2a..16a7329b 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,15 @@ module gitlab.com/harrisoncramer/gitlab.nvim go 1.19 -require github.com/xanzy/go-gitlab v0.93.2 +require ( + github.com/hashicorp/go-retryablehttp v0.7.2 + github.com/xanzy/go-gitlab v0.102.0 +) require ( github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-retryablehttp v0.7.2 // indirect golang.org/x/net v0.8.0 // indirect golang.org/x/oauth2 v0.6.0 // indirect golang.org/x/time v0.3.0 // indirect diff --git a/go.sum b/go.sum index 1bd7d247..c00df949 100644 --- a/go.sum +++ b/go.sum @@ -19,10 +19,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/xanzy/go-gitlab v0.83.0 h1:37p0MpTPNbsTMKX/JnmJtY8Ch1sFiJzVF342+RvZEGw= -github.com/xanzy/go-gitlab v0.83.0/go.mod h1:5ryv+MnpZStBH8I/77HuQBsMbBGANtVpLWC15qOjWAw= -github.com/xanzy/go-gitlab v0.93.2 h1:kNNf3BYNYn/Zkig0B89fma12l36VLcYSGu7OnaRlRDg= -github.com/xanzy/go-gitlab v0.93.2/go.mod h1:5ryv+MnpZStBH8I/77HuQBsMbBGANtVpLWC15qOjWAw= +github.com/xanzy/go-gitlab v0.102.0 h1:ExHuJ1OTQ2yt25zBMMj0G96ChBirGYv8U7HyUiYkZ+4= +github.com/xanzy/go-gitlab v0.102.0/go.mod h1:ETg8tcj4OhrB84UEgeE8dSuV/0h4BBL1uOV/qK0vlyI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= diff --git a/lua/gitlab/actions/labels.lua b/lua/gitlab/actions/labels.lua index 3b25016d..da9447f6 100644 --- a/lua/gitlab/actions/labels.lua +++ b/lua/gitlab/actions/labels.lua @@ -15,18 +15,11 @@ M.delete_label = function() end local refresh_label_state = function(labels) - state.INFO.labels = List.new(labels):reduce(function(agg, label) - return agg .. "," .. label - end, "") + state.INFO.labels = labels end local get_current_labels = function() - local label_string = state.INFO.labels - local current_labels = {} - for value in label_string:gmatch("[^,]+") do - table.insert(current_labels, value) - end - return current_labels + return state.INFO.labels end local get_all_labels = function() @@ -45,16 +38,11 @@ M.add_popup = function(type) if not choice then return end - local label_string = state.INFO.labels - local new_labels = {} - for value in label_string:gmatch("[^,]+") do - table.insert(new_labels, value) - end - - table.insert(new_labels, choice) - local body = { labels = new_labels } + table.insert(current_labels, choice) + local body = { labels = current_labels } job.run_job("/mr/" .. type, "PUT", body, function(data) u.notify(data.message, vim.log.levels.INFO) + refresh_label_state(data.labels) end) end) diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index ac10ce7e..dfa42e17 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -134,7 +134,7 @@ M.build_info_lines = function() assignees = { title = "Assignees", content = u.make_readable_list(info.assignees, "name") }, reviewers = { title = "Reviewers", content = u.make_readable_list(info.reviewers, "name") }, branch = { title = "Branch", content = info.source_branch }, - labels = { title = "Labels", content = u.make_comma_separated_readable(info.labels) }, + labels = { title = "Labels", content = table.concat(info.labels, ", ") }, target_branch = { title = "Target Branch", content = state.INFO.target_branch }, pipeline = { title = "Pipeline Status", From 081ad8f56fecd82573ebde0f374f67b6250667af Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sun, 7 Apr 2024 14:35:27 -0400 Subject: [PATCH 23/97] feat: enable pipeline command on main/master (#244) Enables fetching of pipeline information on main and master branches. Allows plugin startup on main/master branches for future extensibility improvements. --- cmd/git.go | 4 - cmd/pipeline.go | 47 ++++++++---- cmd/pipeline_test.go | 69 +++++++++++------ cmd/server.go | 3 +- cmd/test.go | 5 ++ cmd/types.go | 1 + lua/gitlab/actions/pipeline.lua | 132 ++++++++++++++++---------------- lua/gitlab/actions/summary.lua | 7 +- lua/gitlab/init.lua | 8 +- lua/gitlab/state.lua | 1 + 10 files changed, 168 insertions(+), 109 deletions(-) diff --git a/cmd/git.go b/cmd/git.go index 354f69e6..fc282fc8 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -86,10 +86,6 @@ func GetCurrentBranchNameFromNativeGitCmd() (res string, e error) { branchName := strings.TrimSpace(string(output)) - if branchName == "main" || branchName == "master" { - return "", fmt.Errorf("Cannot run on %s branch", branchName) - } - return branchName, nil } diff --git a/cmd/pipeline.go b/cmd/pipeline.go index f76818e2..e14e3c81 100644 --- a/cmd/pipeline.go +++ b/cmd/pipeline.go @@ -12,12 +12,17 @@ import ( type RetriggerPipelineResponse struct { SuccessResponse - Pipeline *gitlab.Pipeline + LatestPipeline *gitlab.Pipeline `json:"latest_pipeline"` } -type GetJobsResponse struct { +type PipelineWithJobs struct { + Jobs []*gitlab.Job `json:"jobs"` + LatestPipeline *gitlab.Pipeline `json:"latest_pipeline"` +} + +type GetPipelineAndJobsResponse struct { SuccessResponse - Jobs []*gitlab.Job + Pipeline PipelineWithJobs `json:"latest_pipeline"` } /* @@ -27,7 +32,7 @@ about a given job in a pipeline, see the jobHandler function func (a *api) pipelineHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - a.GetJobs(w, r) + a.GetPipelineAndJobs(w, r) case http.MethodPost: a.RetriggerPipeline(w, r) default: @@ -37,18 +42,29 @@ func (a *api) pipelineHandler(w http.ResponseWriter, r *http.Request) { } } -func (a *api) GetJobs(w http.ResponseWriter, r *http.Request) { +func (a *api) GetPipelineAndJobs(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - id := strings.TrimPrefix(r.URL.Path, "/pipeline/") - idInt, err := strconv.Atoi(id) + pipeline, res, err := a.client.GetLatestPipeline(a.projectInfo.ProjectId, &gitlab.GetLatestPipelineOptions{ + Ref: &a.gitInfo.BranchName, + }) if err != nil { - handleError(w, err, "Could not convert pipeline ID to integer", http.StatusBadRequest) + handleError(w, err, fmt.Sprintf("Gitlab failed to get latest pipeline for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("Could not get latest pipeline for %s branch", a.gitInfo.BranchName), res.StatusCode) + return + } + + if pipeline == nil { + handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("No pipeline found for %s branch", a.gitInfo.BranchName), res.StatusCode) return } - jobs, res, err := a.client.ListPipelineJobs(a.projectInfo.ProjectId, idInt, &gitlab.ListJobsOptions{}) + jobs, res, err := a.client.ListPipelineJobs(a.projectInfo.ProjectId, pipeline.ID, &gitlab.ListJobsOptions{}) if err != nil { handleError(w, err, "Could not get pipeline jobs", http.StatusInternalServerError) @@ -61,12 +77,15 @@ func (a *api) GetJobs(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusOK) - response := GetJobsResponse{ + response := GetPipelineAndJobsResponse{ SuccessResponse: SuccessResponse{ Status: http.StatusOK, - Message: "Pipeline jobs retrieved", + Message: "Pipeline retrieved", + }, + Pipeline: PipelineWithJobs{ + LatestPipeline: pipeline, + Jobs: jobs, }, - Jobs: jobs, } err = json.NewEncoder(w).Encode(response) @@ -78,7 +97,7 @@ func (a *api) GetJobs(w http.ResponseWriter, r *http.Request) { func (a *api) RetriggerPipeline(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - id := strings.TrimPrefix(r.URL.Path, "/pipeline/") + id := strings.TrimPrefix(r.URL.Path, "/pipeline/trigger/") idInt, err := strconv.Atoi(id) if err != nil { @@ -104,7 +123,7 @@ func (a *api) RetriggerPipeline(w http.ResponseWriter, r *http.Request) { Message: "Pipeline retriggered", Status: http.StatusOK, }, - Pipeline: pipeline, + LatestPipeline: pipeline, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/pipeline_test.go b/cmd/pipeline_test.go index 344d506e..5f940d33 100644 --- a/cmd/pipeline_test.go +++ b/cmd/pipeline_test.go @@ -32,54 +32,79 @@ func retryPipelineBuildNon200(pid interface{}, pipeline int, options ...gitlab.R return nil, makeResponse(http.StatusSeeOther), nil } +func getLatestPipeline200(pid interface{}, opts *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { + return &gitlab.Pipeline{ID: 1}, makeResponse(http.StatusOK), nil +} + func TestPipelineHandler(t *testing.T) { t.Run("Gets all pipeline jobs", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/pipeline/1", nil) - server, _ := createRouterAndApi(fakeClient{listPipelineJobs: listPipelineJobs}) - data := serveRequest(t, server, request, GetJobsResponse{}) - assert(t, data.SuccessResponse.Message, "Pipeline jobs retrieved") + request := makeRequest(t, http.MethodGet, "/pipeline", nil) + server, _ := createRouterAndApi(fakeClient{ + listPipelineJobs: listPipelineJobs, + getLatestPipeline: getLatestPipeline200, + }) + data := serveRequest(t, server, request, GetPipelineAndJobsResponse{}) + assert(t, data.SuccessResponse.Message, "Pipeline retrieved") assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Disallows non-GET, non-POST methods", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/pipeline/1", nil) - server, _ := createRouterAndApi(fakeClient{listPipelineJobs: listPipelineJobs}) + request := makeRequest(t, http.MethodPatch, "/pipeline", nil) + server, _ := createRouterAndApi(fakeClient{ + listPipelineJobs: listPipelineJobs, + getLatestPipeline: getLatestPipeline200, + }) data := serveRequest(t, server, request, ErrorResponse{}) checkBadMethod(t, *data, http.MethodGet, http.MethodPost) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/pipeline/1", nil) - server, _ := createRouterAndApi(fakeClient{listPipelineJobs: listPipelineJobsErr}) + request := makeRequest(t, http.MethodGet, "/pipeline", nil) + server, _ := createRouterAndApi(fakeClient{ + listPipelineJobs: listPipelineJobsErr, + getLatestPipeline: getLatestPipeline200, + }) data := serveRequest(t, server, request, ErrorResponse{}) checkErrorFromGitlab(t, *data, "Could not get pipeline jobs") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/pipeline/1", nil) - server, _ := createRouterAndApi(fakeClient{listPipelineJobs: listPipelineJobsNon200}) + request := makeRequest(t, http.MethodGet, "/pipeline", nil) + server, _ := createRouterAndApi(fakeClient{ + listPipelineJobs: listPipelineJobsNon200, + getLatestPipeline: getLatestPipeline200, + }) data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not get pipeline jobs", "/pipeline") }) - t.Run("Retriggers pipeline", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/pipeline/1", nil) - server, _ := createRouterAndApi(fakeClient{retryPipelineBuild: retryPipelineBuild}) - data := serveRequest(t, server, request, GetJobsResponse{}) - assert(t, data.SuccessResponse.Message, "Pipeline retriggered") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/pipeline/1", nil) - server, _ := createRouterAndApi(fakeClient{retryPipelineBuild: retryPipelineBuildErr}) + request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil) + server, _ := createRouterAndApi(fakeClient{ + retryPipelineBuild: retryPipelineBuildErr, + getLatestPipeline: getLatestPipeline200, + }) data := serveRequest(t, server, request, ErrorResponse{}) checkErrorFromGitlab(t, *data, "Could not retrigger pipeline") }) + t.Run("Retriggers pipeline", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil) + server, _ := createRouterAndApi(fakeClient{ + retryPipelineBuild: retryPipelineBuild, + getLatestPipeline: getLatestPipeline200, + }) + data := serveRequest(t, server, request, GetPipelineAndJobsResponse{}) + assert(t, data.SuccessResponse.Message, "Pipeline retriggered") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + t.Run("Handles non-200s from Gitlab client on retrigger", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/pipeline/1", nil) - server, _ := createRouterAndApi(fakeClient{retryPipelineBuild: retryPipelineBuildNon200}) + request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil) + server, _ := createRouterAndApi(fakeClient{ + retryPipelineBuild: retryPipelineBuildNon200, + getLatestPipeline: getLatestPipeline200, + }) data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not retrigger pipeline", "/pipeline") }) diff --git a/cmd/server.go b/cmd/server.go index 07d7df75..ae2a8a0b 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -132,11 +132,12 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv m.HandleFunc("/mr/revoke", a.withMr(a.revokeHandler)) m.HandleFunc("/mr/awardable/note/", a.withMr(a.emojiNoteHandler)) + m.HandleFunc("/pipeline", a.pipelineHandler) + m.HandleFunc("/pipeline/trigger/", a.pipelineHandler) m.HandleFunc("/users/me", a.meHandler) m.HandleFunc("/attachment", a.attachmentHandler) m.HandleFunc("/create_mr", a.createMr) m.HandleFunc("/job", a.jobHandler) - m.HandleFunc("/pipeline/", a.pipelineHandler) m.HandleFunc("/project/members", a.projectMembersHandler) m.HandleFunc("/shutdown", a.shutdownHandler) diff --git a/cmd/test.go b/cmd/test.go index 33cc5d2e..085009b1 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -35,6 +35,7 @@ type fakeClient struct { listAllProjectMembers func(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) retryPipelineBuild func(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) listPipelineJobs func(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) + getLatestPipeline func(pid interface{}, opt *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) getTraceFile func(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) listLabels func(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) listMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) @@ -120,6 +121,10 @@ func (f fakeClient) ListPipelineJobs(pid interface{}, pipelineID int, opts *gitl return f.listPipelineJobs(pid, pipelineID, opts, options...) } +func (f fakeClient) GetLatestPipeline(pid interface{}, opts *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { + return f.getLatestPipeline(pid, opts, options...) +} + func (f fakeClient) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { return f.getTraceFile(pid, jobID, options...) } diff --git a/cmd/types.go b/cmd/types.go index 70f3d2ee..871112a6 100644 --- a/cmd/types.go +++ b/cmd/types.go @@ -53,6 +53,7 @@ type ClientInterface interface { ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) + GetLatestPipeline(pid interface{}, opt *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) ListLabels(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) ListMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) diff --git a/lua/gitlab/actions/pipeline.lua b/lua/gitlab/actions/pipeline.lua index 11c41d41..6c3d87cb 100644 --- a/lua/gitlab/actions/pipeline.lua +++ b/lua/gitlab/actions/pipeline.lua @@ -7,12 +7,12 @@ local job = require("gitlab.job") local u = require("gitlab.utils") local M = { pipeline_jobs = nil, + latest_pipeline = nil, pipeline_popup = nil, } -local function get_pipeline() - local pipeline = state.INFO.head_pipeline or state.INFO.pipeline - +local function get_latest_pipeline() + local pipeline = state.PIPELINE and state.PIPELINE.latest_pipeline if type(pipeline) ~= "table" or (type(pipeline) == "table" and u.table_size(pipeline) == 0) then u.notify("Pipeline not found", vim.log.levels.WARN) return @@ -20,97 +20,101 @@ local function get_pipeline() return pipeline end +local function get_pipeline_jobs() + M.latest_pipeline = get_latest_pipeline() + if not M.latest_pipeline then + return + end + return u.reverse(type(state.PIPELINE.jobs) == "table" and state.PIPELINE.jobs or {}) +end + M.get_pipeline_status = function() - local pipeline = get_pipeline() - if pipeline == nil then - return nil + M.latest_pipeline = get_latest_pipeline() + if not M.latest_pipeline then + return end - return string.format("%s (%s)", state.settings.pipeline[pipeline.status], pipeline.status) + return string.format("%s (%s)", state.settings.pipeline[M.latest_pipeline.status], M.latest_pipeline.status) end -- The function will render the Pipeline state in a popup M.open = function() - local pipeline = get_pipeline() - if not pipeline then + M.pipeline_jobs = get_pipeline_jobs() + M.latest_pipeline = get_latest_pipeline() + if M.latest_pipeline == nil then return end - job.run_job("/pipeline/" .. pipeline.id, "GET", nil, function(data) - local pipeline_jobs = u.reverse(type(data.Jobs) == "table" and data.Jobs or {}) - M.pipeline_jobs = pipeline_jobs + local width = string.len(M.latest_pipeline.web_url) + 10 + local height = 6 + #M.pipeline_jobs + 3 - local width = string.len(pipeline.web_url) + 10 - local height = 6 + #pipeline_jobs + 3 + local pipeline_popup = + Popup(u.create_popup_state("Loading Pipeline...", state.settings.popup.pipeline, width, height, 60)) + M.pipeline_popup = pipeline_popup + pipeline_popup:mount() - local pipeline_popup = - Popup(u.create_popup_state("Loading Pipeline...", state.settings.popup.pipeline, width, height, 60)) - M.pipeline_popup = pipeline_popup - pipeline_popup:mount() - - local bufnr = vim.api.nvim_get_current_buf() - vim.opt_local.wrap = false + local bufnr = vim.api.nvim_get_current_buf() + vim.opt_local.wrap = false - local lines = {} + local lines = {} - u.switch_can_edit_buf(bufnr, true) - table.insert(lines, "Status: " .. M.get_pipeline_status()) - table.insert(lines, "") - table.insert(lines, string.format("Last Run: %s", u.time_since(pipeline.created_at))) - table.insert(lines, string.format("Url: %s", pipeline.web_url)) - table.insert(lines, string.format("Triggered By: %s", pipeline.source)) + u.switch_can_edit_buf(bufnr, true) + table.insert(lines, "Status: " .. M.get_pipeline_status()) + table.insert(lines, "") + table.insert(lines, string.format("Last Run: %s", u.time_since(M.latest_pipeline.created_at))) + table.insert(lines, string.format("Url: %s", M.latest_pipeline.web_url)) + table.insert(lines, string.format("Triggered By: %s", M.latest_pipeline.source)) - table.insert(lines, "") - table.insert(lines, "Jobs:") + table.insert(lines, "") + table.insert(lines, "Jobs:") - local longest_title = u.get_longest_string(u.map(pipeline_jobs, function(v) - return v.name - end)) + local longest_title = u.get_longest_string(u.map(M.pipeline_jobs, function(v) + return v.name + end)) - local function row_offset(name) - local offset = longest_title - string.len(name) - local res = string.rep(" ", offset + 5) - return res - end + local function row_offset(name) + local offset = longest_title - string.len(name) + local res = string.rep(" ", offset + 5) + return res + end - for _, pipeline_job in ipairs(pipeline_jobs) do - local offset = row_offset(pipeline_job.name) - local row = string.format( - "%s%s %s (%s)", - pipeline_job.name, - offset, - state.settings.pipeline[pipeline_job.status] or "*", - pipeline_job.status or "" - ) - - table.insert(lines, row) - end + for _, pipeline_job in ipairs(M.pipeline_jobs) do + local offset = row_offset(pipeline_job.name) + local row = string.format( + "%s%s %s (%s)", + pipeline_job.name, + offset, + state.settings.pipeline[pipeline_job.status] or "*", + pipeline_job.status or "" + ) + + table.insert(lines, row) + end - vim.schedule(function() - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - M.color_status(pipeline.status, bufnr, lines[1], 1) + vim.schedule(function() + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + M.color_status(M.latest_pipeline.status, bufnr, lines[1], 1) - for i, pipeline_job in ipairs(pipeline_jobs) do - M.color_status(pipeline_job.status, bufnr, lines[7 + i], 7 + i) - end + for i, pipeline_job in ipairs(M.pipeline_jobs) do + M.color_status(pipeline_job.status, bufnr, lines[7 + i], 7 + i) + end - pipeline_popup.border:set_text("top", "Pipeline Status", "center") - state.set_popup_keymaps(pipeline_popup, M.retrigger, M.see_logs) - u.switch_can_edit_buf(bufnr, false) - end) + pipeline_popup.border:set_text("top", "Pipeline Status", "center") + state.set_popup_keymaps(pipeline_popup, M.retrigger, M.see_logs) + u.switch_can_edit_buf(bufnr, false) end) end M.retrigger = function() - local pipeline = get_pipeline() - if not pipeline then + M.latest_pipeline = get_latest_pipeline() + if not M.latest_pipeline then return end - if pipeline.status ~= "failed" then + if M.latest_pipeline.status ~= "failed" then u.notify("Pipeline is not in a failed state!", vim.log.levels.WARN) return end - job.run_job("/pipeline/" .. pipeline.id, "POST", nil, function() + job.run_job("/pipeline/" .. M.latest_pipeline.id, "POST", nil, function() u.notify("Pipeline re-triggered!", vim.log.levels.INFO) end) end diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index dfa42e17..8f1f00c8 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -8,7 +8,6 @@ local u = require("gitlab.utils") local List = require("gitlab.utils.list") local state = require("gitlab.state") local miscellaneous = require("gitlab.actions.miscellaneous") -local pipeline = require("gitlab.actions.pipeline") local M = { layout_visible = false, @@ -139,7 +138,11 @@ M.build_info_lines = function() pipeline = { title = "Pipeline Status", content = function() - return pipeline.get_pipeline_status() + local pipeline = state.INFO.pipeline + if type(pipeline) ~= "table" or (type(pipeline) == "table" and u.table_size(pipeline) == 0) then + return "" + end + return pipeline.status end, }, } diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 4a2ee718..7635e4b1 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -19,6 +19,7 @@ local user = state.dependencies.user local info = state.dependencies.info local labels_dep = state.dependencies.labels local project_members = state.dependencies.project_members +local pipeline_dep = state.dependencies.pipeline local revisions = state.dependencies.revisions return { @@ -34,7 +35,10 @@ return { emoji.init() -- Read in emojis for lookup purposes end, -- Global Actions 🌎 - summary = async.sequence({ u.merge(info, { refresh = true }), labels_dep }, summary.summary), + summary = async.sequence({ + u.merge(info, { refresh = true }), + labels_dep, + }, summary.summary), approve = async.sequence({ info }, approvals.approve), revoke = async.sequence({ info }, approvals.revoke), add_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.add_reviewer), @@ -55,7 +59,7 @@ return { close_review = function() reviewer.close() end, - pipeline = async.sequence({ info }, pipeline.open), + pipeline = async.sequence({ pipeline_dep }, pipeline.open), merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge), -- Discussion Tree Actions 🌴 toggle_discussions = async.sequence({ info, user }, discussions.toggle), diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 9f472246..d5a57192 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -312,6 +312,7 @@ end M.dependencies = { user = { endpoint = "/users/me", key = "user", state = "USER", refresh = false }, info = { endpoint = "/mr/info", key = "info", state = "INFO", refresh = false }, + pipeline = { endpoint = "/pipeline", key = "latest_pipeline", state = "PIPELINE", refresh = true }, labels = { endpoint = "/mr/label", key = "labels", state = "LABELS", refresh = false }, revisions = { endpoint = "/mr/revisions", key = "Revisions", state = "MR_REVISIONS", refresh = false }, project_members = { From b00b16897847e863ad1f65bfd5da69e7b9e62004 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sun, 7 Apr 2024 21:29:07 -0400 Subject: [PATCH 24/97] Expose Gitlab Data via API (#235) This PR adds the ability to access the underlying data in the plugin, such as pipeline information, the information about a merge request, and other data. This data can be integrated into other plugins such as statusline plugins, or other workflows. --- doc/gitlab.nvim.txt | 34 ++++++++++++++++++++++++ lua/gitlab/actions/data.lua | 45 ++++++++++++++++++++++++++++++++ lua/gitlab/actions/pipeline.lua | 46 ++++++++++++++++++++++++++------- lua/gitlab/init.lua | 6 +++-- lua/gitlab/state.lua | 2 +- 5 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 lua/gitlab/actions/data.lua diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 31471756..45c737bf 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -733,4 +733,38 @@ Merges the merge request into the target branch >lua require("gitlab").merge() +gitlab.data({ opts }, cb) *gitlab.nvim.data* + +The data function can be used to integrate `gitlab.nvim` with other plugins and tooling, by fetching +raw data about the current MR, including the summary information (title, description, etc); +reviewers, assignees, pipeline status. +>lua + require("gitlab").data({ + { type = "info", refresh = false }, + { type = "user", refresh = false } }, function (data) + vim.print("The info data is: ", data.info) + vim.print("The user data is: ", data.user) + end) + +If the resources have not yet been fetched from Gitlab, this function will +perform API calls for them. Once the data has been fetched, the callback will +execute and passed the data as an argument. + +Parameters: ~ + • {resources} (table) A list of resource blocks to fetch. + • {resource} (table) A resource to fetch, such as job information, etc. + • {resource.type}: (string) The type of resource, either: "user" + "labels", "project_members", "pipeline," or "revisions"." The types are: + • {user}: Information about the currently authenticated user + • {labels}: The labels available in the current project + • {project_members}: The list of current project members + • {revisions}: Revision information about the MR + • {pipeline}: Information about the current branch's pipeline. Returns + and object with `latest_pipeline` and `jobs` as fields. + • {resource.refresh}: (bool) Whether to re-fetch the data from Gitlab + or use the cached data locally, if available. + • {cb} (function) The callback function that runs after all of the + resources have been fetched. Will be passed a table with the data, + with each resource as a key-value pair, with the key being it's type. + vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/lua/gitlab/actions/data.lua b/lua/gitlab/actions/data.lua new file mode 100644 index 00000000..3f1d98f3 --- /dev/null +++ b/lua/gitlab/actions/data.lua @@ -0,0 +1,45 @@ +local u = require("gitlab.utils") +local async = require("gitlab.async") +local state = require("gitlab.state") +local M = {} + +local user = state.dependencies.user +local info = state.dependencies.info +local labels = state.dependencies.labels +local project_members = state.dependencies.project_members +local revisions = state.dependencies.revisions +local latest_pipeline = state.dependencies.latest_pipeline + +M.data = function(resources, cb) + if type(resources) ~= "table" or type(cb) ~= "function" then + u.notify("The data function must be passed a resources table and a callback function", vim.log.levels.ERROR) + return + end + + local all_resources = { + info = info, + user = user, + labels = labels, + project_members = project_members, + revisions = revisions, + pipeline = latest_pipeline, + } + + local api_calls = {} + for _, resource in ipairs(resources) do + local api_call = all_resources[resource.type] + table.insert(api_calls, u.merge(api_call, { refresh = resource.refresh })) + end + + -- TODO: Build an async "parallel" that fetches the resources + -- in parallel where possible to speed up this API + return async.sequence(api_calls, function() + local data = {} + for k, v in pairs(all_resources) do + data[k] = state[v.state] + end + cb(data) + end)() +end + +return M diff --git a/lua/gitlab/actions/pipeline.lua b/lua/gitlab/actions/pipeline.lua index 6c3d87cb..045fa73e 100644 --- a/lua/gitlab/actions/pipeline.lua +++ b/lua/gitlab/actions/pipeline.lua @@ -28,14 +28,6 @@ local function get_pipeline_jobs() return u.reverse(type(state.PIPELINE.jobs) == "table" and state.PIPELINE.jobs or {}) end -M.get_pipeline_status = function() - M.latest_pipeline = get_latest_pipeline() - if not M.latest_pipeline then - return - end - return string.format("%s (%s)", state.settings.pipeline[M.latest_pipeline.status], M.latest_pipeline.status) -end - -- The function will render the Pipeline state in a popup M.open = function() M.pipeline_jobs = get_pipeline_jobs() @@ -58,7 +50,7 @@ M.open = function() local lines = {} u.switch_can_edit_buf(bufnr, true) - table.insert(lines, "Status: " .. M.get_pipeline_status()) + table.insert(lines, "Status: " .. M.get_pipeline_status(false)) table.insert(lines, "") table.insert(lines, string.format("Last Run: %s", u.time_since(M.latest_pipeline.created_at))) table.insert(lines, string.format("Url: %s", M.latest_pipeline.web_url)) @@ -177,6 +169,42 @@ M.see_logs = function() end) end +---Returns the user-defined symbol representing the status +---of the current pipeline. Takes an optional argument to +---colorize the pipeline icon. +---@param wrap_with_color boolean +---@return string +M.get_pipeline_icon = function(wrap_with_color) + M.latest_pipeline = get_latest_pipeline() + if not M.latest_pipeline then + return "" + end + local symbol = state.settings.pipeline[M.latest_pipeline.status] + if not wrap_with_color then + return symbol + end + if M.latest_pipeline.status == "failed" then + return "%#DiagnosticError#" .. symbol + end + if M.latest_pipeline.status == "success" then + return "%#DiagnosticOk#" .. symbol + end + return "%#DiagnosticWarn#" .. symbol +end + +---Returns the status of the latest pipeline and the symbol +--representing the status of the current pipeline. Takes an optional argument to +---colorize the pipeline icon. +---@param wrap_with_color boolean +---@return string +M.get_pipeline_status = function(wrap_with_color) + M.latest_pipeline = get_latest_pipeline() + if not M.latest_pipeline then + return "" + end + return string.format("%s (%s)", M.get_pipeline_icon(wrap_with_color), M.latest_pipeline.status) +end + M.color_status = function(status, bufnr, status_line, linnr) local ns_id = vim.api.nvim_create_namespace("GitlabNamespace") vim.cmd(string.format("highlight default StatusHighlight guifg=%s", state.settings.pipeline[status])) diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 7635e4b1..1e9add6c 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -8,6 +8,7 @@ local reviewer = require("gitlab.reviewer") local discussions = require("gitlab.actions.discussions") local merge = require("gitlab.actions.merge") local summary = require("gitlab.actions.summary") +local data = require("gitlab.actions.data") local assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers") local comment = require("gitlab.actions.comment") local pipeline = require("gitlab.actions.pipeline") @@ -19,7 +20,7 @@ local user = state.dependencies.user local info = state.dependencies.info local labels_dep = state.dependencies.labels local project_members = state.dependencies.project_members -local pipeline_dep = state.dependencies.pipeline +local latest_pipeline = state.dependencies.latest_pipeline local revisions = state.dependencies.revisions return { @@ -59,7 +60,7 @@ return { close_review = function() reviewer.close() end, - pipeline = async.sequence({ pipeline_dep }, pipeline.open), + pipeline = async.sequence({ latest_pipeline }, pipeline.open), merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge), -- Discussion Tree Actions 🌴 toggle_discussions = async.sequence({ info, user }, discussions.toggle), @@ -69,6 +70,7 @@ return { reply = async.sequence({ info }, discussions.reply), -- Other functions 🤷 state = state, + data = data.data, print_settings = state.print_settings, open_in_browser = async.sequence({ info }, function() if state.INFO.web_url == nil then diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index d5a57192..797efe73 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -312,7 +312,7 @@ end M.dependencies = { user = { endpoint = "/users/me", key = "user", state = "USER", refresh = false }, info = { endpoint = "/mr/info", key = "info", state = "INFO", refresh = false }, - pipeline = { endpoint = "/pipeline", key = "latest_pipeline", state = "PIPELINE", refresh = true }, + latest_pipeline = { endpoint = "/pipeline", key = "latest_pipeline", state = "PIPELINE", refresh = true }, labels = { endpoint = "/mr/label", key = "labels", state = "LABELS", refresh = false }, revisions = { endpoint = "/mr/revisions", key = "Revisions", state = "MR_REVISIONS", refresh = false }, project_members = { From db90a36896c124af760a64bc56d2ed1e668327bb Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sun, 7 Apr 2024 21:42:01 -0400 Subject: [PATCH 25/97] fix: line code fix (#245) fix: correcting some problem with review mode --- lua/gitlab/actions/discussions/init.lua | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index eb98661d..dc3a7a4f 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -392,20 +392,13 @@ end ---@param node any ---@return number|nil local function get_new_line(node) - if node.new_line == nil then - return nil - end - ---@type GitlabLineRange|nil local range = node.range if range == nil then - if node.new_line == nil then - return nil - end return node.new_line end - local start_new_line, _ = common.parse_line_code(range.start.line_code) + local _, start_new_line = common.parse_line_code(range.start.line_code) return start_new_line end @@ -414,17 +407,13 @@ end ---@param node any ---@return number|nil local function get_old_line(node) - if node.old_line == nil then - return nil - end - ---@type GitlabLineRange|nil local range = node.range if range == nil then return node.old_line end - local _, start_old_line = common.parse_line_code(range.start.line_code) + local start_old_line, _ = common.parse_line_code(range.start.line_code) return start_old_line end @@ -453,7 +442,7 @@ M.jump_to_file = function(tree) if line_number == nil then line_number = 1 end - local bufnr = vim.fn.bufnr(root_node.filename) + local bufnr = vim.fn.bufnr(root_node.file_name) if bufnr ~= -1 then vim.cmd("buffer " .. bufnr) vim.api.nvim_win_set_cursor(0, { line_number, 0 }) @@ -461,7 +450,7 @@ M.jump_to_file = function(tree) end -- If buffer is not already open, open it - vim.cmd("edit " .. root_node.filename) + vim.cmd("edit " .. root_node.file_name) vim.api.nvim_win_set_cursor(0, { line_number, 0 }) end From bce50d01e183482080750b716d69ac296a4a219c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Mon, 8 Apr 2024 14:35:46 +0200 Subject: [PATCH 26/97] feat: add squash and remove source branch (#230) This MR adds the ability to specify some settings when creating a merge request. Users may now specify that the original branch should be deleted after merge, and that the commits should be squashed into a single commit. It also updates the settings consistent with this change, and refactors numerous stylistic updates in the application. --- README.md | 12 +- cmd/create_mr.go | 12 +- cmd/create_mr_test.go | 10 + doc/gitlab.nvim.txt | 316 +++++++++++++++++-------------- lua/gitlab/actions/create_mr.lua | 144 +++++++------- lua/gitlab/actions/merge.lua | 2 +- lua/gitlab/actions/summary.lua | 66 ++----- lua/gitlab/state.lua | 8 +- lua/gitlab/utils/init.lua | 56 ++++++ 9 files changed, 342 insertions(+), 284 deletions(-) diff --git a/README.md b/README.md index a9b200e6..52da6802 100644 --- a/README.md +++ b/README.md @@ -169,8 +169,12 @@ require("gitlab").setup({ "conflicts", "assignees", "reviewers", - "branch", "pipeline", + "branch", + "target_branch", + "delete_branch", + "squash", + "labels", }, }, discussion_signs = { @@ -195,13 +199,11 @@ require("gitlab").setup({ success = "✓", failed = "", }, - merge = { -- The default behaviors when merging an MR, see "Merging an MR" - squash = false, - delete_branch = false, - }, create_mr = { target = nil, -- Default branch to target when creating an MR template_file = nil, -- Default MR template in .gitlab/merge_request_templates + delete_branch = false, -- Whether the source branch will be marked for deletion + squash = false, -- Whether the commits will be marked for squashing title_input = { -- Default settings for MR title input window width = 40, border = "rounded", diff --git a/cmd/create_mr.go b/cmd/create_mr.go index 79b724aa..edce8ce4 100644 --- a/cmd/create_mr.go +++ b/cmd/create_mr.go @@ -14,6 +14,8 @@ type CreateMrRequest struct { Title string `json:"title"` Description string `json:"description"` TargetBranch string `json:"target_branch"` + DeleteBranch bool `json:"delete_branch"` + Squash bool `json:"squash"` } /* createMr creates a merge request */ @@ -49,10 +51,12 @@ func (a *api) createMr(w http.ResponseWriter, r *http.Request) { } opts := gitlab.CreateMergeRequestOptions{ - Title: &createMrRequest.Title, - Description: &createMrRequest.Description, - TargetBranch: &createMrRequest.TargetBranch, - SourceBranch: &a.gitInfo.BranchName, + Title: &createMrRequest.Title, + Description: &createMrRequest.Description, + TargetBranch: &createMrRequest.TargetBranch, + SourceBranch: &a.gitInfo.BranchName, + RemoveSourceBranch: &createMrRequest.DeleteBranch, + Squash: &createMrRequest.Squash, } _, res, err := a.client.CreateMergeRequest(a.projectInfo.ProjectId, &opts) diff --git a/cmd/create_mr_test.go b/cmd/create_mr_test.go index 77365cde..c097269b 100644 --- a/cmd/create_mr_test.go +++ b/cmd/create_mr_test.go @@ -27,6 +27,8 @@ func TestCreateMr(t *testing.T) { Title: "Some title", Description: "Some description", TargetBranch: "main", + DeleteBranch: false, + Squash: false, } request := makeRequest(t, http.MethodPost, "/create_mr", body) @@ -48,6 +50,8 @@ func TestCreateMr(t *testing.T) { Title: "Some title", Description: "Some description", TargetBranch: "main", + DeleteBranch: false, + Squash: false, } request := makeRequest(t, http.MethodPost, "/create_mr", body) server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFnErr}) @@ -60,6 +64,8 @@ func TestCreateMr(t *testing.T) { Title: "Some title", Description: "Some description", TargetBranch: "main", + DeleteBranch: false, + Squash: false, } request := makeRequest(t, http.MethodPost, "/create_mr", body) server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFnNon200}) @@ -72,6 +78,8 @@ func TestCreateMr(t *testing.T) { Title: "", Description: "Some description", TargetBranch: "main", + DeleteBranch: false, + Squash: false, } request := makeRequest(t, http.MethodPost, "/create_mr", body) server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFn}) @@ -86,6 +94,8 @@ func TestCreateMr(t *testing.T) { Title: "Some title", Description: "Some description", TargetBranch: "", + DeleteBranch: false, + Squash: false, } request := makeRequest(t, http.MethodPost, "/create_mr", body) server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFn}) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 45c737bf..a845f933 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -14,7 +14,7 @@ Table of Contents *gitlab.nvim.table-of-contents* - Discussions and Notes |gitlab.nvim.discussions-and-notes| - Labels |gitlab.nvim.labels| - Signs and diagnostics |gitlab.nvim.signs-and-diagnostics| - - Emojis gitlab.nvim.emojis + - Emojis |gitlab.nvim.emojis| - Uploading Files |gitlab.nvim.uploading-files| - MR Approvals |gitlab.nvim.mr-approvals| - Merging an MR |gitlab.nvim.merging-an-mr| @@ -26,6 +26,7 @@ Table of Contents *gitlab.nvim.table-of-contents* - Troubleshooting |gitlab.nvim.troubleshooting| - Api |gitlab.nvim.api| + OVERVIEW *gitlab.nvim.overview* This Neovim plugin is designed to make it easy to review Gitlab MRs from within @@ -39,6 +40,7 @@ the editor. This means you can do things like: - View and manage pipeline Jobs - Upload files, jump to the browser, and a lot more! + REQUIREMENTS *gitlab.nvim.requirements* - Go >= v1.19 @@ -56,7 +58,6 @@ QUICK START *gitlab.nvim.quick-start* INSTALLATION *gitlab.nvim.installation* With Lazy: - >lua return { "harrisoncramer/gitlab.nvim", @@ -74,9 +75,7 @@ With Lazy: end, } < - And with Packer: - >lua use { 'harrisoncramer/gitlab.nvim', @@ -94,7 +93,6 @@ And with Packer: } < - CONNECTING TO GITLAB *gitlab.nvim.connecting-to-gitlab* This plugin requires an auth token to connect to Gitlab. The token can be set @@ -106,19 +104,15 @@ Optionally provide a GITLAB_URL environment variable (or gitlab_url value in the `.gitlab.nvim` file) to connect to a self-hosted Gitlab instance. This is optional, use ONLY for self-hosted instances. Here’s what they’d look like as environment variables: - >bash export GITLAB_TOKEN="your_gitlab_token" export GITLAB_URL="https://my-personal-gitlab-instance.com/" < - And as a `.gitlab.nvim` file: - > auth_token=your_gitlab_token gitlab_url=https://my-personal-gitlab-instance.com/ < - The plugin will look for the `.gitlab.nvim` file in the root of the current project by default. However, you may provide a custom path to the configuration file via the `config_path` option. This must be an absolute path to the @@ -127,11 +121,11 @@ directory that holds your `.gitlab.nvim` file. The `connection_settings` block in the `state.lua` file will be used to configure your connection to Gitlab. + CONFIGURING THE PLUGIN *gitlab.nvim.configuring-the-plugin* Here is the default setup function. All of these values are optional, and if you call this function with no values the defaults will be used: - >lua require("gitlab").setup({ port = nil, -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically @@ -198,22 +192,24 @@ you call this function with no values the defaults will be used: "conflicts", "assignees", "reviewers", + "pipeline", "branch", "target_branch", - "pipeline", + "delete_branch", + "squash", "labels", }, }, discussion_signs = { - enabled = true, -- Show diagnostics for gitlab comments in the reviewer - skip_resolved_discussion = false, -- Show diagnostics for resolved discussions - severity = vim.diagnostic.severity.INFO, -- ERROR, WARN, INFO, or HINT - virtual_text = false, -- Whether to show the comment text inline as floating virtual text - priority = 100, -- Higher will override LSP warnings, etc - icons = { - comment = "→|", - range = " |", - }, + enabled = true, -- Show diagnostics for gitlab comments in the reviewer + skip_resolved_discussion = false, -- Show diagnostics for resolved discussions + severity = vim.diagnostic.severity.INFO, -- ERROR, WARN, INFO, or HINT + virtual_text = false, -- Whether to show the comment text inline as floating virtual text + priority = 100, -- Higher will override LSP warnings, etc + icons = { + comment = "→|", + range = " |", + }, }, pipeline = { created = "", @@ -226,13 +222,11 @@ you call this function with no values the defaults will be used: success = "✓", failed = "", }, - merge = { -- The default behaviors when merging an MR, see "Merging an MR" - squash = false, - delete_branch = false, - }, create_mr = { target = nil, -- Default branch to target when creating an MR template_file = nil, -- Default MR template in .gitlab/merge_request_templates + delete_branch = false, -- Whether the source branch will be marked for deletion + squash = false, -- Whether the commits will be marked for squashing title_input = { -- Default settings for MR title input window width = 40, border = "rounded", @@ -251,27 +245,22 @@ you call this function with no values the defaults will be used: }) < - USAGE *gitlab.nvim.usage* -First, check out the branch that you want to review locally. - +First, check out the branch that you want to review locally: > git checkout feature-branch < - Then open Neovim. To begin, try running the `summary` command or the `review` command. THE SUMMARY VIEW *gitlab.nvim.the-summary-view* -The `summary` action will open the MR title and description. - +The `summary` action will open the MR title and description: >lua require("gitlab").summary() < - After editing the description or title, you may save your changes via the `settings.popup.perform_action` keybinding. @@ -286,15 +275,13 @@ REVIEWING AN MR *gitlab.nvim.reviewing-an-mr* The `review` action will open a diff of the changes. You can leave comments using the `create_comment` action. In visual mode, add multiline comments with the `create_multiline_comment` command, and add suggested changes with the -`create_comment_suggestion` command. - +`create_comment_suggestion` command: >lua require("gitlab").review() require("gitlab").create_comment() require("gitlab").create_multiline_comment() require("gitlab").create_comment_suggestion() < - For suggesting changes you can use `create_comment_suggestion` in visual mode which works similar to `create_multiline_comment` but prefills the comment window with Gitlab’s suggest changes @@ -303,13 +290,13 @@ code block with prefilled code from the visual selection. Just like the summary, all the different kinds of comments are saved via the `settings.popup.perform_action` keybinding. + DISCUSSIONS AND NOTES *gitlab.nvim.discussions-and-notes* Gitlab groups threads of comments together into "discussions." To display all discussions for the current MR, use the `toggle_discussions` -action, which will show the discussions in a split window. - +action, which will show the discussions in a split window: >lua require("gitlab").toggle_discussions() < @@ -324,7 +311,6 @@ Within the discussion tree, you can delete/edit/reply to comments with the If you’d like to create a note in an MR (like a comment, but not linked to a specific line) use the `create_note` action. The same keybindings for delete/edit/reply are available on the note tree. - >lua require("gitlab").create_note() < @@ -339,7 +325,7 @@ $XDG_CONFIG_HOME/nvim/after/ftplugin/gitlab.lua with the following contents: vim.o.breakindent = true < -LABELS *gitlab.nvim.labels* +LABELS *gitlab.nvim.labels* You can add or remove labels from the current MR. >lua @@ -349,6 +335,7 @@ You can add or remove labels from the current MR. These labels will be visible in the summary panel, as long as you provide the "fields" string in your setup function under the `setting.info.fields` block. + SIGNS AND DIAGNOSTICS *gitlab.nvim.signs-and-diagnostics* By default when reviewing files, you will see diagnostics for comments that @@ -376,7 +363,8 @@ You may skip resolved discussions by toggling `discussion_signs.skip_resolved_di in your setup function to true. By default, discussions from this plugin are shown at the INFO severity level (see :h vim.diagnostic.severity). -EMOJIS *gitlab.nvim.emojis* + +EMOJIS *gitlab.nvim.emojis* You can add or remove emojis from a note or comment in the discussion tree. @@ -401,42 +389,34 @@ Use the `settings.popup.perform_action` to send the changes to Gitlab. MR APPROVALS *gitlab.nvim.mr-approvals* You can approve or revoke approval for an MR with the `approve` and `revoke` -actions respectively. - +actions respectively: >lua require("gitlab").approve() require("gitlab").revoke() < - MERGING AN MR *gitlab.nvim.merging-an-mr* The `merge` action will merge an MR. The MR must be in a "mergeable" state for this command to work. - >lua require("gitlab").merge() require("gitlab").merge({ squash = false, delete_branch = false }) < - -You can configure default behaviors via the setup function, values passed into -the `merge` action will override the defaults. - -If you enable `squash` you will be prompted for a squash message. To use the -default message, leave the popup empty. Use the `settings.popup.perform_action` -to merge the MR with your message. +See |gitlab.nvim.merge| for more help on this function. CREATING AN MR *gitlab.nvim.creating-an-mr* To create an MR for the current branch, make sure you have the branch checked -out. Then, use the `create_mr` action. See `:h gitlab.nvim.create_mr` for more +out. Then, use the `create_mr` action. See |gitlab.nvim.create_mr| for more help on this function >lua require("gitlab").create_mr() require("gitlab").create_mr({ target = "main" }) - require("gitlab").create_mr({ target = "main", template_file = "my-template.md" }) + require("gitlab").create_mr({ template_file = "my-template.md", title = "Fix bug XYZ" }) < + PIPELINES *gitlab.nvim.pipelines* You can view the status of the pipeline for the current MR with the `pipeline` @@ -449,22 +429,20 @@ To re-trigger failed jobs in the pipeline manually, use the new Neovim buffer, use your `settings.popup.perform_linewise_action` keybinding. + REVIEWERS AND ASSIGNEES *gitlab.nvim.reviewers-and-assignees* The `add_reviewer` and `delete_reviewer` actions, as well as the `add_assignee` and `delete_assignee` functions, will let you choose from a list of users who are available in the current project: - >lua require("gitlab").add_reviewer() require("gitlab").delete_reviewer() require("gitlab").add_assignee() require("gitlab").delete_assignee() < - These actions use Neovim’s built in picker, which is much nicer if you -install dressing. If you use Dressing, please enable it: - +install `dressing.nvim`. If you use Dressing, please enable it: >lua require("dressing").setup({ input = { @@ -473,21 +451,18 @@ install dressing. If you use Dressing, please enable it: }) < - RESTARTING OR SHUTTING DOWN *gitlab.nvim.restarting-or-shutting-down* The `gitlab.nvim` server will shut down automatically when you exit Neovim. However, if you would like to manage this yourself (for instance, restart the server when you check out a new branch) you may do so via the `restart` -command, or `shutdown` commands, which both accept callbacks. - +command, or `shutdown` commands, which both accept callbacks: >lua require("gitlab.server").restart() + require("gitlab.server").shutdown() < - For instance you could set up the following keybinding to close and reopen the reviewer when checking out a new branch: - >lua local gitlab = require("gitlab") vim.keymap.set("n", "glB", function () @@ -498,14 +473,12 @@ reviewer when checking out a new branch: end) < - KEYBINDINGS *gitlab.nvim.keybindings* The plugin does not set up any keybindings outside of the special buffers it creates, you need to set them up yourself. Here’s what I’m using (note that the `` prefix is not necessary, as `gl` does not have a special meaning in normal mode): - >lua local gitlab = require("gitlab") local gitlab_server = require("gitlab.server") @@ -529,35 +502,35 @@ in normal mode): vim.keymap.set("n", "glM", gitlab.merge) < - TROUBLESHOOTING *gitlab.nvim.troubleshooting* -**To check that the current settings of the plugin are configured correctly, -please run: :lua require("gitlab").print_settings()** - +To check that the current settings of the plugin are configured correctly,~ +please run:~ +>lua + require("gitlab").print_settings() +< This plugin uses a Go server to reach out to Gitlab. It’s possible that something is going wrong when starting that server or connecting with Gitlab. The Go server runs outside of Neovim, and can be interacted with directly in order to troubleshoot. To start the server, check out your feature branch and run these commands: - >lua - :lua require("gitlab.server").build(true) - :lua require("gitlab.server").start(function() print("Server started") end) + require("gitlab.server").build(true) + require("gitlab.server").start(function() print("Server started") end) < The easiest way to debug what’s going wrong is to turn on the `debug` options in your setup function. This will allow you to see requests leaving the Go server, and the responses coming back from Gitlab. Once the server is running, you can also interact with the Go server like any other process: - > curl --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" localhost:21036/mr/info < ============================================================================== -Lua API *gitlab.nvim.api* +LUA API *gitlab.nvim.api* -setup() *gitlab.nvim.setup* + *gitlab.nvim.setup* +setup() ~ Call this first to initialize the plugin. With no arguments, it will use the default arguments outlined under "Configuring the Plugin". @@ -567,15 +540,17 @@ default arguments outlined under "Configuring the Plugin". require("gitlab").setup({ port = 8392 }) require("gitlab").setup({ discussion_tree = { blacklist = { "some_bot"} } }) - -review() *gitlab.nvim.review* +< + *gitlab.nvim.review* +review() ~ Opens the reviewer pane. Can be used from anywhere within Neovim after the plugin is loaded. If run twice, will open a second reviewer pane. >lua require("gitlab").review() - -summary() *gitlab.nvim.summary* +< + *gitlab.nvim.summary* +summary() ~ Opens the summary window with information about the current MR, such as the description, the author, and the title. Can be configured via the `info` field @@ -586,31 +561,35 @@ in the setup call. The summary can be edited. Once you have made changes, send them to Gitlab via the `settings.popup.perform_action` keybinding. -approve() *gitlab.nvim.approve* + *gitlab.nvim.approve* +approve() ~ Approves the current MR. Will error if the current user does not have permission. >lua require("gitlab").approve() - -gitlab.revoke() *gitlab.nvim.revoke* +< + *gitlab.nvim.revoke* +gitlab.revoke() ~ Revokes approval for the current MR. Will error if the current user does not have permission or has not previously approved the MR. >lua require("gitlab").approve() - -gitlab.create_comment() *gitlab.nvim.create_comment* +< + *gitlab.nvim.create_comment* +gitlab.create_comment() ~ Opens a popup to create a comment on the current line. Must be called when focused on the reviewer pane (see the gitlab.nvim.review command), otherwise it will error. >lua require("gitlab").comment() -After the comment is typed, submit it to Gitlab via the |settings.popup.perform_action| -keybinding, by default `l` +After the comment is typed, submit it to Gitlab via the `settings.popup.perform_action` +keybinding, by default `l`. -create_multiline_comment() *gitlab.nvim.create_multiline_comment* + *gitlab.nvim.create_multiline_comment* +create_multiline_comment() ~ Opens a popup to create a multi-line comment. May only be called in visual mode, and will use the currently selected lines. @@ -620,7 +599,8 @@ mode, and will use the currently selected lines. After the comment is typed, submit it to Gitlab via the |settings.popup.perform_linewise_action| keybinding, by default `l`. -create_comment_suggestion() *gitlab.nvim.create_comment_suggestion* + *gitlab.nvim.create_comment_suggestion* +create_comment_suggestion() ~ Opens a popup to create a comment suggestion (aka a comment that makes a committable change suggestion to the currently selected lines). @@ -630,23 +610,38 @@ change suggestion to the currently selected lines). After the comment is typed, submit it to Gitlab via the |settings.popup.perform_linewise_action| keybinding, by default |l| -create_mr({opts}) *gitlab.nvim.create_mr* + *gitlab.nvim.create_mr* +create_mr({opts}) ~ Starts the process of creating an MR for the currently checked out branch. >lua - require("gitlab").create_mr() - require("gitlab").create_mr({ target = "main" }) - require("gitlab").create_mr({ target = "main", template_file = "my-template.md" }) - -Parameters: - • {opts} Lua table that can be used to skip certain steps in the MR - creation process. If `target` is provided, you will not be prompted for one. - If a template_file is provided, it will be used automatically. Must be - located in `.gitlab/merge_request_templates` - - These values can also be configured via the `gitlab.setup` function - -gitlab.move_to_discussion_tree_from_diagnostic() *gitlab.nvim.move_to_discussion_tree_from_diagnostic* + require("gitlab").create_mr() + require("gitlab").create_mr({ target = "main" }) + require("gitlab").create_mr({ target = "main", template_file = "my-template.md" }) + require("gitlab").create_mr({ title = "Fix bug XYZ", description = "Closes #123" }) +< + Parameters: ~ + • {opts}: (table|nil) Keyword arguments that can be used to skip + certain steps in the MR creation process. `target` and + `template_file` can also be configured via the `gitlab.setup()` + function in the `settings.create_mr` field. If any field is missing, + you will be prompted to select a value interactively: + • {target}: (string) Name of the target branch. + • {template_file}: (string) Name of file (relative to + `.gitlab/merge_request_templates`) that will be used for the MR + description. See also + . + • {description}: (string) String used for the MR description. + Takes precedence over the {template_file}, if both options are + used. + • {title}: (string) MR title. + • {delete_branch}: (bool) If true, the source branch will be + marked for deletion. + • {squash}: (bool) If true, the commits will be marked for + squashing. + + *gitlab.nvim.move_to_discussion_tree_from_diagnostic* +gitlab.move_to_discussion_tree_from_diagnostic() ~ When hovering over a diagnostic in the reviewer pane, jumps to the relevant node in the discussion tree. @@ -655,17 +650,19 @@ node in the discussion tree. If there are no diagnostics for the current line, shows a warning message. -gitlab.create_note() *gitlab.nvim.create_note* + *gitlab.nvim.create_note* +gitlab.create_note() ~ Opens a popup to create a note. Notes are like comments except they are not tied to specific changes in an MR. >lua require("gitlab").create_note() -After the comment is typed, submit it to Gitlab via the |settings.popup.perform_action| +After the comment is typed, submit it to Gitlab via the `settings.popup.perform_action` keybinding, by default |l| -gitlab.toggle_discussions() *gitlab.nvim.toggle_discussions* + *gitlab.nvim.toggle_discussions* +gitlab.toggle_discussions() ~ Toggles visibility of the discussion tree. >lua @@ -675,69 +672,95 @@ Once the discussion tree is open, a number of different keybindings are availabl for interacting with different discussions. Please see the `settings.discussion_tree` section of the setup call for more information about different keybindings. -gitlab.add_assignee() *gitlab.nvim.add_assignee* + *gitlab.nvim.add_assignee* +gitlab.add_assignee() ~ Opens up a select menu for choosing an assignee for the current merge request. >lua require("gitlab").add_assignee() - -gitlab.delete_assignee() *gitlab.nvim.delete_assignee* +< + *gitlab.nvim.delete_assignee* +gitlab.delete_assignee() ~ Opens up a select menu for removing an existing assignee for the current merge request. >lua require("gitlab").delete_assignee() - -gitlab.add_reviewer() *gitlab.nvim.add_reviewer* +< + *gitlab.nvim.add_reviewer* +gitlab.add_reviewer() ~ Opens up a select menu for adding a reviewer for the current merge request. >lua require("gitlab").add_reviewer() - -gitlab.add_label() *gitlab.nvim.add_label* +< + *gitlab.nvim.add_label* +gitlab.add_label() ~ Opens up a select menu for adding a label to the current merge request. >lua require("gitlab").add_label() - -gitlab.delete_label() *gitlab.nvim.delete_label* +< + *gitlab.nvim.delete_label* +gitlab.delete_label() ~ Opens up a select menu for removing an existing label from the current merge request. >lua require("gitlab").delete_label() - -gitlab.delete_reviewer() *gitlab.nvim.delete_reviewer* +< + *gitlab.nvim.delete_reviewer* +gitlab.delete_reviewer() ~ Opens up a select menu for removing an existing reviewer for the current merge request. >lua require("gitlab").delete_reviewer() - -gitlab.pipeline() *gitlab.nvim.pipeline* +< + *gitlab.nvim.pipeline* +gitlab.pipeline() ~ Opens up a popup with information about the pipeline for the current merge request. >lua require("gitlab").pipeline() - +< To re-trigger failed jobs in the pipeline manually, use the `settings.popup.perform_action` keybinding. To open the log trace of a job in a new Neovim buffer, use your `settings.popup.perform_linewise_action` keybinding. -gitlab.open_in_browser() *gitlab.nvim.open_in_browser* + *gitlab.nvim.open_in_browser* +gitlab.open_in_browser() ~ Opens the current MR in your default web browser. >lua require("gitlab").open_in_browser() +< + *gitlab.nvim.merge* +gitlab.merge({opts}) ~ -gitlab.merge() *gitlab.nvim.merge* - -Merges the merge request into the target branch +Merges the merge request into the target branch. When run without any +arguments, the `merge` action will respect the "Squash commits" and "Delete +source branch" settings set by `require("gitlab").create_mr()` or set in +Gitlab online. You can see the current settings in the Summary view, see +|gitlab.nvim.the-summary-view|. >lua require("gitlab").merge() - -gitlab.data({ opts }, cb) *gitlab.nvim.data* - -The data function can be used to integrate `gitlab.nvim` with other plugins and tooling, by fetching -raw data about the current MR, including the summary information (title, description, etc); -reviewers, assignees, pipeline status. + require("gitlab").merge({ squash = false, delete_branch = true }) +< + Parameters: ~ + • {opts}: (table|nil) Keyword arguments that can be used to override + default behavior. + • {delete_branch}: (bool) If true, the source branch will be + deleted. + • {squash}: (bool) If true, the commits will be squashed. If + you enable {squash} you will be prompted for a squash + message. To use the default message, leave the popup empty. + Use the `settings.popup.perform_action` to merge the MR with + your message. + + *gitlab.nvim.data* +gitlab.data({resources}, {cb}) ~ + +The data function can be used to integrate `gitlab.nvim` with other plugins +and tooling, by fetching raw data about the current MR, including the summary +information (title, description, etc); reviewers, assignees, pipeline status. >lua require("gitlab").data({ { type = "info", refresh = false }, @@ -750,21 +773,24 @@ If the resources have not yet been fetched from Gitlab, this function will perform API calls for them. Once the data has been fetched, the callback will execute and passed the data as an argument. -Parameters: ~ - • {resources} (table) A list of resource blocks to fetch. - • {resource} (table) A resource to fetch, such as job information, etc. - • {resource.type}: (string) The type of resource, either: "user" - "labels", "project_members", "pipeline," or "revisions"." The types are: - • {user}: Information about the currently authenticated user - • {labels}: The labels available in the current project - • {project_members}: The list of current project members - • {revisions}: Revision information about the MR - • {pipeline}: Information about the current branch's pipeline. Returns - and object with `latest_pipeline` and `jobs` as fields. - • {resource.refresh}: (bool) Whether to re-fetch the data from Gitlab - or use the cached data locally, if available. - • {cb} (function) The callback function that runs after all of the - resources have been fetched. Will be passed a table with the data, - with each resource as a key-value pair, with the key being it's type. - -vim:tw=78:ts=8:noet:ft=help:norl: + Parameters: ~ + • {resources}: (table) A list of {resource} blocks to fetch. + • {resource}: (table) A resource to fetch, such as job + information, etc. The resource is defined by its {type}: + • {type}: (string) The type of resource, either: + • "user": Information about the currently authenticated + user. + • "labels": The labels available in the current project. + • "project_members": The list of current project members. + • "revisions": Revision information about the MR. + • "pipeline": Information about the current branch's + pipeline. Returns and object with `latest_pipeline` and + `jobs` as fields. + • {refresh}: (bool) Whether to re-fetch the data from Gitlab + or use the cached data locally, if available. + • {cb}: (function) The callback function that runs after all of the + resources have been fetched. Will be passed a table with the data, + with each resource as a key-value pair, with the key being it's + type. + +vim:tw=78:ts=4:sw=4:expandtab:ft=help:norl: diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua index 932c53bd..7c7ea2b4 100644 --- a/lua/gitlab/actions/create_mr.lua +++ b/lua/gitlab/actions/create_mr.lua @@ -13,10 +13,9 @@ local miscellaneous = require("gitlab.actions.miscellaneous") ---@field target? string ---@field title? string ---@field description? string - ----@class Args ----@field target? string ---@field template_file? string +---@field delete_branch boolean? +---@field squash boolean? local M = { started = false, @@ -39,49 +38,9 @@ M.reset_state = function() M.mr.description = "" end -local title_popup_settings = { - buf_options = { - filetype = "markdown", - }, - focusable = true, - border = { - style = "rounded", - text = { - top = "Title", - }, - }, -} - -local target_popup_settings = { - buf_options = { - filetype = "markdown", - }, - focusable = true, - border = { - style = "rounded", - text = { - top = "Target branch", - }, - }, -} - -local description_popup_settings = { - buf_options = { - filetype = "markdown", - }, - enter = true, - focusable = true, - border = { - style = "rounded", - text = { - top = "Description", - }, - }, -} - ---1. If the user has already begun writing an MR, prompt them to --- continue working on it. ----@param args? Args +---@param args? Mr M.start = function(args) if M.started then vim.ui.select({ "Yes", "No" }, { prompt = "Continue your previous MR?" }, function(choice) @@ -99,18 +58,19 @@ M.start = function(args) end ---2. Pick the target branch ----@param args? Args -M.pick_target = function(args) - if not args then - args = {} +---@param mr? Mr +M.pick_target = function(mr) + if not mr then + mr = {} end - if args.target ~= nil then - M.pick_template({ target = args.target }, args) + if mr.target ~= nil then + M.pick_template(mr) return end if state.settings.create_mr.target ~= nil then - M.pick_template({ target = state.settings.create_mr.target }, args) + mr.target = state.settings.create_mr.target + M.pick_template(mr) return end @@ -119,7 +79,8 @@ M.pick_target = function(args) prompt = "Choose target branch for merge", }, function(choice) if choice then - M.pick_template({ target = choice }, args) + mr.target = choice + M.pick_template(mr) end end) end @@ -137,22 +98,22 @@ end ---3. Pick template (if applicable). This is used as the description ---@param mr Mr ----@param args Args -M.pick_template = function(mr, args) - if not args then - args = {} +M.pick_template = function(mr) + if mr.description ~= nil then + M.add_title(mr) + return end - local template_file = args.template_file or state.settings.create_mr.template_file + local template_file = mr.template_file or state.settings.create_mr.template_file if template_file ~= nil then - local description = u.read_file(make_template_path(template_file)) - M.add_title({ target = mr.target, description = description }) + mr.description = u.read_file(make_template_path(template_file)) + M.add_title(mr) return end local all_templates = u.list_files_in_folder(".gitlab" .. state.settings.file_separator .. "merge_request_templates") if all_templates == nil then - M.add_title({ target = mr.target }) + M.add_title(mr) return end @@ -163,18 +124,21 @@ M.pick_template = function(mr, args) vim.ui.select(opts, { prompt = "Choose Template", }, function(choice) - if choice then - local description = u.read_file(make_template_path(choice)) - M.add_title({ target = mr.target, description = description }) - elseif choice == "Blank Template" then - M.add_title({ target = mr.target }) + if choice and choice ~= "Blank Template" then + mr.description = u.read_file(make_template_path(choice)) end + M.add_title(mr) end) end ---4. Prompts the user for the title of the MR ---@param mr Mr M.add_title = function(mr) + if mr.title ~= nil then + M.open_confirmation_popup(mr) + return + end + local input = Input({ position = "50%", relative = "editor", @@ -200,8 +164,8 @@ M.add_title = function(mr) end ---5. Show the final popup. ----The function will render a popup containing the MR title and MR description, and ----target branch. The title and description are editable. +---The function will render a popup containing the MR title and MR description, +---target branch, and the "delete_branch" and "squash" options. All fields are editable. ---@param mr Mr M.open_confirmation_popup = function(mr) M.started = true @@ -211,7 +175,7 @@ M.open_confirmation_popup = function(mr) return end - local layout, title_popup, description_popup, target_popup = M.create_layout() + local layout, title_popup, description_popup, target_popup, delete_branch_popup, squash_popup = M.create_layout() M.layout = layout M.layout_buf = layout.bufnr @@ -220,22 +184,30 @@ M.open_confirmation_popup = function(mr) local function exit() local title = vim.fn.trim(u.get_buffer_text(M.title_bufnr)) local description = u.get_buffer_text(M.description_bufnr) - local target = vim.fn.trim(u.get_buffer_text(target_popup.bufnr)) + local target = vim.fn.trim(u.get_buffer_text(M.target_bufnr)) + local delete_branch = u.string_to_bool(u.get_buffer_text(M.delete_branch_bufnr)) + local squash = u.string_to_bool(u.get_buffer_text(M.squash_bufnr)) M.mr = { title = title, description = description, target = target, + delete_branch = delete_branch, + squash = squash, } layout:unmount() M.layout_visible = false end local description_lines = mr.description and M.build_description_lines(mr.description) or { "" } + local delete_branch = u.get_first_non_nil_value({ mr.delete_branch, state.settings.create_mr.delete_branch }) + local squash = u.get_first_non_nil_value({ mr.squash, state.settings.create_mr.squash }) vim.schedule(function() - vim.api.nvim_buf_set_lines(description_popup.bufnr, 0, -1, false, description_lines) - vim.api.nvim_buf_set_lines(title_popup.bufnr, 0, -1, false, { mr.title }) - vim.api.nvim_buf_set_lines(target_popup.bufnr, 0, -1, false, { mr.target }) + vim.api.nvim_buf_set_lines(M.description_bufnr, 0, -1, false, description_lines) + vim.api.nvim_buf_set_lines(M.title_bufnr, 0, -1, false, { mr.title }) + vim.api.nvim_buf_set_lines(M.target_bufnr, 0, -1, false, { mr.target }) + vim.api.nvim_buf_set_lines(M.delete_branch_bufnr, 0, -1, false, { u.bool_to_string(delete_branch) }) + vim.api.nvim_buf_set_lines(M.squash_bufnr, 0, -1, false, { u.bool_to_string(squash) }) local popup_opts = { cb = exit, @@ -246,8 +218,10 @@ M.open_confirmation_popup = function(mr) state.set_popup_keymaps(description_popup, M.create_mr, miscellaneous.attach_file, popup_opts) state.set_popup_keymaps(title_popup, M.create_mr, nil, popup_opts) state.set_popup_keymaps(target_popup, M.create_mr, nil, popup_opts) + state.set_popup_keymaps(delete_branch_popup, M.create_mr, nil, popup_opts) + state.set_popup_keymaps(squash_popup, M.create_mr, nil, popup_opts) - vim.api.nvim_set_current_buf(description_popup.bufnr) + vim.api.nvim_set_current_buf(M.description_bufnr) end) end @@ -268,11 +242,15 @@ M.create_mr = function() local description = u.get_buffer_text(M.description_bufnr) local title = u.get_buffer_text(M.title_bufnr):gsub("\n", " ") local target = u.get_buffer_text(M.target_bufnr):gsub("\n", " ") + local delete_branch = u.string_to_bool(u.get_buffer_text(M.delete_branch_bufnr)) + local squash = u.string_to_bool(u.get_buffer_text(M.squash_bufnr)) local body = { title = title, description = description, target_branch = target, + delete_branch = delete_branch, + squash = squash, } job.run_job("/create_mr", "POST", body, function(data) @@ -284,20 +262,30 @@ M.create_mr = function() end M.create_layout = function() - local title_popup = Popup(title_popup_settings) + local title_popup = Popup(u.create_box_popup_state("Title", false)) M.title_bufnr = title_popup.bufnr - local description_popup = Popup(description_popup_settings) + local description_popup = Popup(u.create_box_popup_state("Description", true)) M.description_bufnr = description_popup.bufnr - local target_branch_popup = Popup(target_popup_settings) + local target_branch_popup = Popup(u.create_box_popup_state("Target branch", false)) M.target_bufnr = target_branch_popup.bufnr + local delete_title = vim.o.columns > 110 and "Delete source branch" or "Delete source" + local delete_branch_popup = Popup(u.create_box_popup_state(delete_title, false)) + M.delete_branch_bufnr = delete_branch_popup.bufnr + local squash_title = vim.o.columns > 110 and "Squash commits" or "Squash" + local squash_popup = Popup(u.create_box_popup_state(squash_title, false)) + M.squash_bufnr = squash_popup.bufnr local internal_layout internal_layout = Layout.Box({ Layout.Box({ Layout.Box(title_popup, { grow = 1 }), - Layout.Box(target_branch_popup, { grow = 1 }), }, { size = 3 }), Layout.Box(description_popup, { grow = 1 }), + Layout.Box({ + Layout.Box(delete_branch_popup, { size = { width = #delete_title + 4 } }), + Layout.Box(squash_popup, { size = { width = #squash_title + 4 } }), + Layout.Box(target_branch_popup, { grow = 1 }), + }, { size = 3 }), }, { dir = "col" }) local layout = Layout({ @@ -311,7 +299,7 @@ M.create_layout = function() layout:mount() - return layout, title_popup, description_popup, target_branch_popup + return layout, title_popup, description_popup, target_branch_popup, delete_branch_popup, squash_popup end return M diff --git a/lua/gitlab/actions/merge.lua b/lua/gitlab/actions/merge.lua index 324dc1e2..b610c8e9 100644 --- a/lua/gitlab/actions/merge.lua +++ b/lua/gitlab/actions/merge.lua @@ -17,7 +17,7 @@ end ---@param opts MergeOpts M.merge = function(opts) - local merge_body = { squash = state.settings.merge.squash, delete_branch = state.settings.merge.delete_branch } + local merge_body = { squash = state.INFO.squash, delete_branch = state.INFO.delete_branch } if opts then merge_body.squash = opts.squash ~= nil and opts.squash merge_body.delete_branch = opts.delete_branch ~= nil and opts.delete_branch diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 8f1f00c8..0f850eca 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -17,43 +17,6 @@ local M = { description_bufnr = nil, } -local title_popup_settings = { - buf_options = { - filetype = "markdown", - }, - focusable = true, - border = { - style = "rounded", - }, -} - -local details_popup_settings = { - buf_options = { - filetype = "markdown", - }, - focusable = true, - border = { - style = "rounded", - text = { - top = "Details", - }, - }, -} - -local description_popup_settings = { - buf_options = { - filetype = "markdown", - }, - enter = true, - focusable = true, - border = { - style = "rounded", - text = { - top = "Description", - }, - }, -} - -- The function will render a popup containing the MR title and MR description, and optionally, -- any additional metadata that the user wants. The title and description are editable and -- can be changed via the local action keybinding, which also closes the popup @@ -66,7 +29,7 @@ M.summary = function() local title = state.INFO.title local description_lines = M.build_description_lines() - local info_lines = state.settings.info.enabled and M.build_info_lines() or nil + local info_lines = state.settings.info.enabled and M.build_info_lines() or { "" } local layout, title_popup, description_popup, info_popup = M.create_layout(info_lines) @@ -89,7 +52,7 @@ M.summary = function() vim.api.nvim_set_option_value("readonly", false, { buf = info_popup.bufnr }) end - M.color_labels(info_popup.bufnr) -- Color labels in details popup + M.color_details(info_popup.bufnr) -- Color values in details popup state.set_popup_keymaps( description_popup, @@ -133,8 +96,10 @@ M.build_info_lines = function() assignees = { title = "Assignees", content = u.make_readable_list(info.assignees, "name") }, reviewers = { title = "Reviewers", content = u.make_readable_list(info.reviewers, "name") }, branch = { title = "Branch", content = info.source_branch }, - labels = { title = "Labels", content = table.concat(info.labels, ", ") }, - target_branch = { title = "Target Branch", content = state.INFO.target_branch }, + labels = { title = "Labels", content = u.make_comma_separated_readable(info.labels) }, + target_branch = { title = "Target Branch", content = info.target_branch }, + delete_branch = { title = "Delete Source Branch", content = (info.force_remove_source_branch and "Yes" or "No") }, + squash = { title = "Squash Commits", content = (info.squash and "Yes" or "No") }, pipeline = { title = "Pipeline Status", content = function() @@ -196,15 +161,15 @@ M.edit_summary = function() end M.create_layout = function(info_lines) - local title_popup = Popup(title_popup_settings) + local title_popup = Popup(u.create_box_popup_state(nil, false)) M.title_bufnr = title_popup.bufnr - local description_popup = Popup(description_popup_settings) + local description_popup = Popup(u.create_box_popup_state("Description", true)) M.description_bufnr = description_popup.bufnr local details_popup local internal_layout if state.settings.info.enabled then - details_popup = Popup(details_popup_settings) + details_popup = Popup(u.create_box_popup_state("Details", false)) if state.settings.info.horizontal then local longest_line = u.get_longest_string(info_lines) internal_layout = Layout.Box({ @@ -241,8 +206,8 @@ M.create_layout = function(info_lines) return layout, title_popup, description_popup, details_popup end -M.color_labels = function(bufnr) - local label_namespace = vim.api.nvim_create_namespace("Labels") +M.color_details = function(bufnr) + local details_namespace = vim.api.nvim_create_namespace("Details") for i, v in ipairs(state.settings.info.fields) do if v == "labels" then local line_content = u.get_line_content(bufnr, i) @@ -251,9 +216,16 @@ M.color_labels = function(bufnr) if start_idx ~= nil and end_idx ~= nil then vim.cmd("highlight " .. "label" .. j .. " guifg=white") vim.api.nvim_set_hl(0, ("label" .. j), { fg = label.Color }) - vim.api.nvim_buf_add_highlight(bufnr, label_namespace, ("label" .. j), i - 1, start_idx - 1, end_idx) + vim.api.nvim_buf_add_highlight(bufnr, details_namespace, ("label" .. j), i - 1, start_idx - 1, end_idx) end end + elseif v == "delete_branch" or v == "squash" or v == "draft" or v == "conflicts" then + local line_content = u.get_line_content(bufnr, i) + local start_idx, end_idx = line_content:find("%S-$") + if start_idx ~= nil and end_idx ~= nil then + vim.api.nvim_set_hl(0, "boolean", { link = "Constant" }) + vim.api.nvim_buf_add_highlight(bufnr, details_namespace, "boolean", i - 1, start_idx - 1, end_idx) + end end end end diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 797efe73..ccc7d7b7 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -85,13 +85,11 @@ M.settings = { return " " .. discussions_content .. " %#Comment#| " .. notes_content .. help end, }, - merge = { - squash = false, - delete_branch = false, - }, create_mr = { target = nil, template_file = nil, + delete_branch = false, + squash = false, title_input = { width = 40, border = "rounded", @@ -112,6 +110,8 @@ M.settings = { "pipeline", "branch", "target_branch", + "delete_branch", + "squash", "labels", }, }, diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index f641162d..45b7e150 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -29,6 +29,17 @@ M.get_last_word = function(sentence, divider) return words[#words] or "" end +---Return the first non-nil value in the input table, or nil +---@param values table The list of input values +---@return any +M.get_first_non_nil_value = function(values) + for _, val in pairs(values) do + if val ~= nil then + return val + end + end +end + ---Returns whether a string ends with a substring ---@param str string ---@param ending string @@ -336,6 +347,30 @@ M.get_buffer_text = function(bufnr) return text end +---Convert string to corresponding boolean +---@param str string +---@return boolean +M.string_to_bool = function(str) + str = vim.fn.trim(str) + if str == "true" or str == "True" or str == "TRUE" then + return true + elseif str == "false" or str == "False" or str == "FALSE" then + return false + end + M.notify("Not a valid boolean value `" .. str .. "`. Defaulting to `false`", vim.log.levels.WARN) + return false +end + +---Convert boolean to corresponding string +---@param bool boolean +---@return string +M.bool_to_string = function(bool) + if bool == true then + return "true" + end + return "false" +end + M.string_starts = function(str, start) return str:sub(1, #start) == start end @@ -431,6 +466,27 @@ M.create_popup_state = function(title, settings, width, height, zindex) return view_opts end +---Create view_opts for Box popups used inside popup Layouts +---@param title string The string to appear on top of the popup +---@param enter boolean Whether the pop should be focused after creation +---@return table +M.create_box_popup_state = function(title, enter) + local settings = require("gitlab.state").settings.popup + return { + buf_options = { + filetype = "markdown", + }, + enter = enter or false, + focusable = true, + border = { + style = settings.border, + text = { + top = title, + }, + }, + } +end + M.read_file = function(file_path, opts) local file = io.open(file_path, "r") if file == nil then From 50cded63ef445c07fb75f5e06bf9bf9c004fa7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Mon, 8 Apr 2024 21:00:00 +0200 Subject: [PATCH 27/97] Fix: Treat INFO.labels as a table not as a string (#250) This fixes a bug introduced in the delete_branch/squash PR. --- lua/gitlab/actions/summary.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 0f850eca..14af9e46 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -96,7 +96,7 @@ M.build_info_lines = function() assignees = { title = "Assignees", content = u.make_readable_list(info.assignees, "name") }, reviewers = { title = "Reviewers", content = u.make_readable_list(info.reviewers, "name") }, branch = { title = "Branch", content = info.source_branch }, - labels = { title = "Labels", content = u.make_comma_separated_readable(info.labels) }, + labels = { title = "Labels", content = table.concat(info.labels, ", ") }, target_branch = { title = "Target Branch", content = info.target_branch }, delete_branch = { title = "Delete Source Branch", content = (info.force_remove_source_branch and "Yes" or "No") }, squash = { title = "Squash Commits", content = (info.squash and "Yes" or "No") }, From 77a6d92df85d821f2a53ada2eb7989622306fdb9 Mon Sep 17 00:00:00 2001 From: sunfuze Date: Tue, 9 Apr 2024 10:45:58 +0800 Subject: [PATCH 28/97] feat: ssh remote url can have custom port (#248) Lets users have a custom port in the URL when connecting to Gitlab --- cmd/git.go | 4 +--- cmd/git_test.go | 12 ++++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/cmd/git.go b/cmd/git.go index fc282fc8..c66f144c 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -28,14 +28,12 @@ it to the client for initialization. The current directory must be a valid Gitlab project and the branch must be a feature branch */ func extractGitInfo(refreshGitInfo func() error, getProjectRemoteUrl func() (string, error), getCurrentBranchName func() (string, error)) (GitProjectInfo, error) { - err := refreshGitInfo() if err != nil { return GitProjectInfo{}, fmt.Errorf("Could not get latest information from remote: %v", err) } url, err := getProjectRemoteUrl() - if err != nil { return GitProjectInfo{}, fmt.Errorf("Could not get project Url: %v", err) } @@ -52,7 +50,7 @@ func extractGitInfo(refreshGitInfo func() error, getProjectRemoteUrl func() (str https://git@gitlab.com/namespace/subnamespace/dummy-test-repo.git git@git@gitlab.com:namespace/subnamespace/dummy-test-repo.git */ - re := regexp.MustCompile(`(?:^https?:\/\/|^ssh:\/\/|^git@)(?:[^\/:]+)[\/:](.*)\/([^\/]+?)(?:\.git)?$`) + re := regexp.MustCompile(`(?:^https?:\/\/|^ssh:\/\/|^git@)(?:[^\/:]+)(?::\d+)?[\/:](.*)\/([^\/]+?)(?:\.git)?$`) matches := re.FindStringSubmatch(url) if len(matches) != 3 { return GitProjectInfo{}, fmt.Errorf("Invalid Git URL format: %s", url) diff --git a/cmd/git_test.go b/cmd/git_test.go index 99935d84..5ec04f28 100644 --- a/cmd/git_test.go +++ b/cmd/git_test.go @@ -102,6 +102,18 @@ func TestExtractGitInfo_Success(t *testing.T) { Namespace: "namespace-1/namespace-2/namespace-3", }, }, + { + desc: "Project configured in SSH:// and have a custom port", + getProjectRemoteUrl: func() (string, error) { + return "ssh://custom-gitlab.com:2222/namespace-1/project-name", nil + }, + expected: GitProjectInfo{ + RemoteUrl: "ssh://custom-gitlab.com:2222/namespace-1/project-name", + BranchName: "feature/abc", + ProjectName: "project-name", + Namespace: "namespace-1", + }, + }, { desc: "Project configured in HTTP and under a single folder without .git extension", getProjectRemoteUrl: func() (string, error) { From 2d59ac6671dbd5bd392bf5743d9cc386a662bb6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Tue, 9 Apr 2024 18:19:16 +0200 Subject: [PATCH 29/97] feat: Allow saving popup contents to temp_registers when quitting (#238) * Feat: Allow saving popup contents to temp_registers when quitting * Docs: Add info about temp_registers to README and docs * Fix: Save to temp_registers in BufUnload callback * Fix: Quitting Confirmation popup does not work with BufUnload --- README.md | 1 + doc/gitlab.nvim.txt | 21 +++++++++++++++++++++ lua/gitlab/actions/comment.lua | 8 ++++---- lua/gitlab/actions/discussions/init.lua | 11 +++++++++-- lua/gitlab/actions/merge.lua | 3 ++- lua/gitlab/actions/miscellaneous.lua | 4 ++++ lua/gitlab/emoji.lua | 5 +++++ lua/gitlab/state.lua | 9 ++++++++- 8 files changed, 54 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 52da6802..146da6db 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ require("gitlab").setup({ pipeline = nil, reply = nil, squash_message = nil, + temp_registers = {}, -- List of registers for backing up popup content (see `:h gitlab.nvim.temp-registers`) }, discussion_tree = { -- The discussion tree that holds all comments auto_open = true, -- Automatically open when the reviewer is opened diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index a845f933..b585f995 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -11,6 +11,7 @@ Table of Contents *gitlab.nvim.table-of-contents* - Usage |gitlab.nvim.usage| - The Summary view |gitlab.nvim.the-summary-view| - Reviewing an MR |gitlab.nvim.reviewing-an-mr| + - Temporary registers |gitlab.nvim.temp-registers| - Discussions and Notes |gitlab.nvim.discussions-and-notes| - Labels |gitlab.nvim.labels| - Signs and diagnostics |gitlab.nvim.signs-and-diagnostics| @@ -152,6 +153,7 @@ you call this function with no values the defaults will be used: pipeline = nil, reply = nil, squash_message = nil, + temp_registers = {}, -- List of registers for backing up popup content (see `:h gitlab.nvim.temp-registers`) }, discussion_tree = { -- The discussion tree that holds all comments auto_open = true, -- Automatically open when the reviewer is opened @@ -290,6 +292,25 @@ code block with prefilled code from the visual selection. Just like the summary, all the different kinds of comments are saved via the `settings.popup.perform_action` keybinding. +TEMPORARY REGISTERS *gitlab.nvim.temp-registers* + +While writing a note/comment/suggestion/reply, you may need to interrupt the +work, do something else (e.g., read something in another file, etc.), and then +get back to your work on the comment. For occasions like this, you can set +`settings.popup.temp_registers` to a list of writable registers (see |registers|) +to which the contents of the popup window will be saved just before quitting. + +A possible setting is, e.g., `settings.popup.backup_register = {'"', "+", "g"}` +which saves to the so called unnamed register (see |quotequote|), the system +clipboard register (see |quoteplus|), as well as to the "g" register. This lets +you easily paste the text back in with |p| or |P| (from the unnamed register), +or with `"gp` or `"gP` if the unnamed register gets overwritten with something +else. Using the clipboard register lets you easily use the text outside of +nvim. + +NOTE: The `temp_registers` are also filled with the contents of the popup when +pressing the `settings.popup.perform_action` keybinding, even if the action +that was supposed to be performed fails. DISCUSSIONS AND NOTES *gitlab.nvim.discussions-and-notes* diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 28366931..1416822b 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -35,7 +35,7 @@ M.create_comment = function() comment_popup:mount() state.set_popup_keymaps(comment_popup, function(text) M.confirm_create_comment(text) - end, miscellaneous.attach_file) + end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) end ---Create multiline comment for the last selection. @@ -48,7 +48,7 @@ M.create_multiline_comment = function() comment_popup:mount() state.set_popup_keymaps(comment_popup, function(text) M.confirm_create_comment(text, { start_line = start_line, end_line = end_line }) - end, miscellaneous.attach_file) + end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) end ---Create comment prepopulated with gitlab suggestion @@ -95,7 +95,7 @@ M.create_comment_suggestion = function() else M.confirm_create_comment(text, nil) end - end, miscellaneous.attach_file) + end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) end M.create_note = function() @@ -103,7 +103,7 @@ M.create_note = function() note_popup:mount() state.set_popup_keymaps(note_popup, function(text) M.confirm_create_comment(text, nil, true) - end, miscellaneous.attach_file) + end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) end ---This function (settings.popup.perform_action) will send the comment to the Go server diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index dc3a7a4f..d8d2c94e 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -256,7 +256,12 @@ M.reply = function(tree) 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(tree, id), miscellaneous.attach_file) + state.set_popup_keymaps( + reply_popup, + M.send_reply(tree, id), + miscellaneous.attach_file, + miscellaneous.editable_popup_opts + ) end -- This function will send the reply to the Go API @@ -331,7 +336,9 @@ M.edit_comment = function(tree, unlinked) vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines) state.set_popup_keymaps( edit_popup, - M.send_edits(tostring(root_node.id), tonumber(note_node.root_note_id or note_node.id), unlinked) + M.send_edits(tostring(root_node.id), tonumber(note_node.root_note_id or note_node.id), unlinked), + nil, + miscellaneous.editable_popup_opts ) end diff --git a/lua/gitlab/actions/merge.lua b/lua/gitlab/actions/merge.lua index b610c8e9..81d23846 100644 --- a/lua/gitlab/actions/merge.lua +++ b/lua/gitlab/actions/merge.lua @@ -3,6 +3,7 @@ local Popup = require("nui.popup") local state = require("gitlab.state") local job = require("gitlab.job") local reviewer = require("gitlab.reviewer") +local miscellaneous = require("gitlab.actions.miscellaneous") local M = {} @@ -33,7 +34,7 @@ M.merge = function(opts) squash_message_popup:mount() state.set_popup_keymaps(squash_message_popup, function(text) M.confirm_merge(merge_body, text) - end) + end, nil, miscellaneous.editable_popup_opts) else M.confirm_merge(merge_body) end diff --git a/lua/gitlab/actions/miscellaneous.lua b/lua/gitlab/actions/miscellaneous.lua index e7550e8b..b6cc086a 100644 --- a/lua/gitlab/actions/miscellaneous.lua +++ b/lua/gitlab/actions/miscellaneous.lua @@ -34,4 +34,8 @@ M.attach_file = function() end) end +M.editable_popup_opts = { + save_to_temp_register = true, +} + return M diff --git a/lua/gitlab/emoji.lua b/lua/gitlab/emoji.lua index 1d61dafd..0d778a98 100644 --- a/lua/gitlab/emoji.lua +++ b/lua/gitlab/emoji.lua @@ -85,9 +85,14 @@ M.init_popup = function(tree, bufnr) end local cursor_pos = vim.api.nvim_win_get_cursor(0) + -- "zyiw on the next line erases the unnamed register. This may interfere with the + -- `temp_registers` used for backing up editable popup contents, so let's backup the unnamed + -- register. + local unnamed_register_contents = vim.fn.getreg('"') vim.api.nvim_command('normal! "zyiw') vim.api.nvim_win_set_cursor(0, cursor_pos) local word = vim.fn.getreg("z") + vim.fn.setreg('"', unnamed_register_contents) -- restore the unnamed register for k, v in pairs(M.emoji_map) do if v.moji == word then diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index ccc7d7b7..6c15622a 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -39,6 +39,7 @@ M.settings = { help = nil, pipeline = nil, squash_message = nil, + temp_registers = {}, }, discussion_tree = { auto_open = true, @@ -296,9 +297,15 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts) end, { buffer = popup.bufnr, desc = "Perform linewise action" }) end - vim.api.nvim_create_autocmd("BufUnload", { + vim.api.nvim_create_autocmd("BufWinLeave", { buffer = popup.bufnr, callback = function() + if opts.save_to_temp_register then + local text = u.get_buffer_text(popup.bufnr) + for _, register in ipairs(M.settings.popup.temp_registers) do + vim.fn.setreg(register, text) + end + end exit(popup, opts) end, }) From cc689a1526b408a1463e2c5b89e1767b55e094b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 10 Apr 2024 15:29:33 +0200 Subject: [PATCH 30/97] fix: Autocommand causes exit() to be called twice (#252) Fixes errors after merge request is created due to autocommand errors. --- lua/gitlab/state.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 6c15622a..0b3b96c7 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -280,9 +280,9 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts) local text = u.get_buffer_text(popup.bufnr) if opts.action_before_close then action(text, popup.bufnr) - exit(popup, opts) + vim.api.nvim_buf_delete(popup.bufnr, {}) else - exit(popup, opts) + vim.api.nvim_buf_delete(popup.bufnr, {}) action(text, popup.bufnr) end end, { buffer = popup.bufnr, desc = "Perform action" }) From 6d15f2395df77b67e9dd7bb9d20b253708db897d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 10 Apr 2024 15:32:17 +0200 Subject: [PATCH 31/97] feat: Add keymap to copy node URL to clipboard (#253) feat: Adds a "u" keybinding to the discussion tree for copying the current discussion URL to the system clipboard. --- README.md | 1 + doc/gitlab.nvim.txt | 15 ++++++++------- lua/gitlab/actions/discussions/init.lua | 23 ++++++++++++++++++++++- lua/gitlab/state.lua | 1 + 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 146da6db..b819d1f8 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ require("gitlab").setup({ toggle_resolved = "p" -- Toggles the resolved status of the whole discussion position = "left", -- "top", "right", "bottom" or "left" open_in_browser = "b" -- Jump to the URL of the current note/discussion + copy_node_url = "u", -- Copy the URL of the current node to clipboard size = "20%", -- Size of split relative = "editor", -- Position of tree split relative to "editor" or "window" resolved = '✓', -- Symbol to show next to resolved discussions diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index b585f995..40ac2dbe 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -173,6 +173,7 @@ you call this function with no values the defaults will be used: toggle_resolved = "p" -- Toggles the resolved status of the whole discussion position = "left", -- "top", "right", "bottom" or "left" open_in_browser = "b" -- Jump to the URL of the current note/discussion + copy_node_url = "u", -- Copy the URL of the current node to clipboard size = "20%", -- Size of split relative = "editor", -- Position of tree split relative to "editor" or "window" resolved = '✓', -- Symbol to show next to resolved discussions @@ -551,7 +552,7 @@ you can also interact with the Go server like any other process: LUA API *gitlab.nvim.api* *gitlab.nvim.setup* -setup() ~ +gitlab.setup() ~ Call this first to initialize the plugin. With no arguments, it will use the default arguments outlined under "Configuring the Plugin". @@ -563,7 +564,7 @@ default arguments outlined under "Configuring the Plugin". require("gitlab").setup({ discussion_tree = { blacklist = { "some_bot"} } }) < *gitlab.nvim.review* -review() ~ +gitlab.review() ~ Opens the reviewer pane. Can be used from anywhere within Neovim after the plugin is loaded. If run twice, will open a second reviewer pane. @@ -571,7 +572,7 @@ plugin is loaded. If run twice, will open a second reviewer pane. require("gitlab").review() < *gitlab.nvim.summary* -summary() ~ +gitlab.summary() ~ Opens the summary window with information about the current MR, such as the description, the author, and the title. Can be configured via the `info` field @@ -583,7 +584,7 @@ The summary can be edited. Once you have made changes, send them to Gitlab via the `settings.popup.perform_action` keybinding. *gitlab.nvim.approve* -approve() ~ +gitlab.approve() ~ Approves the current MR. Will error if the current user does not have permission. @@ -604,13 +605,13 @@ gitlab.create_comment() ~ Opens a popup to create a comment on the current line. Must be called when focused on the reviewer pane (see the gitlab.nvim.review command), otherwise it will error. >lua - require("gitlab").comment() + require("gitlab").create_comment() After the comment is typed, submit it to Gitlab via the `settings.popup.perform_action` keybinding, by default `l`. *gitlab.nvim.create_multiline_comment* -create_multiline_comment() ~ +gitlab.create_multiline_comment() ~ Opens a popup to create a multi-line comment. May only be called in visual mode, and will use the currently selected lines. @@ -621,7 +622,7 @@ After the comment is typed, submit it to Gitlab via the |settings.popup.perform_ keybinding, by default `l`. *gitlab.nvim.create_comment_suggestion* -create_comment_suggestion() ~ +gitlab.create_comment_suggestion() ~ Opens a popup to create a comment suggestion (aka a comment that makes a committable change suggestion to the currently selected lines). diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index d8d2c94e..c7f411ee 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -837,6 +837,9 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) vim.keymap.set("n", state.settings.discussion_tree.open_in_browser, function() M.open_in_browser(tree) end, { buffer = bufnr, desc = "Open the note in your browser" }) + vim.keymap.set("n", state.settings.discussion_tree.copy_node_url, function() + M.copy_node_url(tree) + end, { buffer = bufnr, desc = "Copy the URL of the current node to clipboard" }) vim.keymap.set("n", "p", function() M.print_node(tree) end, { buffer = bufnr, desc = "Print current node (for debugging)" }) @@ -953,7 +956,7 @@ M.add_reply_to_tree = function(tree, note, discussion_id) end ---@param tree NuiTree -M.open_in_browser = function(tree) +M.get_url = function(tree) local current_node = tree:get_node() local note_node = M.get_note_node(tree, current_node) if note_node == nil then @@ -964,10 +967,28 @@ M.open_in_browser = function(tree) u.notify("Could not get URL of note", vim.log.levels.ERROR) return end + return url +end +---@param tree NuiTree +M.open_in_browser = function(tree) + local url = M.get_url(tree) + if url == nil then + return + end u.open_in_browser(url) end +---@param tree NuiTree +M.copy_node_url = function(tree) + local url = M.get_url(tree) + if url == nil then + return + end + u.notify("Copied '" .. url .. "' to clipboard", vim.log.levels.INFO) + vim.fn.setreg("+", url) +end + M.add_emoji_to_note = function(tree, unlinked) local node = tree:get_node() local note_node = M.get_note_node(tree, node) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 0b3b96c7..c8b90096 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -51,6 +51,7 @@ M.settings = { edit_comment = "e", delete_comment = "dd", open_in_browser = "b", + copy_node_url = "u", reply = "r", toggle_node = "t", add_emoji = "Ea", From 48a03c6c1716ef377ee9c52ff8b5c95348474304 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Wed, 10 Apr 2024 14:58:59 -0400 Subject: [PATCH 32/97] fix: do not colorize output if no info is provided (#255) fix: do not attempt to colorize info if none is provided to summary view --- lua/gitlab/actions/summary.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 14af9e46..c1dc9728 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -50,10 +50,9 @@ M.summary = function() vim.api.nvim_buf_set_lines(info_popup.bufnr, 0, -1, false, info_lines) vim.api.nvim_set_option_value("modifiable", false, { buf = info_popup.bufnr }) vim.api.nvim_set_option_value("readonly", false, { buf = info_popup.bufnr }) + M.color_details(info_popup.bufnr) -- Color values in details popup end - M.color_details(info_popup.bufnr) -- Color values in details popup - state.set_popup_keymaps( description_popup, M.edit_summary, From 9bdd077d9b05a88245c32eea74047fdd5a0ca7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Thu, 11 Apr 2024 18:00:28 +0200 Subject: [PATCH 33/97] feat: Copy web url of MR to clipboard (#258) feat: Add API and keymap for copying web URL to clipboard --- doc/gitlab.nvim.txt | 10 +++++++++- lua/gitlab/actions/discussions/init.lua | 12 +++++------- lua/gitlab/init.lua | 14 ++++++++++---- lua/gitlab/utils/init.lua | 8 ++++++++ 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 40ac2dbe..67e02dbb 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -522,6 +522,7 @@ in normal mode): vim.keymap.set("n", "glp", gitlab.pipeline) vim.keymap.set("n", "glo", gitlab.open_in_browser) vim.keymap.set("n", "glM", gitlab.merge) + vim.keymap.set("n", "glu", gitlab.copy_mr_url) < TROUBLESHOOTING *gitlab.nvim.troubleshooting* @@ -633,7 +634,7 @@ After the comment is typed, submit it to Gitlab via the |settings.popup.perform_ keybinding, by default |l| *gitlab.nvim.create_mr* -create_mr({opts}) ~ +gitlab.create_mr({opts}) ~ Starts the process of creating an MR for the currently checked out branch. >lua @@ -753,6 +754,13 @@ gitlab.open_in_browser() ~ Opens the current MR in your default web browser. >lua require("gitlab").open_in_browser() +< + *gitlab.nvim.copy_mr_url* +gitlab.copy_mr_url() ~ + +Copies the URL of the current MR to system clipboard. +>lua + require("gitlab").copy_mr_url() < *gitlab.nvim.merge* gitlab.merge({opts}) ~ diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index c7f411ee..160439f1 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -973,20 +973,18 @@ end ---@param tree NuiTree M.open_in_browser = function(tree) local url = M.get_url(tree) - if url == nil then - return + if url ~= nil then + u.open_in_browser(url) end - u.open_in_browser(url) end ---@param tree NuiTree M.copy_node_url = function(tree) local url = M.get_url(tree) - if url == nil then - return + if url ~= nil then + vim.fn.setreg("+", url) + u.notify("Copied '" .. url .. "' to clipboard", vim.log.levels.INFO) end - u.notify("Copied '" .. url .. "' to clipboard", vim.log.levels.INFO) - vim.fn.setreg("+", url) end M.add_emoji_to_note = function(tree, unlinked) diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 1e9add6c..874d8502 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -73,10 +73,16 @@ return { data = data.data, print_settings = state.print_settings, open_in_browser = async.sequence({ info }, function() - if state.INFO.web_url == nil then - u.notify("Could not get Gitlab URL", vim.log.levels.ERROR) - return + local web_url = u.get_web_url() + if web_url ~= nil then + u.open_in_browser(web_url) + end + end), + copy_mr_url = async.sequence({ info }, function() + local web_url = u.get_web_url() + if web_url ~= nil then + vim.fn.setreg("+", web_url) + u.notify("Copied '" .. web_url .. "' to clipboard", vim.log.levels.INFO) end - u.open_in_browser(state.INFO.web_url) end), } diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 45b7e150..19071585 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -661,6 +661,14 @@ M.basename = function(str) return name end +M.get_web_url = function() + local web_url = require("gitlab.state").INFO.web_url + if web_url ~= nil then + return web_url + end + M.notify("Could not get Gitlab URL", vim.log.levels.ERROR) +end + ---@param url string? M.open_in_browser = function(url) if vim.fn.has("mac") == 1 then From 7d76187cdbef54ddfefda7c3b348e6c61744c9eb Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Fri, 12 Apr 2024 09:52:38 -0400 Subject: [PATCH 34/97] fix: Jumping to wrong buffer (#261) Fixes issues where we were jumping to the wrong buffer --- lua/gitlab/actions/discussions/init.lua | 7 ++++++- lua/gitlab/reviewer/init.lua | 12 ++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 160439f1..67f26c64 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -432,7 +432,12 @@ M.jump_to_reviewer = function(tree) u.notify("Could not get discussion node", vim.log.levels.ERROR) return end - reviewer.jump(root_node.file_name, get_new_line(root_node), get_old_line(root_node)) + local line_number = (root_node.new_line or root_node.old_line or 1) + if root_node.range then + local start_old_line, start_new_line = common.parse_line_code(root_node.range.start.line_code) + line_number = root_node.old_line and start_old_line or start_new_line + end + reviewer.jump(root_node.file_name, line_number, root_node.old_line == nil) M.refresh_view() end diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 32c86023..203d0e10 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -93,9 +93,9 @@ end -- Jumps to the location provided in the reviewer window ---@param file_name string ----@param new_line number|nil ----@param old_line number|nil -M.jump = function(file_name, new_line, old_line) +---@param line_number number +---@param new_buffer boolean +M.jump = function(file_name, line_number, new_buffer) if M.tabnr == nil then u.notify("Can't jump to Diffvew. Is it open?", vim.log.levels.ERROR) return @@ -115,12 +115,12 @@ M.jump = function(file_name, new_line, old_line) async.await(view:set_file(file)) local layout = view.cur_layout - if old_line == nil then + if new_buffer then layout.b:focus() - vim.api.nvim_win_set_cursor(0, { new_line, 0 }) + vim.api.nvim_win_set_cursor(0, { line_number, 0 }) else layout.a:focus() - vim.api.nvim_win_set_cursor(0, { old_line, 0 }) + vim.api.nvim_win_set_cursor(0, { line_number, 0 }) end end From 540986a8418cd22febb76471a2f3e9665306145c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sun, 14 Apr 2024 18:28:45 +0200 Subject: [PATCH 35/97] Fix: Go to last line and show warning when diagnostic is past the end of buffer (#262) --- lua/gitlab/reviewer/init.lua | 10 ++++++++-- lua/gitlab/utils/init.lua | 5 +++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 203d0e10..1aa6f971 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -115,13 +115,19 @@ M.jump = function(file_name, line_number, new_buffer) async.await(view:set_file(file)) local layout = view.cur_layout + local number_of_lines if new_buffer then layout.b:focus() - vim.api.nvim_win_set_cursor(0, { line_number, 0 }) + number_of_lines = u.get_buffer_length(layout.b.file.bufnr) else layout.a:focus() - vim.api.nvim_win_set_cursor(0, { line_number, 0 }) + number_of_lines = u.get_buffer_length(layout.a.file.bufnr) end + if line_number > number_of_lines then + u.notify("Diagnostic position outside buffer. Jumping to last line instead.", vim.log.levels.WARN) + line_number = number_of_lines + end + vim.api.nvim_win_set_cursor(0, { line_number, 0 }) end ---Get the data from diffview, such as line information and file name. May be used by diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 19071585..4f1d0239 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -347,6 +347,11 @@ M.get_buffer_text = function(bufnr) return text end +---Returns the number of lines in the buffer. Returns 1 even for empty buffers. +M.get_buffer_length = function(bufnr) + return #vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) +end + ---Convert string to corresponding boolean ---@param str string ---@return boolean From 5df7bb39c49cd8fb57bc43521544b03ad28f8838 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sun, 14 Apr 2024 15:33:38 -0400 Subject: [PATCH 36/97] fix: Get recent pipeline through other means (#266) fix: gets most recent pipeline for current branch by hitting different Gitlab API --- cmd/comment.go | 2 +- cmd/git.go | 24 +++++++++++++++---- cmd/git_test.go | 13 ++++++++-- cmd/pipeline.go | 45 ++++++++++++++++++++++++++++------- cmd/pipeline_test.go | 56 ++++++++++++++++++++++++++------------------ cmd/reply.go | 2 +- cmd/server.go | 9 ++++--- cmd/test.go | 6 ++--- cmd/types.go | 2 +- makefile | 2 +- 10 files changed, 113 insertions(+), 48 deletions(-) diff --git a/cmd/comment.go b/cmd/comment.go index 3b300ba4..879344f9 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -222,7 +222,7 @@ func (a *api) editComment(w http.ResponseWriter, r *http.Request) { } options := gitlab.UpdateMergeRequestDiscussionNoteOptions{} - options.Body = gitlab.String(editCommentRequest.Comment) + options.Body = gitlab.Ptr(editCommentRequest.Comment) note, res, err := a.client.UpdateMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, editCommentRequest.DiscussionId, editCommentRequest.NoteId, &options) diff --git a/cmd/git.go b/cmd/git.go index c66f144c..2fc89f6b 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -8,10 +8,11 @@ import ( ) type GitProjectInfo struct { - RemoteUrl string - Namespace string - ProjectName string - BranchName string + RemoteUrl string + Namespace string + ProjectName string + BranchName string + GetLatestCommitOnRemote func(a *api) (string, error) } /* @@ -108,3 +109,18 @@ func RefreshProjectInfo() error { return nil } + +/* +The GetLatestCommitOnRemote function is attached during the createRouterAndApi call, since it needs to be called every time to get the latest commit. +*/ +func GetLatestCommitOnRemote(a *api) (string, error) { + cmd := exec.Command("git", "log", "-1", "--format=%H", fmt.Sprintf("origin/%s", a.gitInfo.BranchName)) + + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("Failed to run `git log -1 --format=%%H " + fmt.Sprintf("origin/%s", a.gitInfo.BranchName)) + } + + commit := strings.TrimSpace(string(out)) + return commit, nil +} diff --git a/cmd/git_test.go b/cmd/git_test.go index 5ec04f28..d2f263bd 100644 --- a/cmd/git_test.go +++ b/cmd/git_test.go @@ -169,8 +169,17 @@ func TestExtractGitInfo_Success(t *testing.T) { if err != nil { t.Errorf("No error was expected, got %s", err) } - if actual != tC.expected { - t.Errorf("\nExpected: %s\nActual: %s", tC.expected, actual) + if actual.RemoteUrl != tC.expected.RemoteUrl { + t.Errorf("\nExpected Remote URL: %s\nActual: %s", tC.expected.RemoteUrl, actual.RemoteUrl) + } + if actual.BranchName != tC.expected.BranchName { + t.Errorf("\nExpected Branch Name: %s\nActual: %s", tC.expected.BranchName, actual.BranchName) + } + if actual.ProjectName != tC.expected.ProjectName { + t.Errorf("\nExpected Project Name: %s\nActual: %s", tC.expected.ProjectName, actual.ProjectName) + } + if actual.Namespace != tC.expected.Namespace { + t.Errorf("\nExpected Namespace: %s\nActual: %s", tC.expected.Namespace, actual.Namespace) } }) } diff --git a/cmd/pipeline.go b/cmd/pipeline.go index e14e3c81..877904cf 100644 --- a/cmd/pipeline.go +++ b/cmd/pipeline.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -16,8 +17,8 @@ type RetriggerPipelineResponse struct { } type PipelineWithJobs struct { - Jobs []*gitlab.Job `json:"jobs"` - LatestPipeline *gitlab.Pipeline `json:"latest_pipeline"` + Jobs []*gitlab.Job `json:"jobs"` + LatestPipeline *gitlab.PipelineInfo `json:"latest_pipeline"` } type GetPipelineAndJobsResponse struct { @@ -42,25 +43,51 @@ func (a *api) pipelineHandler(w http.ResponseWriter, r *http.Request) { } } +/* Gets the latest pipeline for a given commit, returns an error if there is no pipeline */ +func (a *api) GetLastPipeline(commit string) (*gitlab.PipelineInfo, error) { + + l := &gitlab.ListProjectPipelinesOptions{ + SHA: gitlab.Ptr(commit), + Sort: gitlab.Ptr("desc"), + } + + l.Page = 1 + l.PerPage = 1 + + pipes, _, err := a.client.ListProjectPipelines(a.projectInfo.ProjectId, l) + + if err != nil { + return nil, err + } + + if len(pipes) == 0 { + return nil, errors.New("No pipeline running or available for commit " + commit) + } + + return pipes[0], nil +} + +/* Gets the latest pipeline and job information for the current branch */ func (a *api) GetPipelineAndJobs(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - pipeline, res, err := a.client.GetLatestPipeline(a.projectInfo.ProjectId, &gitlab.GetLatestPipelineOptions{ - Ref: &a.gitInfo.BranchName, - }) + commit, err := a.gitInfo.GetLatestCommitOnRemote(a) if err != nil { - handleError(w, err, fmt.Sprintf("Gitlab failed to get latest pipeline for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError) + fmt.Println(err) + handleError(w, err, "Error getting commit on remote branch", http.StatusInternalServerError) return } - if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("Could not get latest pipeline for %s branch", a.gitInfo.BranchName), res.StatusCode) + pipeline, err := a.GetLastPipeline(commit) + + if err != nil { + handleError(w, err, fmt.Sprintf("Gitlab failed to get latest pipeline for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError) return } if pipeline == nil { - handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("No pipeline found for %s branch", a.gitInfo.BranchName), res.StatusCode) + handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("No pipeline found for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError) return } diff --git a/cmd/pipeline_test.go b/cmd/pipeline_test.go index 5f940d33..2ef44821 100644 --- a/cmd/pipeline_test.go +++ b/cmd/pipeline_test.go @@ -32,17 +32,27 @@ func retryPipelineBuildNon200(pid interface{}, pipeline int, options ...gitlab.R return nil, makeResponse(http.StatusSeeOther), nil } -func getLatestPipeline200(pid interface{}, opts *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { - return &gitlab.Pipeline{ID: 1}, makeResponse(http.StatusOK), nil +func listProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) { + return []*gitlab.PipelineInfo{ + {ID: 12345}, + }, makeResponse(http.StatusOK), nil +} + +func withGitInfo(a *api) error { + a.gitInfo.GetLatestCommitOnRemote = func(a *api) (string, error) { + return "123abc", nil + } + a.gitInfo.BranchName = "some-feature" + return nil } func TestPipelineHandler(t *testing.T) { t.Run("Gets all pipeline jobs", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/pipeline", nil) server, _ := createRouterAndApi(fakeClient{ - listPipelineJobs: listPipelineJobs, - getLatestPipeline: getLatestPipeline200, - }) + listPipelineJobs: listPipelineJobs, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, GetPipelineAndJobsResponse{}) assert(t, data.SuccessResponse.Message, "Pipeline retrieved") assert(t, data.SuccessResponse.Status, http.StatusOK) @@ -51,9 +61,9 @@ func TestPipelineHandler(t *testing.T) { t.Run("Disallows non-GET, non-POST methods", func(t *testing.T) { request := makeRequest(t, http.MethodPatch, "/pipeline", nil) server, _ := createRouterAndApi(fakeClient{ - listPipelineJobs: listPipelineJobs, - getLatestPipeline: getLatestPipeline200, - }) + listPipelineJobs: listPipelineJobs, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, ErrorResponse{}) checkBadMethod(t, *data, http.MethodGet, http.MethodPost) }) @@ -61,9 +71,9 @@ func TestPipelineHandler(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/pipeline", nil) server, _ := createRouterAndApi(fakeClient{ - listPipelineJobs: listPipelineJobsErr, - getLatestPipeline: getLatestPipeline200, - }) + listPipelineJobs: listPipelineJobsErr, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, ErrorResponse{}) checkErrorFromGitlab(t, *data, "Could not get pipeline jobs") }) @@ -71,9 +81,9 @@ func TestPipelineHandler(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/pipeline", nil) server, _ := createRouterAndApi(fakeClient{ - listPipelineJobs: listPipelineJobsNon200, - getLatestPipeline: getLatestPipeline200, - }) + listPipelineJobs: listPipelineJobsNon200, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not get pipeline jobs", "/pipeline") }) @@ -81,9 +91,9 @@ func TestPipelineHandler(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil) server, _ := createRouterAndApi(fakeClient{ - retryPipelineBuild: retryPipelineBuildErr, - getLatestPipeline: getLatestPipeline200, - }) + retryPipelineBuild: retryPipelineBuildErr, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, ErrorResponse{}) checkErrorFromGitlab(t, *data, "Could not retrigger pipeline") }) @@ -91,9 +101,9 @@ func TestPipelineHandler(t *testing.T) { t.Run("Retriggers pipeline", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil) server, _ := createRouterAndApi(fakeClient{ - retryPipelineBuild: retryPipelineBuild, - getLatestPipeline: getLatestPipeline200, - }) + retryPipelineBuild: retryPipelineBuild, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, GetPipelineAndJobsResponse{}) assert(t, data.SuccessResponse.Message, "Pipeline retriggered") assert(t, data.SuccessResponse.Status, http.StatusOK) @@ -102,9 +112,9 @@ func TestPipelineHandler(t *testing.T) { t.Run("Handles non-200s from Gitlab client on retrigger", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil) server, _ := createRouterAndApi(fakeClient{ - retryPipelineBuild: retryPipelineBuildNon200, - getLatestPipeline: getLatestPipeline200, - }) + retryPipelineBuild: retryPipelineBuildNon200, + listProjectPipelines: listProjectPipelines, + }, withGitInfo) data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not retrigger pipeline", "/pipeline") }) diff --git a/cmd/reply.go b/cmd/reply.go index e2998b92..556184d8 100644 --- a/cmd/reply.go +++ b/cmd/reply.go @@ -45,7 +45,7 @@ func (a *api) replyHandler(w http.ResponseWriter, r *http.Request) { now := time.Now() options := gitlab.AddMergeRequestDiscussionNoteOptions{ - Body: gitlab.String(replyRequest.Reply), + Body: gitlab.Ptr(replyRequest.Reply), CreatedAt: &now, } diff --git a/cmd/server.go b/cmd/server.go index ae2a8a0b..c38d78a3 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -35,8 +35,11 @@ func startServer(client *Client, projectInfo *ProjectInfo, gitInfo GitProjectInf func(a *api) error { err := attachEmojisToApi(a) return err + }, + func(a *api) error { + a.gitInfo.GetLatestCommitOnRemote = GetLatestCommitOnRemote + return nil }) - l := createListener() server := &http.Server{Handler: m} @@ -191,8 +194,8 @@ func (a *api) withMr(f func(w http.ResponseWriter, r *http.Request)) func(http.R } options := gitlab.ListProjectMergeRequestsOptions{ - Scope: gitlab.String("all"), - State: gitlab.String("opened"), + Scope: gitlab.Ptr("all"), + State: gitlab.Ptr("opened"), SourceBranch: &a.gitInfo.BranchName, } diff --git a/cmd/test.go b/cmd/test.go index 085009b1..29430a2d 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -35,7 +35,7 @@ type fakeClient struct { listAllProjectMembers func(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) retryPipelineBuild func(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) listPipelineJobs func(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) - getLatestPipeline func(pid interface{}, opt *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) + listProjectPipelines func(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) getTraceFile func(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) listLabels func(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) listMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) @@ -121,8 +121,8 @@ func (f fakeClient) ListPipelineJobs(pid interface{}, pipelineID int, opts *gitl return f.listPipelineJobs(pid, pipelineID, opts, options...) } -func (f fakeClient) GetLatestPipeline(pid interface{}, opts *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { - return f.getLatestPipeline(pid, opts, options...) +func (f fakeClient) ListProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) { + return f.listProjectPipelines(pid, opt, options...) } func (f fakeClient) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { diff --git a/cmd/types.go b/cmd/types.go index 871112a6..9434577b 100644 --- a/cmd/types.go +++ b/cmd/types.go @@ -53,7 +53,7 @@ type ClientInterface interface { ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) - GetLatestPipeline(pid interface{}, opt *gitlab.GetLatestPipelineOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) + ListProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) ListLabels(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) ListMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) diff --git a/makefile b/makefile index f077924c..626ad683 100644 --- a/makefile +++ b/makefile @@ -7,7 +7,7 @@ compile: @cd cmd && go build -o bin && mv bin ../bin ## test: run golang project tests test: - @cd cmd && go test -v + @cd cmd && go test .PHONY: help all: help From 15a02cb940818e2e25dd09b349c72e1aeda3a3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Mon, 15 Apr 2024 15:46:30 +0200 Subject: [PATCH 37/97] feat: Add keymaps and linewise actions to layouts (#265) * feat: Add keymaps for cycling popups in Summary and MR Confirmation popups= * feat: Add toggle_bool linewise action * fix: Don't include the HEAD pointer in the list of branches * fix: Enable excluding current branch from list of branches * feat: Add linewise action to select new target branch --- README.md | 4 ++ doc/gitlab.nvim.txt | 15 ++++- lua/gitlab/actions/create_mr.lua | 43 ++++++++++---- lua/gitlab/actions/miscellaneous.lua | 54 +++++++++++++++++ lua/gitlab/actions/summary.lua | 10 +++- lua/gitlab/state.lua | 4 ++ lua/gitlab/utils/init.lua | 89 ++++++++++++++++++++++------ 7 files changed, 187 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index b819d1f8..4eb50f9c 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ require("gitlab").setup({ }, help = "g?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) popup = { -- The popup for comment creation, editing, and replying + keymaps = { + next_field = "", -- Cycle to the next field. Accepts count. + prev_field = "", -- Cycle to the previous field. Accepts count. + }, perform_action = "s", -- Once in normal mode, does action (like saving comment or editing description, etc) perform_linewise_action = "l", -- Once in normal mode, does the linewise action (see logs for this job, etc) width = "40%", diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index ea5dcc5e..dad3cde6 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -141,6 +141,10 @@ you call this function with no values the defaults will be used: }, help = "g?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) popup = { -- The popup for comment creation, editing, and replying + keymaps = { + next_field = "", -- Cycle to the next field. Accepts |count|. + prev_field = "", -- Cycle to the previous field. Accepts |count|. + }, perform_action = "s", -- Once in normal mode, does action (like saving comment or editing description, etc) perform_linewise_action = "l", -- Once in normal mode, does the linewise action (see logs for this job, etc) width = "40%", @@ -663,6 +667,15 @@ Starts the process of creating an MR for the currently checked out branch. • {squash}: (bool) If true, the commits will be marked for squashing. +After selecting all necessary details, you'll be presented with a confirmation +window. You can cycle through the individual fields with the keymaps defined +in `settings.popup.keymaps.next_field` and `settings.popup.keymaps.prev_field`. +Both keymaps accept a count, i.g., 2 goes to the 2nd next field. +In the "Delete source branch", "Squash commits", and "Target branch" fields, +you can use the `settings.popup.perform_linewise_action` keymap to either +toggle the Boolean value or to select a new target branch, respectively. +Use the `settings.popup.perform_action` keymap to POST the MR to Gitlab. + *gitlab.nvim.move_to_discussion_tree_from_diagnostic* gitlab.move_to_discussion_tree_from_diagnostic() ~ @@ -682,7 +695,7 @@ tied to specific changes in an MR. require("gitlab").create_note() After the comment is typed, submit it to Gitlab via the `settings.popup.perform_action` -keybinding, by default |l| +keybinding, by default |s|. *gitlab.nvim.toggle_discussions* gitlab.toggle_discussions() ~ diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua index 7c7ea2b4..41b68f27 100644 --- a/lua/gitlab/actions/create_mr.lua +++ b/lua/gitlab/actions/create_mr.lua @@ -74,14 +74,10 @@ M.pick_target = function(mr) return end - local all_branch_names = u.get_all_git_branches(true) - vim.ui.select(all_branch_names, { - prompt = "Choose target branch for merge", - }, function(choice) - if choice then - mr.target = choice - M.pick_template(mr) - end + -- Select target branch interactively if it hasn't been selected by other means + u.select_target_branch(function(target) + mr.target = target + M.pick_template(mr) end) end @@ -177,6 +173,14 @@ M.open_confirmation_popup = function(mr) local layout, title_popup, description_popup, target_popup, delete_branch_popup, squash_popup = M.create_layout() + local popups = { + title_popup, + description_popup, + delete_branch_popup, + squash_popup, + target_popup, + } + M.layout = layout M.layout_buf = layout.bufnr M.layout_visible = true @@ -209,6 +213,10 @@ M.open_confirmation_popup = function(mr) vim.api.nvim_buf_set_lines(M.delete_branch_bufnr, 0, -1, false, { u.bool_to_string(delete_branch) }) vim.api.nvim_buf_set_lines(M.squash_bufnr, 0, -1, false, { u.bool_to_string(squash) }) + u.switch_can_edit_buf(M.delete_branch_bufnr, false) + u.switch_can_edit_buf(M.squash_bufnr, false) + u.switch_can_edit_buf(M.target_bufnr, false) + local popup_opts = { cb = exit, action_before_close = true, @@ -217,9 +225,10 @@ M.open_confirmation_popup = function(mr) state.set_popup_keymaps(description_popup, M.create_mr, miscellaneous.attach_file, popup_opts) state.set_popup_keymaps(title_popup, M.create_mr, nil, popup_opts) - state.set_popup_keymaps(target_popup, M.create_mr, nil, popup_opts) - state.set_popup_keymaps(delete_branch_popup, M.create_mr, nil, popup_opts) - state.set_popup_keymaps(squash_popup, M.create_mr, nil, popup_opts) + state.set_popup_keymaps(target_popup, M.create_mr, M.select_new_target, popup_opts) + state.set_popup_keymaps(delete_branch_popup, M.create_mr, miscellaneous.toggle_bool, popup_opts) + state.set_popup_keymaps(squash_popup, M.create_mr, miscellaneous.toggle_bool, popup_opts) + miscellaneous.set_cycle_popups_keymaps(popups) vim.api.nvim_set_current_buf(M.description_bufnr) end) @@ -237,6 +246,18 @@ M.build_description_lines = function(template_content) return description_lines end +---Prompts for interactive selection of a new target among remote-tracking branches +M.select_new_target = function() + local bufnr = vim.api.nvim_get_current_buf() + u.select_target_branch(function(target) + vim.schedule(function() + u.switch_can_edit_buf(bufnr, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { target }) + u.switch_can_edit_buf(bufnr, false) + end) + end) +end + ---This function will POST the new MR to create it M.create_mr = function() local description = u.get_buffer_text(M.description_bufnr) diff --git a/lua/gitlab/actions/miscellaneous.lua b/lua/gitlab/actions/miscellaneous.lua index b6cc086a..868b4be5 100644 --- a/lua/gitlab/actions/miscellaneous.lua +++ b/lua/gitlab/actions/miscellaneous.lua @@ -38,4 +38,58 @@ M.editable_popup_opts = { save_to_temp_register = true, } +-- Get the index of the next popup when cycling forward +local function next_index(i, n, count) + count = count > 0 and count or 1 + for _ = 1, count do + if i < n then + i = i + 1 + elseif i == n then + i = 1 + end + end + return i +end + +---Get the index of the previous popup when cycling backward +---@param i integer The current index +---@param n integer The total number of popups +---@param count integer The count used with the keymap (replaced with 1 if no count was given) +local function prev_index(i, n, count) + count = count > 0 and count or 1 + for _ = 1, count do + if i > 1 then + i = i - 1 + elseif i == 1 then + i = n + end + end + return i +end + +---Setup keymaps for cycling popups. The keymap accepts count. +---@param popups table Table of Popups +M.set_cycle_popups_keymaps = function(popups) + local number_of_popups = #popups + for i, popup in ipairs(popups) do + popup:map("n", state.settings.popup.keymaps.next_field, function() + vim.api.nvim_set_current_win(popups[next_index(i, number_of_popups, vim.v.count)].winid) + end, { desc = "Go to next field (accepts count)" }) + popup:map("n", state.settings.popup.keymaps.prev_field, function() + vim.api.nvim_set_current_win(popups[prev_index(i, number_of_popups, vim.v.count)].winid) + end, { desc = "Go to previous field (accepts count)" }) + end +end + +---Toggle the value in a "Boolean buffer" +M.toggle_bool = function() + local bufnr = vim.api.nvim_get_current_buf() + local current_val = u.get_buffer_text(bufnr) + vim.schedule(function() + u.switch_can_edit_buf(bufnr, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { u.toggle_string_bool(current_val) }) + u.switch_can_edit_buf(bufnr, false) + end) +end + return M diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index c1dc9728..a3747f95 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -32,6 +32,11 @@ M.summary = function() local info_lines = state.settings.info.enabled and M.build_info_lines() or { "" } local layout, title_popup, description_popup, info_popup = M.create_layout(info_lines) + local popups = { + title_popup, + description_popup, + info_popup, + } M.layout = layout M.layout_buf = layout.bufnr @@ -48,8 +53,7 @@ M.summary = function() if info_popup then vim.api.nvim_buf_set_lines(info_popup.bufnr, 0, -1, false, info_lines) - vim.api.nvim_set_option_value("modifiable", false, { buf = info_popup.bufnr }) - vim.api.nvim_set_option_value("readonly", false, { buf = info_popup.bufnr }) + u.switch_can_edit_buf(info_popup.bufnr, false) M.color_details(info_popup.bufnr) -- Color values in details popup end @@ -60,6 +64,8 @@ M.summary = function() { cb = exit, action_before_close = true } ) state.set_popup_keymaps(title_popup, M.edit_summary, nil, { cb = exit, action_before_close = true }) + state.set_popup_keymaps(info_popup, M.edit_summary, nil, { cb = exit, action_before_close = true }) + miscellaneous.set_cycle_popups_keymaps(popups) vim.api.nvim_set_current_buf(description_popup.bufnr) end) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index c8b90096..609d802b 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -26,6 +26,10 @@ M.settings = { attachment_dir = "", help = "g?", popup = { + keymaps = { + next_field = "", + prev_field = "", + }, perform_action = "s", perform_linewise_action = "l", width = "40%", diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 4f1d0239..d33caf98 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -376,6 +376,27 @@ M.bool_to_string = function(bool) return "false" end +---Toggle boolean value +---@param bool string +---@return string +M.toggle_string_bool = function(bool) + local string_bools = { + ["true"] = "false", + ["True"] = "False", + ["TRUE"] = "FALSE", + ["false"] = "true", + ["False"] = "True", + ["FALSE"] = "TRUE", + } + bool = bool:gsub("^%s+", ""):gsub("%s+$", "") + local toggled = string_bools[bool] + if toggled == nil then + M.notify(("Cannot toggle value '%s'"):format(bool), vim.log.levels.ERROR) + return bool + end + return toggled +end + M.string_starts = function(str, start) return str:sub(1, #start) == start end @@ -635,32 +656,64 @@ M.make_comma_separated_readable = function(str) return string.gsub(str, ",", ", ") end ----@param remote? boolean -M.get_all_git_branches = function(remote) - local branches = {} - - local handle = remote == true and io.popen("git branch -r 2>&1") or io.popen("git branch 2>&1") - +---Return the name of the current branch +---@return string|nil +M.get_current_branch = function() + local handle = io.popen("git branch --show-current 2>&1") if handle then - for line in handle:lines() do - local branch - if remote then - for res in line:gmatch("origin/([^\n]+)") do - branch = res -- Trim /origin - end - else - branch = line:gsub("^%s*%*?%s*", "") -- Trim leading whitespace and the "* " marker for the current branch - end - table.insert(branches, branch) - end - handle:close() + return handle:read() else M.notify("Error running 'git branch' command.", vim.log.levels.ERROR) end +end + +---Return the list of names of all remote-tracking branches +M.get_all_merge_targets = function() + local handle = io.popen("git branch -r 2>&1") + if not handle then + M.notify("Error running 'git branch' command.", vim.log.levels.ERROR) + return + end + + local current_branch = M.get_current_branch() + if not current_branch then + return + end + + local lines = {} + for line in handle:lines() do + table.insert(lines, line) + end + handle:close() + + -- Trim "origin/" and don't include the HEAD pointer + local branches = List.new(lines) + :map(function(line) + return line:match("origin/(%S+)") + end) + :filter(function(branch) + return not branch:match("^HEAD$") and branch ~= current_branch + end) return branches end +---Select a git branch and perform callback with the branch as an argument +---@param cb function The callback to perform with the selected branch +M.select_target_branch = function(cb) + local all_branch_names = M.get_all_merge_targets() + if not all_branch_names then + return + end + vim.ui.select(all_branch_names, { + prompt = "Choose target branch for merge", + }, function(choice) + if choice then + cb(choice) + end + end) +end + M.basename = function(str) local name = string.gsub(str, "(.*/)(.*)", "%2") return name From a2c79c3de968deeb4509a78842e507ed1595b05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 17 Apr 2024 00:07:07 +0200 Subject: [PATCH 38/97] ix: File comments don't have line number information (#268) fix: Fixes diagnostics for comments that are left "on a file" which do not have position information --- lua/gitlab/indicators/diagnostics.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index 7548a9b0..c2c7a6c6 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -49,7 +49,7 @@ end local create_single_line_diagnostic = function(discussion) local first_note = discussion.notes[1] return create_diagnostic({ - lnum = (first_note.position.new_line or first_note.position.old_line) - 1, + lnum = (first_note.position.new_line or first_note.position.old_line or 1) - 1, }, discussion) end From 442dea1c4a632ba26053df197880a3c4b51333d9 Mon Sep 17 00:00:00 2001 From: Patrick Pichler Date: Fri, 19 Apr 2024 14:13:04 +0200 Subject: [PATCH 39/97] feat: add authentication provider setting (#270) feat: Adds authentication provider function Fixes #269 --- README.md | 12 +++++++++ doc/gitlab.nvim.txt | 18 +++++++++++-- lua/gitlab/state.lua | 60 ++++++++++++++++++++++++++------------------ 3 files changed, 63 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 4eb50f9c..ad06f209 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,18 @@ gitlab_url=https://my-personal-gitlab-instance.com/ The plugin will look for the `.gitlab.nvim` file in the root of the current project by default. However, you may provide a custom path to the configuration file via the `config_path` option. This must be an absolute path to the directory that holds your `.gitlab.nvim` file. +In case even more control over the auth config is needed, there is the possibility to override the `auth_provider` settings field. It should be +a function that returns the `token` as well as the `gitlab_url` value. If the `gitlab_url` is `nil`, `https://gitlab.com` is used as default. + +Here an example how to use a custom `auth_provider`: +```lua +require("gitlab").setup({ + auth_provider = function() + return "my_token", "https://custom.gitlab.instance.url" + end, +} +``` + For more settings, please see `:h gitlab.nvim.connecting-to-gitlab` ## Configuring the Plugin diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index dad3cde6..003fbcfb 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -122,6 +122,20 @@ directory that holds your `.gitlab.nvim` file. The `connection_settings` block in the `state.lua` file will be used to configure your connection to Gitlab. +In case even more control over the auth config is needed, there is the +possibility to override the `auth_provider` settings field. It should be +a function that returns the `token` as well as the `gitlab_url` value. +If the `gitlab_url` is `nil`, `https://gitlab.com` is used as default. + +Here an example how to use a custom `auth_provider`: +>lua + require("gitlab").setup({ + auth_provider = function() + return "my_token", "https://custom.gitlab.instance.url" + end, + } +< + CONFIGURING THE PLUGIN *gitlab.nvim.configuring-the-plugin* @@ -364,7 +378,7 @@ These labels will be visible in the summary panel, as long as you provide the SIGNS AND DIAGNOSTICS *gitlab.nvim.signs-and-diagnostics* -By default when reviewing files, you will see diagnostics for comments that +By default when reviewing files, you will see diagnostics for comments that have been added to a review. These are the default settings: >lua discussion_signs = { @@ -379,7 +393,7 @@ have been added to a review. These are the default settings: }, }, -When the cursor is on diagnostic line you can view discussion thread by using `vim.diagnostic.show()` +When the cursor is on diagnostic line you can view discussion thread by using `vim.diagnostic.show()` You can also jump to discussion tree for the given comment: >lua diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 609d802b..818c4298 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -8,8 +8,40 @@ local M = {} M.emoji_map = nil +M.default_auth_provider = function() + local base_path + if M.settings.config_path ~= nil then + base_path = M.settings.config_path + else + base_path = vim.fn.trim(vim.fn.system({ "git", "rev-parse", "--show-toplevel" })) + if vim.v.shell_error ~= 0 then + u.notify(string.format("Could not get base directory: %s", base_path), vim.log.levels.ERROR) + return false + end + end + + local config_file_path = base_path .. M.settings.file_separator .. ".gitlab.nvim" + local config_file_content = u.read_file(config_file_path, { remove_newlines = true }) + + local file_properties = {} + if config_file_content ~= nil then + local file = assert(io.open(config_file_path, "r")) + for line in file:lines() do + for key, value in string.gmatch(line, "(.-)=(.-)$") do + file_properties[key] = value + end + end + end + + local auth_token = file_properties.auth_token or os.getenv("GITLAB_TOKEN") + local gitlab_url = file_properties.gitlab_url or os.getenv("GITLAB_URL") + + return auth_token, gitlab_url +end + -- These are the default settings for the plugin M.settings = { + auth_provider = M.default_auth_provider, port = nil, -- choose random port debug = { go_request = false, go_response = false }, log_path = (vim.fn.stdpath("cache") .. "/gitlab.nvim.log"), @@ -218,32 +250,10 @@ M.setPluginConfiguration = function() return true end - local base_path - if M.settings.config_path ~= nil then - base_path = M.settings.config_path - else - base_path = vim.fn.trim(vim.fn.system({ "git", "rev-parse", "--show-toplevel" })) - if vim.v.shell_error ~= 0 then - u.notify(string.format("Could not get base directory: %s", base_path), vim.log.levels.ERROR) - return false - end - end - - local config_file_path = base_path .. M.settings.file_separator .. ".gitlab.nvim" - local config_file_content = u.read_file(config_file_path, { remove_newlines = true }) - - local file_properties = {} - if config_file_content ~= nil then - local file = assert(io.open(config_file_path, "r")) - for line in file:lines() do - for key, value in string.gmatch(line, "(.-)=(.-)$") do - file_properties[key] = value - end - end - end + local token, url = M.settings.auth_provider() - M.settings.auth_token = file_properties.auth_token or os.getenv("GITLAB_TOKEN") - M.settings.gitlab_url = u.trim_slash(file_properties.gitlab_url or os.getenv("GITLAB_URL") or "https://gitlab.com") + M.settings.auth_token = token + M.settings.gitlab_url = u.trim_slash(url or "https://gitlab.com") if M.settings.auth_token == nil then vim.notify( From f604c9441fea7dc3d73719d2d1b87a502068cb0a Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Fri, 19 Apr 2024 09:51:46 -0400 Subject: [PATCH 40/97] feat: Create and Manage Reviews (Draft Notes) (#264) feat: Implements adding "draft" notes of all types to the review, and publishing them, either individually or all at once. Addresses feature request #223. --- README.md | 6 + after/syntax/gitlab.vim | 2 + cmd/client.go | 2 + cmd/comment.go | 78 +- cmd/comment_helpers.go | 82 ++ cmd/comment_test.go | 20 +- cmd/draft_notes.go | 304 +++++++ cmd/draft_notes_test.go | 197 +++++ cmd/server.go | 2 + cmd/test.go | 30 + cmd/types.go | 6 + doc/gitlab.nvim.txt | 24 + lua/gitlab/actions/comment.lua | 283 ++++--- lua/gitlab/actions/common.lua | 229 +++++ lua/gitlab/actions/create_mr.lua | 15 +- lua/gitlab/actions/data.lua | 2 + .../actions/discussions/annotations.lua | 15 +- lua/gitlab/actions/discussions/init.lua | 785 ++++++------------ lua/gitlab/actions/discussions/tree.lua | 487 ++++++++--- lua/gitlab/actions/discussions/winbar.lua | 107 ++- lua/gitlab/actions/draft_notes/init.lua | 239 ++++++ lua/gitlab/actions/summary.lua | 19 +- lua/gitlab/async.lua | 5 +- lua/gitlab/colors.lua | 1 + lua/gitlab/emoji.lua | 9 +- lua/gitlab/{hunks/init.lua => hunks.lua} | 0 lua/gitlab/indicators/common.lua | 83 +- lua/gitlab/indicators/diagnostics.lua | 59 +- lua/gitlab/init.lua | 13 +- lua/gitlab/reviewer/init.lua | 11 +- lua/gitlab/state.lua | 75 +- lua/gitlab/utils/init.lua | 16 +- lua/gitlab/utils/list.lua | 19 +- 33 files changed, 2226 insertions(+), 999 deletions(-) create mode 100644 cmd/comment_helpers.go create mode 100644 cmd/draft_notes.go create mode 100644 cmd/draft_notes_test.go create mode 100644 lua/gitlab/actions/common.lua create mode 100755 lua/gitlab/actions/draft_notes/init.lua rename lua/gitlab/{hunks/init.lua => hunks.lua} (100%) diff --git a/README.md b/README.md index ad06f209..881affae 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ require("gitlab").setup({ toggle_resolved_discussions = "R", -- Open or close all resolved discussions toggle_unresolved_discussions = "U", -- Open or close all unresolved discussions keep_current_open = false, -- If true, current discussion stays open even if it should otherwise be closed when toggling + publish_draft = "P", -- Publishes the currently focused note/comment toggle_resolved = "p" -- Toggles the resolved status of the whole discussion position = "left", -- "top", "right", "bottom" or "left" open_in_browser = "b" -- Jump to the URL of the current note/discussion @@ -175,6 +176,9 @@ require("gitlab").setup({ winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) -- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar. }, + comments = { + default_to_draft = false, -- Whether to default a comment to a "draft" or not in the popup + }, info = { -- Show additional fields in the summary view enabled = true, horizontal = false, -- Display metadata to the left of the summary rather than underneath @@ -278,6 +282,8 @@ vim.keymap.set("n", "glrd", gitlab.delete_reviewer) vim.keymap.set("n", "glp", gitlab.pipeline) vim.keymap.set("n", "glo", gitlab.open_in_browser) vim.keymap.set("n", "glM", gitlab.merge) +vim.keymap.set("n", "glu", gitlab.copy_mr_url) +vim.keymap.set("n", "glP", gitlab.publish_all_drafts) ``` For more information about each of these commands, and about the APIs in general, run `:h gitlab.nvim.api` diff --git a/after/syntax/gitlab.vim b/after/syntax/gitlab.vim index 937df5cc..4647290a 100644 --- a/after/syntax/gitlab.vim +++ b/after/syntax/gitlab.vim @@ -8,6 +8,7 @@ syntax match ChevronDown "" syntax match ChevronRight "" syntax match Resolved /\s✓\s\?/ syntax match Unresolved /\s-\s\?/ +syntax match Pencil // highlight link Username GitlabUsername highlight link Date GitlabDate @@ -15,5 +16,6 @@ highlight link ChevronDown GitlabChevron highlight link ChevronRight GitlabChevron highlight link Resolved GitlabResolved highlight link Unresolved GitlabUnresolved +highlight link Pencil GitlabDraft let b:current_syntax = "gitlab" diff --git a/cmd/client.go b/cmd/client.go index 6ce09720..59de32ca 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -40,6 +40,7 @@ type Client struct { *gitlab.LabelsService *gitlab.AwardEmojiService *gitlab.UsersService + *gitlab.DraftNotesService } /* initGitlabClient parses and validates the project settings and initializes the Gitlab client. */ @@ -116,6 +117,7 @@ func initGitlabClient() (error, *Client) { LabelsService: client.Labels, AwardEmojiService: client.AwardEmoji, UsersService: client.Users, + DraftNotesService: client.DraftNotes, } } diff --git a/cmd/comment.go b/cmd/comment.go index 879344f9..bd13e25d 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -1,7 +1,6 @@ package main import ( - "crypto/sha1" "encoding/json" "fmt" "io" @@ -11,28 +10,8 @@ import ( ) type PostCommentRequest struct { - Comment string `json:"comment"` - FileName string `json:"file_name"` - NewLine *int `json:"new_line,omitempty"` - OldLine *int `json:"old_line,omitempty"` - HeadCommitSHA string `json:"head_commit_sha"` - BaseCommitSHA string `json:"base_commit_sha"` - StartCommitSHA string `json:"start_commit_sha"` - Type string `json:"type"` - LineRange *LineRange `json:"line_range,omitempty"` -} - -/* LineRange represents the range of a note. */ -type LineRange struct { - StartRange *LinePosition `json:"start"` - EndRange *LinePosition `json:"end"` -} - -/* LinePosition represents a position in a line range. Unlike the Gitlab struct, this does not contain LineCode with a sha1 of the filename */ -type LinePosition struct { - Type string `json:"type"` - OldLine int `json:"old_line"` - NewLine int `json:"new_line"` + Comment string `json:"comment"` + PositionData } type DeleteCommentRequest struct { @@ -53,6 +32,15 @@ type CommentResponse struct { Discussion *gitlab.Discussion `json:"discussion"` } +/* CommentWithPosition is a comment with an (optional) position data value embedded in it. The position data will be non-nil for range-based comments. */ +type CommentWithPosition struct { + PositionData PositionData +} + +func (comment CommentWithPosition) GetPositionData() PositionData { + return comment.PositionData +} + /* commentHandler creates, edits, and deletes discussions (comments, multi-line comments) */ func (a *api) commentHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -133,46 +121,10 @@ func (a *api) postComment(w http.ResponseWriter, r *http.Request) { /* If we are leaving a comment on a line, leave position. Otherwise, we are leaving a note (unlinked comment) */ - var friendlyName = "Note" + if postCommentRequest.FileName != "" { - friendlyName = "Comment" - opt.Position = &gitlab.PositionOptions{ - PositionType: &postCommentRequest.Type, - StartSHA: &postCommentRequest.StartCommitSHA, - HeadSHA: &postCommentRequest.HeadCommitSHA, - BaseSHA: &postCommentRequest.BaseCommitSHA, - NewPath: &postCommentRequest.FileName, - OldPath: &postCommentRequest.FileName, - NewLine: postCommentRequest.NewLine, - OldLine: postCommentRequest.OldLine, - } - - if postCommentRequest.LineRange != nil { - friendlyName = "Multiline Comment" - shaFormat := "%x_%d_%d" - startFilenameSha := fmt.Sprintf( - shaFormat, - sha1.Sum([]byte(postCommentRequest.FileName)), - postCommentRequest.LineRange.StartRange.OldLine, - postCommentRequest.LineRange.StartRange.NewLine, - ) - endFilenameSha := fmt.Sprintf( - shaFormat, - sha1.Sum([]byte(postCommentRequest.FileName)), - postCommentRequest.LineRange.EndRange.OldLine, - postCommentRequest.LineRange.EndRange.NewLine, - ) - opt.Position.LineRange = &gitlab.LineRangeOptions{ - Start: &gitlab.LinePositionOptions{ - Type: &postCommentRequest.LineRange.StartRange.Type, - LineCode: &startFilenameSha, - }, - End: &gitlab.LinePositionOptions{ - Type: &postCommentRequest.LineRange.EndRange.Type, - LineCode: &endFilenameSha, - }, - } - } + commentWithPositionData := CommentWithPosition{postCommentRequest.PositionData} + opt.Position = buildCommentPosition(commentWithPositionData) } discussion, res, err := a.client.CreateMergeRequestDiscussion(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt) @@ -190,7 +142,7 @@ func (a *api) postComment(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) response := CommentResponse{ SuccessResponse: SuccessResponse{ - Message: fmt.Sprintf("%s created successfully", friendlyName), + Message: "Comment created successfully", Status: http.StatusOK, }, Comment: discussion.Notes[0], diff --git a/cmd/comment_helpers.go b/cmd/comment_helpers.go new file mode 100644 index 00000000..bd69507e --- /dev/null +++ b/cmd/comment_helpers.go @@ -0,0 +1,82 @@ +package main + +import ( + "crypto/sha1" + "fmt" + + "github.com/xanzy/go-gitlab" +) + +/* LinePosition represents a position in a line range. Unlike the Gitlab struct, this does not contain LineCode with a sha1 of the filename */ +type LinePosition struct { + Type string `json:"type"` + OldLine int `json:"old_line"` + NewLine int `json:"new_line"` +} + +/* LineRange represents the range of a note. */ +type LineRange struct { + StartRange *LinePosition `json:"start"` + EndRange *LinePosition `json:"end"` +} + +/* PositionData represents the position of a comment or note (relative to a file diff) */ +type PositionData struct { + FileName string `json:"file_name"` + NewLine *int `json:"new_line,omitempty"` + OldLine *int `json:"old_line,omitempty"` + HeadCommitSHA string `json:"head_commit_sha"` + BaseCommitSHA string `json:"base_commit_sha"` + StartCommitSHA string `json:"start_commit_sha"` + Type string `json:"type"` + LineRange *LineRange `json:"line_range,omitempty"` +} + +/* RequestWithPosition is an interface that abstracts the handling of position data for a comment or a draft comment */ +type RequestWithPosition interface { + GetPositionData() PositionData +} + +/* buildCommentPosition takes a comment or draft comment request and builds the position data necessary for a location-based comment */ +func buildCommentPosition(commentWithPositionData RequestWithPosition) *gitlab.PositionOptions { + positionData := commentWithPositionData.GetPositionData() + + opt := &gitlab.PositionOptions{ + PositionType: &positionData.Type, + StartSHA: &positionData.StartCommitSHA, + HeadSHA: &positionData.HeadCommitSHA, + BaseSHA: &positionData.BaseCommitSHA, + NewPath: &positionData.FileName, + OldPath: &positionData.FileName, + NewLine: positionData.NewLine, + OldLine: positionData.OldLine, + } + + if positionData.LineRange != nil { + shaFormat := "%x_%d_%d" + startFilenameSha := fmt.Sprintf( + shaFormat, + sha1.Sum([]byte(positionData.FileName)), + positionData.LineRange.StartRange.OldLine, + positionData.LineRange.StartRange.NewLine, + ) + endFilenameSha := fmt.Sprintf( + shaFormat, + sha1.Sum([]byte(positionData.FileName)), + positionData.LineRange.EndRange.OldLine, + positionData.LineRange.EndRange.NewLine, + ) + opt.LineRange = &gitlab.LineRangeOptions{ + Start: &gitlab.LinePositionOptions{ + Type: &positionData.LineRange.StartRange.Type, + LineCode: &startFilenameSha, + }, + End: &gitlab.LinePositionOptions{ + Type: &positionData.LineRange.EndRange.Type, + LineCode: &endFilenameSha, + }, + } + } + + return opt +} diff --git a/cmd/comment_test.go b/cmd/comment_test.go index 1628eb7d..7b7939ad 100644 --- a/cmd/comment_test.go +++ b/cmd/comment_test.go @@ -25,12 +25,16 @@ func TestPostComment(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{}) server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) data := serveRequest(t, server, request, CommentResponse{}) - assert(t, data.SuccessResponse.Message, "Note created successfully") + assert(t, data.SuccessResponse.Message, "Comment created successfully") assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Creates a new comment", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{FileName: "some_file.txt"}) + request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{ + PositionData: PositionData{ + FileName: "some_file.txt", + }, + }) server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) data := serveRequest(t, server, request, CommentResponse{}) assert(t, data.SuccessResponse.Message, "Comment created successfully") @@ -39,15 +43,17 @@ func TestPostComment(t *testing.T) { t.Run("Creates a new multiline comment", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{ - FileName: "some_file.txt", - LineRange: &LineRange{ - StartRange: &LinePosition{}, /* These would have real data */ - EndRange: &LinePosition{}, + PositionData: PositionData{ + FileName: "some_file.txt", + LineRange: &LineRange{ + StartRange: &LinePosition{}, /* These would have real data */ + EndRange: &LinePosition{}, + }, }, }) server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) data := serveRequest(t, server, request, CommentResponse{}) - assert(t, data.SuccessResponse.Message, "Multiline Comment created successfully") + assert(t, data.SuccessResponse.Message, "Comment created successfully") assert(t, data.SuccessResponse.Status, http.StatusOK) }) diff --git a/cmd/draft_notes.go b/cmd/draft_notes.go new file mode 100644 index 00000000..f002a5e9 --- /dev/null +++ b/cmd/draft_notes.go @@ -0,0 +1,304 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/xanzy/go-gitlab" +) + +/* The data coming from the client when creating a draft note is the same, +as when they are creating a normal comment, but the Gitlab +endpoints + resources we handle are different */ + +type PostDraftNoteRequest struct { + Comment string `json:"comment"` + PositionData +} + +type UpdateDraftNoteRequest struct { + Note string `json:"note"` +} + +type DraftNotePublishRequest struct { + Note int `json:"note,omitempty"` + PublishAll bool `json:"publish_all"` +} + +type DraftNoteResponse struct { + SuccessResponse + DraftNote *gitlab.DraftNote `json:"draft_note"` +} + +type ListDraftNotesResponse struct { + SuccessResponse + DraftNotes []*gitlab.DraftNote `json:"draft_notes"` +} + +/* DraftNoteWithPosition is a draft comment with an (optional) position data value embedded in it. The position data will be non-nil for range-based draft comments. */ +type DraftNoteWithPosition struct { + PositionData PositionData +} + +func (draftNote DraftNoteWithPosition) GetPositionData() PositionData { + return draftNote.PositionData +} + +/* draftNoteHandler creates, edits, and deletes draft notes */ +func (a *api) draftNoteHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + a.listDraftNotes(w, r) + case http.MethodPost: + a.postDraftNote(w, r) + case http.MethodPatch: + a.updateDraftNote(w, r) + case http.MethodDelete: + a.deleteDraftNote(w, r) + default: + w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s, %s, %s", http.MethodDelete, http.MethodPost, http.MethodPatch, http.MethodGet)) + handleError(w, InvalidRequestError{}, "Expected DELETE, GET, POST or PATCH", http.StatusMethodNotAllowed) + } +} + +func (a *api) draftNotePublisher(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) + handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + defer r.Body.Close() + var draftNotePublishRequest DraftNotePublishRequest + err = json.Unmarshal(body, &draftNotePublishRequest) + + if err != nil { + handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + return + } + + var res *gitlab.Response + if draftNotePublishRequest.PublishAll { + res, err = a.client.PublishAllDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId) + } else { + if draftNotePublishRequest.Note == 0 { + handleError(w, errors.New("No ID provided"), "Must provide Note ID", http.StatusBadRequest) + return + } + res, err = a.client.PublishDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, draftNotePublishRequest.Note) + } + + if err != nil { + handleError(w, err, "Could not publish draft note(s)", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/draft_notes/publish"}, "Could not publish dfaft note", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) + response := SuccessResponse{ + Message: "Draft note(s) published", + Status: http.StatusOK, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} + +/* postDraftNote creates a draft note */ +func (a *api) postDraftNote(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + defer r.Body.Close() + + var postDraftNoteRequest PostDraftNoteRequest + err = json.Unmarshal(body, &postDraftNoteRequest) + if err != nil { + handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) + return + } + + opt := gitlab.CreateDraftNoteOptions{ + Note: &postDraftNoteRequest.Comment, + // TODO: Support posting replies as drafts and rendering draft replies in the discussion tree + // instead of the notes tree + // InReplyToDiscussionID *string `url:"in_reply_to_discussion_id,omitempty" json:"in_reply_to_discussion_id,omitempty"` + } + + if postDraftNoteRequest.FileName != "" { + draftNoteWithPosition := DraftNoteWithPosition{postDraftNoteRequest.PositionData} + opt.Position = buildCommentPosition(draftNoteWithPosition) + } + + draftNote, res, err := a.client.CreateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt) + + if err != nil { + handleError(w, err, "Could not create draft note", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not create draft note", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) + response := DraftNoteResponse{ + SuccessResponse: SuccessResponse{ + Message: "Draft note created successfully", + Status: http.StatusOK, + }, + DraftNote: draftNote, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} + +/* deleteDraftNote deletes a draft note */ +func (a *api) deleteDraftNote(w http.ResponseWriter, r *http.Request) { + suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/") + id, err := strconv.Atoi(suffix) + if err != nil { + handleError(w, err, "Could not parse draft note ID", http.StatusBadRequest) + return + } + + res, err := a.client.DeleteDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id) + + if err != nil { + handleError(w, err, "Could not delete draft note", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not delete draft note", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) + response := SuccessResponse{ + Message: "Draft note deleted", + Status: http.StatusOK, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} + +/* updateDraftNote edits the text of a draft comment */ +func (a *api) updateDraftNote(w http.ResponseWriter, r *http.Request) { + suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/") + id, err := strconv.Atoi(suffix) + if err != nil { + handleError(w, err, "Could not parse draft note ID", http.StatusBadRequest) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + defer r.Body.Close() + + var updateDraftNoteRequest UpdateDraftNoteRequest + err = json.Unmarshal(body, &updateDraftNoteRequest) + if err != nil { + handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) + return + } + + if updateDraftNoteRequest.Note == "" { + handleError(w, errors.New("Draft note text missing"), "Must provide draft note text", http.StatusBadRequest) + return + } + + opt := gitlab.UpdateDraftNoteOptions{ + Note: &updateDraftNoteRequest.Note, + } + + draftNote, res, err := a.client.UpdateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id, &opt) + + if err != nil { + handleError(w, err, "Could not update draft note", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not update draft note", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) + response := DraftNoteResponse{ + SuccessResponse: SuccessResponse{ + Message: "Draft note updated", + Status: http.StatusOK, + }, + DraftNote: draftNote, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} + +/* listDraftNotes lists all draft notes for the currently authenticated user */ +func (a *api) listDraftNotes(w http.ResponseWriter, r *http.Request) { + + opt := gitlab.ListDraftNotesOptions{} + draftNotes, res, err := a.client.ListDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt) + + if err != nil { + handleError(w, err, "Could not get draft notes", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/draft/comment"}, "Could not get draft notes", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) + response := ListDraftNotesResponse{ + SuccessResponse: SuccessResponse{ + Message: "Draft notes fetched successfully", + Status: http.StatusOK, + }, + DraftNotes: draftNotes, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} diff --git a/cmd/draft_notes_test.go b/cmd/draft_notes_test.go new file mode 100644 index 00000000..ed7cc5ac --- /dev/null +++ b/cmd/draft_notes_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "errors" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +func listDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) { + return []*gitlab.DraftNote{}, makeResponse(http.StatusOK), nil +} + +func listDraftNotesErr(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) { + return nil, makeResponse(http.StatusInternalServerError), errors.New("Some error") +} + +func TestListDraftNotes(t *testing.T) { + t.Run("Lists all draft notes", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) + server, _ := createRouterAndApi(fakeClient{listDraftNotes: listDraftNotes}) + data := serveRequest(t, server, request, ListDraftNotesResponse{}) + + assert(t, data.SuccessResponse.Message, "Draft notes fetched successfully") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Handles error", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) + server, _ := createRouterAndApi(fakeClient{listDraftNotes: listDraftNotesErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + + assert(t, data.Message, "Could not get draft notes") + assert(t, data.Status, http.StatusInternalServerError) + assert(t, data.Details, "Some error") + }) +} + +func createDraftNote(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + return &gitlab.DraftNote{}, makeResponse(http.StatusOK), nil +} + +func createDraftNoteErr(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + return nil, makeResponse(http.StatusInternalServerError), errors.New("Some error") +} + +func TestPostDraftNote(t *testing.T) { + t.Run("Posts new draft note", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", PostDraftNoteRequest{}) + server, _ := createRouterAndApi(fakeClient{createDraftNote: createDraftNote}) + + data := serveRequest(t, server, request, DraftNoteResponse{}) + + assert(t, data.SuccessResponse.Message, "Draft note created successfully") + assert(t, data.SuccessResponse.Status, http.StatusOK) + }) + + t.Run("Handles errors on draft note creation", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", PostDraftNoteRequest{}) + server, _ := createRouterAndApi(fakeClient{createDraftNote: createDraftNoteErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not create draft note") + assert(t, data.Status, http.StatusInternalServerError) + assert(t, data.Details, "Some error") + }) +} + +func deleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return makeResponse(http.StatusOK), nil +} + +func deleteDraftNoteErr(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return makeResponse(http.StatusInternalServerError), errors.New("Something went wrong") +} + +func TestDeleteDraftNote(t *testing.T) { + t.Run("Deletes draft note", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil) + server, _ := createRouterAndApi(fakeClient{deleteDraftNote: deleteDraftNote}) + data := serveRequest(t, server, request, SuccessResponse{}) + assert(t, data.Message, "Draft note deleted") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Handles error", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil) + server, _ := createRouterAndApi(fakeClient{deleteDraftNote: deleteDraftNoteErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not delete draft note") + assert(t, data.Status, http.StatusInternalServerError) + }) + + t.Run("Handles bad ID", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/abc", nil) + server, _ := createRouterAndApi(fakeClient{deleteDraftNote: deleteDraftNote}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not parse draft note ID") + assert(t, data.Status, http.StatusBadRequest) + }) +} + +func updateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + return &gitlab.DraftNote{}, makeResponse(http.StatusOK), nil +} + +func TestEditDraftNote(t *testing.T) { + t.Run("Edits draft note", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", UpdateDraftNoteRequest{Note: "Some new note"}) + server, _ := createRouterAndApi(fakeClient{updateDraftNote: updateDraftNote}) + data := serveRequest(t, server, request, SuccessResponse{}) + assert(t, data.Message, "Draft note updated") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Handles bad ID", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/abc", nil) + server, _ := createRouterAndApi(fakeClient{updateDraftNote: updateDraftNote}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not parse draft note ID") + assert(t, data.Status, http.StatusBadRequest) + }) + + t.Run("Handles empty note", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", UpdateDraftNoteRequest{Note: ""}) + server, _ := createRouterAndApi(fakeClient{updateDraftNote: updateDraftNote}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Must provide draft note text") + assert(t, data.Status, http.StatusBadRequest) + }) +} + +func publishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return makeResponse(http.StatusOK), nil +} + +func publishDraftNoteErr(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return nil, errors.New("Some error") +} + +func TestPublishDraftNote(t *testing.T) { + t.Run("Should publish a draft note", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{Note: 3, PublishAll: false}) + server, _ := createRouterAndApi(fakeClient{ + publishDraftNote: publishDraftNote, + }) + data := serveRequest(t, server, request, SuccessResponse{}) + assert(t, data.Message, "Draft note(s) published") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Handles bad/missing ID", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: false}) + server, _ := createRouterAndApi(fakeClient{publishDraftNote: publishDraftNote}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Must provide Note ID") + assert(t, data.Status, http.StatusBadRequest) + }) + + t.Run("Handles error", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: false, Note: 3}) + server, _ := createRouterAndApi(fakeClient{publishDraftNote: publishDraftNoteErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not publish draft note(s)") + assert(t, data.Status, http.StatusInternalServerError) + }) +} + +func publishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return makeResponse(http.StatusOK), nil +} + +func publishAllDraftNotesErr(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return nil, errors.New("Some error") +} + +func TestPublishAllDraftNotes(t *testing.T) { + t.Run("Should publish all draft notes", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: true}) + server, _ := createRouterAndApi(fakeClient{ + publishAllDraftNotes: publishAllDraftNotes, + }) + data := serveRequest(t, server, request, SuccessResponse{}) + assert(t, data.Message, "Draft note(s) published") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Should handle an error", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: true}) + server, _ := createRouterAndApi(fakeClient{ + publishAllDraftNotes: publishAllDraftNotesErr, + }) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not publish draft note(s)") + assert(t, data.Status, http.StatusInternalServerError) + }) +} diff --git a/cmd/server.go b/cmd/server.go index c38d78a3..7c102416 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -134,6 +134,8 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv m.HandleFunc("/mr/label", a.withMr(a.labelHandler)) m.HandleFunc("/mr/revoke", a.withMr(a.revokeHandler)) m.HandleFunc("/mr/awardable/note/", a.withMr(a.emojiNoteHandler)) + m.HandleFunc("/mr/draft_notes/", a.withMr(a.draftNoteHandler)) + m.HandleFunc("/mr/draft_notes/publish", a.withMr(a.draftNotePublisher)) m.HandleFunc("/pipeline", a.pipelineHandler) m.HandleFunc("/pipeline/trigger/", a.pipelineHandler) diff --git a/cmd/test.go b/cmd/test.go index 29430a2d..be791892 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -41,6 +41,12 @@ type fakeClient struct { listMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) deleteMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID, awardID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) currentUser func(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) + createDraftNote func(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) + listDraftNotes func(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) + deleteDraftNote func(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + updateDraftNote func(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) + publishAllDraftNotes func(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + publishDraftNote func(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) } type Author struct { @@ -141,6 +147,14 @@ func (f fakeClient) DeleteMergeRequestAwardEmojiOnNote(pid interface{}, mergeReq return f.deleteMergeRequestAwardEmojiOnNote(pid, mergeRequestIID, noteID, awardID) } +func (f fakeClient) CreateDraftNote(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + return f.createDraftNote(pid, mergeRequestIID, opt) +} + +func (f fakeClient) ListDraftNotes(pid interface{}, mergeRequestIID int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) { + return f.listDraftNotes(pid, mergeRequestIID, opt) +} + func (f fakeClient) CurrentUser(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) { return f.currentUser() } @@ -154,6 +168,22 @@ func (f fakeClient) CreateMergeRequestAwardEmojiOnNote(pid interface{}, mergeReq return &gitlab.AwardEmoji{}, &gitlab.Response{}, nil } +func (f fakeClient) UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + return f.updateDraftNote(pid, mergeRequest, note, opt) +} + +func (f fakeClient) DeleteDraftNote(pid interface{}, mergeRequestIID int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return f.deleteDraftNote(pid, mergeRequestIID, note) +} + +func (f fakeClient) PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return f.publishDraftNote(pid, mergeRequest, note) +} + +func (f fakeClient) PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return f.publishAllDraftNotes(pid, mergeRequest) +} + /* The assert function is a helper function used to check two comparables */ func assert[T comparable](t *testing.T, got T, want T) { t.Helper() diff --git a/cmd/types.go b/cmd/types.go index 9434577b..25bd4088 100644 --- a/cmd/types.go +++ b/cmd/types.go @@ -49,6 +49,12 @@ type ClientInterface interface { CreateMergeRequestDiscussion(pid interface{}, mergeRequestIID int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) DeleteMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + CreateDraftNote(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) + ListDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) + DeleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) + PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) AddMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 003fbcfb..5976dd9c 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -189,6 +189,7 @@ you call this function with no values the defaults will be used: toggle_unresolved_discussions = "U", -- Open or close all unresolved discussions keep_current_open = false, -- If true, current discussion stays open even if it should otherwise be closed when toggling toggle_resolved = "p" -- Toggles the resolved status of the whole discussion + publish_draft = "P", -- Publishes the currently focused note/comment position = "left", -- "top", "right", "bottom" or "left" open_in_browser = "b" -- Jump to the URL of the current note/discussion copy_node_url = "u", -- Copy the URL of the current node to clipboard @@ -201,6 +202,9 @@ you call this function with no values the defaults will be used: winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) -- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar. }, + comments = { + default_to_draft = false, -- Whether to default a comment to a "draft" or not in the popup + }, info = { -- Show additional fields in the summary view enabled = true, horizontal = false, -- Display metadata to the left of the summary rather than underneath @@ -311,6 +315,16 @@ code block with prefilled code from the visual selection. Just like the summary, all the different kinds of comments are saved via the `settings.popup.perform_action` keybinding. +DRAFT NOTES *gitlab.nvim.draft-comments* + +When you publish a "draft" of any of the above resources (configurable via the +`state.settings.comments.default_to_draft` setting) the comment will be added +to a review. You may publish all draft comments via the `gitlab.publish_all_drafts()` +function, and you can publish an individual comment or note by pressing the +`state.settings.discussion_tree.publish_draft` keybinding. + +Draft notes do not support editing, replying, or emojis. + TEMPORARY REGISTERS *gitlab.nvim.temp-registers* While writing a note/comment/suggestion/reply, you may need to interrupt the @@ -541,6 +555,7 @@ in normal mode): vim.keymap.set("n", "glo", gitlab.open_in_browser) vim.keymap.set("n", "glM", gitlab.merge) vim.keymap.set("n", "glu", gitlab.copy_mr_url) + vim.keymap.set("n", "glP", gitlab.publish_all_drafts) < TROUBLESHOOTING *gitlab.nvim.troubleshooting* @@ -722,6 +737,14 @@ Once the discussion tree is open, a number of different keybindings are availabl for interacting with different discussions. Please see the `settings.discussion_tree` section of the setup call for more information about different keybindings. + *gitlab.nvim.publish_all_drafts* +gitlab.publish_all_drafts() ~ + +Publishes all unpublished draft notes. Used to finish a review and make all notes and +comments visible. +>lua + require("gitlab").publish_all_drafts() +< *gitlab.nvim.add_assignee* gitlab.add_assignee() ~ @@ -843,6 +866,7 @@ execute and passed the data as an argument. • "pipeline": Information about the current branch's pipeline. Returns and object with `latest_pipeline` and `jobs` as fields. + • "draft_notes": The current user's unpublished notes • {refresh}: (bool) Whether to re-fetch the data from Gitlab or use the cached data locally, if available. • {cb}: (function) The callback function that runs after all of the diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 1416822b..1598d1af 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -1,26 +1,152 @@ --- 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 +--- 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 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 = {} --- Popup creation is wrapped in a function so that it is performed *after* user --- configuration has been merged with default configuration, not when this file is being --- required. -local function create_comment_popup() - return Popup(u.create_popup_state("Comment", state.settings.popup.comment)) +local M = { + current_win = nil, + start_line = nil, + end_line = nil, +} + +---Fires the API that sends the comment data to the Go server, called when you "confirm" creation +---via the M.settings.popup.perform_action keybinding +---@param text string comment text +---@param visual_range LineRange | nil range of visual selection or nil +---@param unlinked boolean | nil if true, the comment is not linked to a line +local confirm_create_comment = function(text, visual_range, unlinked) + 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)) + if unlinked then + local body = { comment = text } + local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment" + job.run_job(endpoint, "POST", body, function(data) + u.notify(is_draft and "Draft note created!" or "Note created!", vim.log.levels.INFO) + if is_draft then + draft_notes.add_draft_note({ draft_note = data.draft_note, unlinked = true }) + else + discussions.add_discussion({ data = data, unlinked = true }) + end + discussions.refresh() + 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 body = { + type = "text", + comment = text, + 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, + } + + local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment" + job.run_job(endpoint, "POST", body, function(data) + u.notify(is_draft and "Draft comment created!" or "Comment created!", vim.log.levels.INFO) + if is_draft then + draft_notes.add_draft_note({ draft_note = data.draft_note, unlinked = false }) + else + discussions.add_discussion({ data = data, has_position = true }) + end + discussions.refresh() + end) end --- This function will open a comment popup in order to create a comment on the changed/updated --- line in the current MR +---@class LayoutOpts +---@field ranged boolean +---@field unlinked boolean + +---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|nil +---@return NuiLayout +local function create_comment_layout(opts) + if opts == nil then + opts = {} + end + + M.comment_popup = Popup(u.create_popup_state("Comment", state.settings.popup.comment)) + M.draft_popup = Popup(u.create_box_popup_state("Draft", false)) + M.start_line, M.end_line = u.get_visual_selection_boundaries() + M.current_win = vim.api.nvim_get_current_win() + + 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) + + local popup_opts = { + action_before_close = true, + action_before_exit = false, + } + + 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 + + state.set_popup_keymaps(M.draft_popup, function() + local text = u.get_buffer_text(M.comment_popup.bufnr) + confirm_create_comment(text, range, unlinked) + end, miscellaneous.toggle_bool, popup_opts) + + state.set_popup_keymaps(M.comment_popup, function(text) + confirm_create_comment(text, range, unlinked) + end, miscellaneous.attach_file, popup_opts) + + vim.schedule(function() + local default_to_draft = state.settings.comments.default_to_draft + vim.api.nvim_buf_set_lines(M.draft_popup.bufnr, 0, -1, false, { u.bool_to_string(default_to_draft) }) + 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 = git.has_clean_tree() local is_modified = vim.api.nvim_buf_get_option(0, "modified") @@ -31,38 +157,40 @@ M.create_comment = function() ) return end - local comment_popup = create_comment_popup() - comment_popup:mount() - state.set_popup_keymaps(comment_popup, function(text) - M.confirm_create_comment(text) - end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) + + local layout = create_comment_layout() + layout:mount() end ----Create multiline comment for the last selection. +--- 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 - local comment_popup = create_comment_popup() - local start_line, end_line = u.get_visual_selection_boundaries() - comment_popup:mount() - state.set_popup_keymaps(comment_popup, function(text) - M.confirm_create_comment(text, { start_line = start_line, end_line = end_line }) - end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) + + local layout = create_comment_layout({ ranged = true, unlinked = false }) + layout:mount() end ----Create comment prepopulated with gitlab suggestion ----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 - local comment_popup = create_comment_popup() - local start_line, end_line = u.get_visual_selection_boundaries() +--- 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 = create_comment_layout({ ranged = false, 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 +---@return integer +local build_suggestion = function() local current_line = vim.api.nvim_win_get_cursor(0)[1] - local range = end_line - start_line + 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(start_line, end_line) + local selected_lines = u.get_lines(M.start_line, M.end_line) for line in ipairs(selected_lines) do if string.match(line, "^```$") then @@ -72,14 +200,14 @@ M.create_comment_suggestion = function() end local suggestion_start - if start_line == current_line then - suggestion_start = backticks .. "suggestion:-0+" .. range - elseif end_line == current_line then - suggestion_start = backticks .. "suggestion:-" .. range .. "+0" + 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 + --- This should never happen afaik u.notify("Unexpected suggestion position", vim.log.levels.ERROR) - return + return nil, 0 end suggestion_start = suggestion_start local suggestion_lines = {} @@ -87,76 +215,25 @@ M.create_comment_suggestion = function() vim.list_extend(suggestion_lines, selected_lines) table.insert(suggestion_lines, backticks) - comment_popup:mount() - vim.api.nvim_buf_set_lines(comment_popup.bufnr, 0, -1, false, suggestion_lines) - state.set_popup_keymaps(comment_popup, function(text) - if range > 0 then - M.confirm_create_comment(text, { start_line = start_line, end_line = end_line }) - else - M.confirm_create_comment(text, nil) - end - end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) -end - -M.create_note = function() - local note_popup = Popup(u.create_popup_state("Note", state.settings.popup.note)) - note_popup:mount() - state.set_popup_keymaps(note_popup, function(text) - M.confirm_create_comment(text, nil, true) - end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) + return suggestion_lines, range_length end ----This function (settings.popup.perform_action) will send the comment to the Go server ----@param text string comment text ----@param visual_range LineRange | nil range of visual selection or nil ----@param unlinked boolean | nil if true, the comment is not linked to a line -M.confirm_create_comment = function(text, visual_range, unlinked) - if text == nil then - u.notify("Reviewer did not provide text of change", vim.log.levels.ERROR) - return - end - - if unlinked then - local body = { comment = text } - job.run_job("/mr/comment", "POST", body, function(data) - u.notify("Note created!", vim.log.levels.INFO) - discussions.add_discussion({ data = data, unlinked = true }) - discussions.refresh() - 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) +--- 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 - local revision = state.MR_REVISIONS[1] - local body = { - type = "text", - comment = text, - 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, - } + local suggestion_lines, range_length = build_suggestion() - job.run_job("/mr/comment", "POST", body, function(data) - u.notify("Comment created!", vim.log.levels.INFO) - discussions.add_discussion({ data = data, unlinked = false }) - discussions.refresh() + local layout = create_comment_layout({ ranged = range_length > 0, 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 diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua new file mode 100644 index 00000000..ec961356 --- /dev/null +++ b/lua/gitlab/actions/common.lua @@ -0,0 +1,229 @@ +-- This module contains code shared between at least two modules. This includes +-- actions common to multiple tree types, as well as general utility functions +-- that are specific to actions (like jumping to a file or opening a URL) +local u = require("gitlab.utils") +local reviewer = require("gitlab.reviewer") +local common_indicators = require("gitlab.indicators.common") +local state = require("gitlab.state") +local M = {} + +---Build note header from note +---@param note Note|DraftNote +---@return string +M.build_note_header = function(note) + if note.note then + return "@" .. state.USER.username .. " " .. "" + end + return "@" .. note.author.username .. " " .. u.time_since(note.created_at) +end + +M.switch_can_edit_bufs = function(bool, ...) + local bufnrs = { ... } + ---@param v integer + for _, v in ipairs(bufnrs) do + u.switch_can_edit_buf(v, bool) + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = v }) + end +end + +---Takes in a chunk of text separated by new line characters and returns a lua table +---@param content string +---@return table +M.build_content = function(content) + local description_lines = {} + for line in u.split_by_new_lines(content) do + table.insert(description_lines, line) + end + table.insert(description_lines, "") + return description_lines +end + +---@class TitleArg +---@field bufnr integer +---@field title string +---@field data table + +---@param title_args TitleArg[] +M.add_empty_titles = function(title_args) + for _, v in ipairs(title_args) do + M.switch_can_edit_bufs(true, v.bufnr) + local ns_id = vim.api.nvim_create_namespace("GitlabNamespace") + vim.cmd("highlight default TitleHighlight guifg=#787878") + + -- Set empty title if applicable + if type(v.data) ~= "table" or #v.data == 0 then + vim.api.nvim_buf_set_lines(v.bufnr, 0, 1, false, { v.title }) + local linnr = 1 + vim.api.nvim_buf_set_extmark( + v.bufnr, + ns_id, + linnr - 1, + 0, + { end_row = linnr - 1, end_col = string.len(v.title), hl_group = "TitleHighlight" } + ) + end + end +end + +---@param tree NuiTree +M.get_url = function(tree) + local current_node = tree:get_node() + local note_node = M.get_note_node(tree, current_node) + if note_node == nil then + return + end + local url = note_node.url + if url == nil then + u.notify("Could not get URL of note", vim.log.levels.ERROR) + return + end + return url +end + +---@param tree NuiTree +M.open_in_browser = function(tree) + local url = M.get_url(tree) + if url ~= nil then + u.open_in_browser(url) + end +end + +---@param tree NuiTree +M.copy_node_url = function(tree) + local url = M.get_url(tree) + if url == nil then + vim.fn.setreg("+", url) + u.notify("Copied '" .. url .. "' to clipboard", vim.log.levels.INFO) + end +end + +-- For developers! +M.print_node = function(tree) + local current_node = tree:get_node() + vim.print(current_node) +end + +---Check if type of node is note or note body +---@param node NuiTree.Node? +---@return boolean +M.is_node_note = function(node) + if node and (node.type == "note_body" or node.type == "note") then + return true + else + return false + end +end + +---Get root node +---@param tree NuiTree +---@param node NuiTree.Node? +---@return NuiTree.Node? +M.get_root_node = function(tree, node) + if not node then + return nil + end + if node.type == "note_body" or node.type == "note" and not node.is_root then + local parent_id = node:get_parent_id() + return M.get_root_node(tree, tree:get_node(parent_id)) + elseif node.is_root then + return node + end +end + +---Get note node +---@param tree NuiTree +---@param node NuiTree.Node? +---@return NuiTree.Node? +M.get_note_node = function(tree, node) + if not node then + return nil + end + + if node.type == "note_body" then + local parent_id = node:get_parent_id() + if parent_id == nil then + return node + end + return M.get_note_node(tree, tree:get_node(parent_id)) + elseif node.type == "note" then + return node + end +end + +---Takes a node and returns the line where the note is positioned in the new SHA. If +---the line is not in the new SHA, returns nil +---@param node any +---@return number|nil +local function get_new_line(node) + ---@type GitlabLineRange|nil + local range = node.range + if range == nil then + return node.new_line + end + + local _, start_new_line = common_indicators.parse_line_code(range.start.line_code) + return start_new_line +end + +---Takes a node and returns the line where the note is positioned in the old SHA. If +---the line is not in the old SHA, returns nil +---@param node any +---@return number|nil +local function get_old_line(node) + ---@type GitlabLineRange|nil + local range = node.range + if range == nil then + return node.old_line + end + + local start_old_line, _ = common_indicators.parse_line_code(range.start.line_code) + return start_old_line +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(tree, callback) + local node = tree:get_node() + local root_node = M.get_root_node(tree, node) + if root_node == nil then + u.notify("Could not get discussion node", vim.log.levels.ERROR) + return + end + local line_number = (root_node.new_line or root_node.old_line or 1) + if root_node.range then + local start_old_line, start_new_line = common_indicators.parse_line_code(root_node.range.start.line_code) + line_number = root_node.old_line and start_old_line or start_new_line + end + reviewer.jump(root_node.file_name, line_number, root_node.old_line == nil) + callback() +end + +-- This function (settings.discussion_tree.jump_to_file) will jump to the file changed in a new tab +M.jump_to_file = function(tree) + local node = tree:get_node() + local root_node = M.get_root_node(tree, node) + if root_node == nil then + u.notify("Could not get discussion node", vim.log.levels.ERROR) + return + end + if root_node.file_name == nil then + u.notify("This comment was not left on a particular location", vim.log.levels.WARN) + return + end + vim.cmd.tabnew() + local line_number = get_new_line(root_node) or get_old_line(root_node) + if line_number == nil then + line_number = 1 + end + local bufnr = vim.fn.bufnr(root_node.file_name) + if bufnr ~= -1 then + vim.cmd("buffer " .. bufnr) + vim.api.nvim_win_set_cursor(0, { line_number, 0 }) + return + end + + -- If buffer is not already open, open it + vim.cmd("edit " .. root_node.file_name) + vim.api.nvim_win_set_cursor(0, { line_number, 0 }) +end + +return M diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua index 41b68f27..5c82936c 100644 --- a/lua/gitlab/actions/create_mr.lua +++ b/lua/gitlab/actions/create_mr.lua @@ -7,6 +7,7 @@ local job = require("gitlab.job") local u = require("gitlab.utils") local git = require("gitlab.git") local state = require("gitlab.state") +local common = require("gitlab.actions.common") local miscellaneous = require("gitlab.actions.miscellaneous") ---@class Mr @@ -202,7 +203,7 @@ M.open_confirmation_popup = function(mr) M.layout_visible = false end - local description_lines = mr.description and M.build_description_lines(mr.description) or { "" } + local description_lines = mr.description and common.build_content(mr.description) or { "" } local delete_branch = u.get_first_non_nil_value({ mr.delete_branch, state.settings.create_mr.delete_branch }) local squash = u.get_first_non_nil_value({ mr.squash, state.settings.create_mr.squash }) @@ -234,18 +235,6 @@ M.open_confirmation_popup = function(mr) end) end ----Builds a lua list of strings that contain the MR description -M.build_description_lines = function(template_content) - local description_lines = {} - for line in u.split_by_new_lines(template_content) do - table.insert(description_lines, line) - end - -- TODO: @harrisoncramer Same as in lua/gitlab/actions/summary.lua:114 - table.insert(description_lines, "") - - return description_lines -end - ---Prompts for interactive selection of a new target among remote-tracking branches M.select_new_target = function() local bufnr = vim.api.nvim_get_current_buf() diff --git a/lua/gitlab/actions/data.lua b/lua/gitlab/actions/data.lua index 3f1d98f3..55aa7948 100644 --- a/lua/gitlab/actions/data.lua +++ b/lua/gitlab/actions/data.lua @@ -9,6 +9,7 @@ local labels = state.dependencies.labels local project_members = state.dependencies.project_members local revisions = state.dependencies.revisions local latest_pipeline = state.dependencies.latest_pipeline +local draft_notes = state.dependencies.draft_notes M.data = function(resources, cb) if type(resources) ~= "table" or type(cb) ~= "function" then @@ -23,6 +24,7 @@ M.data = function(resources, cb) project_members = project_members, revisions = revisions, pipeline = latest_pipeline, + draft_notes = draft_notes, } local api_calls = {} diff --git a/lua/gitlab/actions/discussions/annotations.lua b/lua/gitlab/actions/discussions/annotations.lua index 92bbf4e5..408cffe9 100644 --- a/lua/gitlab/actions/discussions/annotations.lua +++ b/lua/gitlab/actions/discussions/annotations.lua @@ -79,9 +79,11 @@ ---@field moji string ---@class WinbarTable ----@field name string +---@field view_type string ---@field resolvable_discussions number ---@field resolved_discussions number +---@field inline_draft_notes number +---@field unlinked_draft_notes number ---@field resolvable_notes number ---@field resolved_notes number ---@field help_keymap string @@ -120,3 +122,14 @@ ---@field old_line integer | nil ---@field new_line integer | nil ---@field line_range ReviewerRangeInfo|nil + +---@class DraftNote +---@field note string +---@field id integer +---@field author_id integer +---@field merge_request_id integer +---@field resolve_discussion boolean +---@field discussion_id string -- This will always be "" +---@field commit_id string -- This will always be "" +---@field line_code string +---@field position NotePosition diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 67f26c64..9a0dcad6 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -1,19 +1,21 @@ --- 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. +-- This module is responsible for the notes and comments discussion tree. +-- That includes things like editing existing notes in the tree, +-- replying to notes in the tree, and marking discussions as resolved/unresolved. +-- Draft notes are managed separately, under lua/gitlab/actions/draft_notes/init.lua local Split = require("nui.split") local Popup = require("nui.popup") local NuiTree = require("nui.tree") -local NuiLine = require("nui.line") local job = require("gitlab.job") local u = require("gitlab.utils") local state = require("gitlab.state") local reviewer = require("gitlab.reviewer") +local common = require("gitlab.actions.common") local List = require("gitlab.utils.list") +local tree_utils = require("gitlab.actions.discussions.tree") local miscellaneous = require("gitlab.actions.miscellaneous") local discussions_tree = require("gitlab.actions.discussions.tree") +local draft_notes = require("gitlab.actions.draft_notes") local diffview_lib = require("diffview.lib") -local common = require("gitlab.indicators.common") local signs = require("gitlab.indicators.signs") local diagnostics = require("gitlab.indicators.diagnostics") local winbar = require("gitlab.actions.discussions.winbar") @@ -24,32 +26,23 @@ local M = { split_visible = false, split = nil, ---@type number - split_bufnr = nil, - ---@type Discussion[] - discussions = {}, - ---@type UnlinkedDiscussion[] - unlinked_discussions = {}, - ---@type EmojiMap - emojis = {}, - ---@type number linked_bufnr = nil, ---@type number unlinked_bufnr = nil, ---@type number - focused_bufnr = nil, discussion_tree = nil, } ----Makes API call to get the discussion data, store it in M.discussions and M.unlinked_discussions and call ----callback with data ----@param callback (fun(data: DiscussionData): nil)? +---Makes API call to get the discussion data, stores it in the state, and calls the callback +---@param callback function|nil M.load_discussions = function(callback) job.run_job("/mr/discussions/list", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data) - M.discussions = data.discussions ~= vim.NIL and data.discussions or {} - M.unlinked_discussions = data.unlinked_discussions ~= vim.NIL and data.unlinked_discussions or {} - M.emojis = data.emojis or {} + state.DISCUSSION_DATA.discussions = data.discussions ~= vim.NIL and data.discussions or {} + state.DISCUSSION_DATA.unlinked_discussions = data.unlinked_discussions ~= vim.NIL and data.unlinked_discussions + or {} + state.DISCUSSION_DATA.emojis = data.emojis ~= vim.NIL and data.emojis or {} if type(callback) == "function" then - callback(data) + callback() end end) end @@ -87,36 +80,23 @@ end ---Refresh discussion data, signs, diagnostics, and winbar with new data from API --- and rebuild the entire view -M.refresh = function() +M.refresh = function(cb) M.load_discussions(function() M.refresh_view() + if cb ~= nil then + cb() + end end) end --- Take existing data and refresh the diagnostics, the winbar, and the signs M.refresh_view = function() - if state.settings.discussion_signs.enabled then - diagnostics.refresh_diagnostics(M.discussions) - end - if M.split_visible then - local linked_is_focused = M.linked_bufnr == M.focused_bufnr - winbar.update_winbar(M.discussions, M.unlinked_discussions, linked_is_focused and "Discussions" or "Notes") - end -end - ----Toggle Discussions tree type between "simple" and "by_file_name" ----@param unlinked boolean True if selected view type is Notes (unlinked discussions) -M.toggle_tree_type = function(unlinked) - if unlinked then - u.notify("Toggling tree type is only possible in Discussions", vim.log.levels.INFO) - return + if state.settings.discussion_signs.enabled and state.DISCUSSION_DATA then + diagnostics.refresh_diagnostics() end - if state.settings.discussion_tree.tree_type == "simple" then - state.settings.discussion_tree.tree_type = "by_file_name" - else - state.settings.discussion_tree.tree_type = "simple" + if M.split_visible and state.DISCUSSION_DATA then + winbar.update_winbar() end - M.rebuild_discussion_tree() end ---Opens the discussion tree, sets the keybindings. It also @@ -128,64 +108,69 @@ M.toggle = function(callback) return end + if + type(state.DISCUSSION_DATA.discussions) ~= "table" + and type(state.DISCUSSION_DATA.unlinked_discussions) ~= "table" + and type(state.DISCUSSION_DATA.draft_notes) ~= "table" + then + u.notify("No discussions, notes, or draft notes for this MR", vim.log.levels.WARN) + vim.api.nvim_buf_set_lines(M.split.bufnr, 0, -1, false, { "" }) + return + end + + -- Make buffers, get and set buffer numbers, set filetypes local split, linked_bufnr, unlinked_bufnr = M.create_split_and_bufs() + M.split = split M.linked_bufnr = linked_bufnr M.unlinked_bufnr = unlinked_bufnr + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.split.bufnr }) + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.unlinked_bufnr }) + vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_bufnr }) + M.split = split M.split_visible = true - M.split_bufnr = split.bufnr split:mount() - M.switch_can_edit_bufs(true) - - vim.api.nvim_buf_set_lines(split.bufnr, 0, -1, false, { "Loading data..." }) - vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.split_bufnr }) - vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.unlinked_bufnr }) - vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_bufnr }) - local default_discussions = state.settings.discussion_tree.default_view == "discussions" - winbar.update_winbar({}, {}, default_discussions and "Discussions" or "Notes") + -- Initialize winbar module with data from buffers + winbar.set_buffers(M.linked_bufnr, M.unlinked_bufnr) + winbar.switch_view_type(state.settings.discussion_tree.default_view) - M.load_discussions(function() - if type(M.discussions) ~= "table" and type(M.unlinked_discussions) ~= "table" then - u.notify("No discussions or notes for this MR", vim.log.levels.WARN) - vim.api.nvim_buf_set_lines(split.bufnr, 0, -1, false, { "" }) - return - end + local current_window = vim.api.nvim_get_current_win() -- Save user's current window in case they switched while content was loading + vim.api.nvim_set_current_win(M.split.winid) - local current_window = vim.api.nvim_get_current_win() -- Save user's current window in case they switched while content was loading - vim.api.nvim_set_current_win(M.split.winid) + common.switch_can_edit_bufs(true, M.linked_bufnr, M.unliked_bufnr) + M.rebuild_discussion_tree() + M.rebuild_unlinked_discussion_tree() + + common.add_empty_titles({ + { + bufnr = M.linked_bufnr, + data = state.DISCUSSION_DATA.discussions, + title = "No Discussions for this MR", + }, + { + bufnr = M.unlinked_bufnr, + data = state.DISCUSSION_DATA.unlinked_discussions, + title = "No Notes (Unlinked Discussions) for this MR", + }, + }) - M.rebuild_discussion_tree() - M.rebuild_unlinked_discussion_tree() - M.add_empty_titles({ - { M.linked_bufnr, M.discussions, "No Discussions for this MR" }, - { M.unlinked_bufnr, M.unlinked_discussions, "No Notes (Unlinked Discussions) for this MR" }, - }) + -- Set default buffer + local default_buffer = winbar.bufnr_map[state.settings.discussion_tree.default_view] + vim.api.nvim_set_current_buf(default_buffer) + common.switch_can_edit_bufs(false, M.linked_bufnr, M.unlinked_bufnr) - local default_buffer = default_discussions and M.linked_bufnr or M.unlinked_bufnr - vim.api.nvim_set_current_buf(default_buffer) - M.focused_bufnr = default_buffer + vim.api.nvim_set_current_win(current_window) + if type(callback) == "function" then + callback() + end - M.switch_can_edit_bufs(false) + vim.schedule(function() M.refresh_view() - - vim.api.nvim_set_current_win(current_window) - if type(callback) == "function" then - callback() - end end) end --- Change between views in the discussion panel, either notes or discussions -local switch_view_type = function() - local change_to_unlinked = M.linked_bufnr == M.focused_bufnr - local new_bufnr = change_to_unlinked and M.unlinked_bufnr or M.linked_bufnr - vim.api.nvim_set_current_buf(new_bufnr) - winbar.update_winbar(M.discussions, M.unlinked_discussions, change_to_unlinked and "Notes" or "Discussions") - M.focused_bufnr = new_bufnr -end - -- Clears the discussion state and unmounts the split M.close = function() if M.split then @@ -253,7 +238,7 @@ end M.reply = function(tree) local reply_popup = Popup(u.create_popup_state("Reply", state.settings.popup.reply)) local node = tree:get_node() - local discussion_node = M.get_root_node(tree, node) + local discussion_node = common.get_root_node(tree, node) local id = tostring(discussion_node.id) reply_popup:mount() state.set_popup_keymaps( @@ -268,6 +253,7 @@ end M.send_reply = function(tree, discussion_id) return function(text) local body = { discussion_id = discussion_id, reply = text } + job.run_job("/mr/reply", "POST", body, function(data) u.notify("Sent reply!", vim.log.levels.INFO) M.add_reply_to_tree(tree, data.note, discussion_id) @@ -277,12 +263,12 @@ M.send_reply = function(tree, discussion_id) end -- This function (settings.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment -M.delete_comment = function(tree, unlinked) +M.delete_comment = function(tree) vim.ui.select({ "Confirm", "Cancel" }, { prompt = "Delete comment?", }, function(choice) if choice == "Confirm" then - M.send_deletion(tree, unlinked) + M.send_deletion(tree) end end) end @@ -292,29 +278,40 @@ end M.send_deletion = function(tree) 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) + local note_node = common.get_note_node(tree, current_node) + local root_node = common.get_root_node(tree, current_node) + if note_node == nil or root_node == nil then + u.notify("Could not get note or root node", vim.log.levels.ERROR) + return + end + + ---@type integer local note_id = note_node.is_root and root_node.root_note_id or note_node.id - local body = { discussion_id = root_node.id, note_id = tonumber(note_id) } - job.run_job("/mr/comment", "DELETE", body, function(data) - u.notify(data.message, vim.log.levels.INFO) - if note_node.is_root then - -- Replace root node w/ current node's contents... - tree:remove_node("-" .. root_node.id) - else - tree:remove_node("-" .. note_id) - end - tree:render() - M.refresh() - end) + + if root_node.is_draft then + draft_notes.send_deletion(tree) + else + local body = { discussion_id = root_node.id, note_id = tonumber(note_id) } + job.run_job("/mr/comment", "DELETE", body, function(data) + u.notify(data.message, vim.log.levels.INFO) + if note_node.is_root then + -- Replace root node w/ current node's contents... + tree:remove_node("-" .. root_node.id) + else + tree:remove_node("-" .. note_id) + end + tree:render() + M.refresh() + 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(tree, unlinked) local edit_popup = Popup(u.create_popup_state("Edit Comment", state.settings.popup.edit)) 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) + local note_node = common.get_note_node(tree, current_node) + local root_node = common.get_root_node(tree, current_node) if note_node == nil or root_node == nil then u.notify("Could not get root or note node", vim.log.levels.ERROR) return @@ -334,12 +331,18 @@ M.edit_comment = function(tree, unlinked) 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), tonumber(note_node.root_note_id or note_node.id), unlinked), - nil, - miscellaneous.editable_popup_opts - ) + + -- Draft notes module handles edits for draft notes + if root_node.is_draft then + state.set_popup_keymaps(edit_popup, draft_notes.send_edits(root_node.id), nil, miscellaneous.editable_popup_opts) + else + state.set_popup_keymaps( + edit_popup, + M.send_edits(tostring(root_node.id), tonumber(note_node.root_note_id or note_node.id), unlinked), + nil, + miscellaneous.editable_popup_opts + ) + end end ---This function sends the edited comment to the Go server @@ -355,12 +358,11 @@ M.send_edits = function(discussion_id, note_id, unlinked) } job.run_job("/mr/comment", "PATCH", body, function(data) u.notify(data.message, vim.log.levels.INFO) - M.rebuild_discussion_tree() if unlinked then - M.replace_text(M.unlinked_discussions, discussion_id, note_id, text) + M.replace_text(state.DISCUSSION_DATA.unlinked_discussions, discussion_id, note_id, text) M.rebuild_unlinked_discussion_tree() else - M.replace_text(M.discussions, discussion_id, note_id, text) + M.replace_text(state.DISCUSSION_DATA.discussions, discussion_id, note_id, text) M.rebuild_discussion_tree() end end) @@ -375,8 +377,8 @@ M.toggle_discussion_resolved = function(tree) end -- Switch to the root node to enable toggling from child nodes and note bodies - if not note.resolvable and M.is_node_note(note) then - note = M.get_root_node(tree, note) + if not note.resolvable and common.is_node_note(note) then + note = common.get_root_node(tree, note) end if note == nil then return @@ -394,330 +396,100 @@ M.toggle_discussion_resolved = function(tree) end) end ----Takes a node and returns the line where the note is positioned in the new SHA. If ----the line is not in the new SHA, returns nil ----@param node any ----@return number|nil -local function get_new_line(node) - ---@type GitlabLineRange|nil - local range = node.range - if range == nil then - return node.new_line - end - - local _, start_new_line = common.parse_line_code(range.start.line_code) - return start_new_line -end - ----Takes a node and returns the line where the note is positioned in the old SHA. If ----the line is not in the old SHA, returns nil ----@param node any ----@return number|nil -local function get_old_line(node) - ---@type GitlabLineRange|nil - local range = node.range - if range == nil then - return node.old_line - end - - local start_old_line, _ = common.parse_line_code(range.start.line_code) - return start_old_line -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(tree) - local node = tree:get_node() - local root_node = M.get_root_node(tree, node) - if root_node == nil then - u.notify("Could not get discussion node", vim.log.levels.ERROR) - return - end - local line_number = (root_node.new_line or root_node.old_line or 1) - if root_node.range then - local start_old_line, start_new_line = common.parse_line_code(root_node.range.start.line_code) - line_number = root_node.old_line and start_old_line or start_new_line - end - reviewer.jump(root_node.file_name, line_number, root_node.old_line == nil) - M.refresh_view() -end - --- This function (settings.discussion_tree.jump_to_file) will jump to the file changed in a new tab -M.jump_to_file = function(tree) - local node = tree:get_node() - local root_node = M.get_root_node(tree, node) - if root_node == nil then - u.notify("Could not get discussion node", vim.log.levels.ERROR) - return - end - vim.cmd.tabnew() - local line_number = get_new_line(root_node) or get_old_line(root_node) - if line_number == nil then - line_number = 1 - end - local bufnr = vim.fn.bufnr(root_node.file_name) - if bufnr ~= -1 then - vim.cmd("buffer " .. bufnr) - vim.api.nvim_win_set_cursor(0, { line_number, 0 }) - return - end - - -- If buffer is not already open, open it - vim.cmd("edit " .. root_node.file_name) - vim.api.nvim_win_set_cursor(0, { line_number, 0 }) -end - --- This function (settings.discussion_tree.toggle_node) expands/collapses the current node and its children -M.toggle_node = function(tree) - local node = tree:get_node() - if node == nil then - return - end - - -- Switch to the "note" node from "note_body" nodes to enable toggling discussions inside comments - if node.type == "note_body" then - node = tree:get_node(node:get_parent_id()) - end - 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() - if M.is_node_note(node) then - for _, child in ipairs(children) do - tree:get_node(child):collapse() - end - end - else - if M.is_node_note(node) then - for _, child in ipairs(children) do - tree:get_node(child):expand() - end - end - node:expand() - end - - tree:render() -end - ----@class ToggleNodesOptions ----@field toggle_resolved boolean Whether to toggle resolved discussions. ----@field toggle_unresolved boolean Whether to toggle unresolved discussions. ----@field keep_current_open boolean Whether to keep the current discussion open even if it should otherwise be closed. - ----This function (settings.discussion_tree.toggle_nodes) expands/collapses all nodes and their children according to the opts. ----@param tree NuiTree ----@param opts ToggleNodesOptions -M.toggle_nodes = function(tree, unlinked, opts) - local current_node = tree:get_node() - if current_node == nil then - return - end - local root_node = M.get_root_node(tree, current_node) - for _, node in ipairs(tree:get_nodes()) do - if opts.toggle_resolved then - if - (unlinked and state.unlinked_discussion_tree.resolved_expanded) - or (not unlinked and state.discussion_tree.resolved_expanded) - then - M.collapse_recursively(tree, node, root_node, opts.keep_current_open, true) - else - M.expand_recursively(tree, node, true) - end - end - if opts.toggle_unresolved then - if - (unlinked and state.unlinked_discussion_tree.unresolved_expanded) - or (not unlinked and state.discussion_tree.unresolved_expanded) - then - M.collapse_recursively(tree, node, root_node, opts.keep_current_open, false) - else - M.expand_recursively(tree, node, false) - end - end - end - -- Reset states of resolved discussions after toggling - if opts.toggle_resolved then - if unlinked then - state.unlinked_discussion_tree.resolved_expanded = not state.unlinked_discussion_tree.resolved_expanded - else - state.discussion_tree.resolved_expanded = not state.discussion_tree.resolved_expanded - end - end - -- Reset states of unresolved discussions after toggling - if opts.toggle_unresolved then - if unlinked then - state.unlinked_discussion_tree.unresolved_expanded = not state.unlinked_discussion_tree.unresolved_expanded - else - state.discussion_tree.unresolved_expanded = not state.discussion_tree.unresolved_expanded - end - end - tree:render() - M.restore_cursor_position(tree, current_node, root_node) -end - ----This function (settings.discussion_tree.collapse_recursively) collapses a node and its children. ----@param tree NuiTree ----@param node NuiTree.Node ----@param current_root_node NuiTree.Node The root node of the current node. ----@param keep_current_open boolean If true, the current node stays open, even if it should otherwise be collapsed. ----@param is_resolved boolean If true, collapse resolved discussions. If false, collapse unresolved discussions. -M.collapse_recursively = function(tree, node, current_root_node, keep_current_open, is_resolved) - if node == nil then - return - end - local root_node = M.get_root_node(tree, node) - if M.is_node_note(node) and root_node.resolved == is_resolved then - if keep_current_open and root_node == current_root_node then - return - end - node:collapse() - end - local children = node:get_child_ids() - for _, child in ipairs(children) do - M.collapse_recursively(tree, tree:get_node(child), current_root_node, keep_current_open, is_resolved) - end -end - ----This function (settings.discussion_tree.expand_recursively) expands a node and its children. ----@param tree NuiTree ----@param node NuiTree.Node ----@param is_resolved boolean If true, expand resolved discussions. If false, expand unresolved discussions. -M.expand_recursively = function(tree, node, is_resolved) - if node == nil then - return - end - if M.is_node_note(node) and M.get_root_node(tree, node).resolved == is_resolved then - node:expand() - end - local children = node:get_child_ids() - for _, child in ipairs(children) do - M.expand_recursively(tree, tree:get_node(child), is_resolved) - end -end - -- -- 🌲 Helper Functions -- ----Inspired by default func https://github.com/MunifTanjim/nui.nvim/blob/main/lua/nui/tree/util.lua#L38 -local function nui_tree_prepare_node(node) - if not node.text then - error("missing node.text") - end - - local texts = node.text - if type(node.text) ~= "table" or node.text.content then - texts = { node.text } - end - - local lines = {} - - for i, text in ipairs(texts) do - local line = NuiLine() - - line:append(string.rep(" ", node._depth - 1)) - - if i == 1 and node:has_children() then - line:append(node:is_expanded() and " " or " ") - if node.icon then - line:append(node.icon .. " ", node.icon_hl) - end - else - line:append(" ") - end - - line:append(text, node.text_hl) - - local note_id = tostring(node.is_root and node.root_note_id or node.id) - - local e = require("gitlab.emoji") - - ---@type Emoji[] - local emojis = M.emojis[note_id] - local placed_emojis = {} - if emojis ~= nil then - for _, v in ipairs(emojis) do - local icon = e.emoji_map[v.name] - if icon ~= nil and not u.contains(placed_emojis, icon.moji) then - line:append(" ") - line:append(icon.moji) - table.insert(placed_emojis, icon.moji) - end - end - end - - table.insert(lines, line) - end - - return lines -end +---Rebuilds the discussion tree, which contains all comments and draft comments +---linked to specific places in the code. M.rebuild_discussion_tree = function() if M.linked_bufnr == nil then return end - M.switch_can_edit_bufs(true) + common.switch_can_edit_bufs(true, M.linked_bufnr, M.unlinked_bufnr) vim.api.nvim_buf_set_lines(M.linked_bufnr, 0, -1, false, {}) - local discussion_tree_nodes = discussions_tree.add_discussions_to_table(M.discussions, false) - local discussion_tree = - NuiTree({ nodes = discussion_tree_nodes, bufnr = M.linked_bufnr, prepare_node = nui_tree_prepare_node }) + local existing_comment_nodes = discussions_tree.add_discussions_to_table(state.DISCUSSION_DATA.discussions, false) + local draft_comment_nodes = draft_notes.add_draft_notes_to_table(false) + + -- Combine inline draft notes with regular comments + local all_nodes = {} + for _, draft_node in ipairs(draft_comment_nodes) do + table.insert(all_nodes, draft_node) + end + for _, node in ipairs(existing_comment_nodes) do + table.insert(all_nodes, node) + end + + local discussion_tree = NuiTree({ + nodes = all_nodes, + bufnr = M.linked_bufnr, + prepare_node = tree_utils.nui_tree_prepare_node, + }) + discussion_tree:render() M.set_tree_keymaps(discussion_tree, M.linked_bufnr, false) M.discussion_tree = discussion_tree - M.switch_can_edit_bufs(false) + common.switch_can_edit_bufs(false, M.linked_bufnr, M.unlinked_bufnr) vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_bufnr }) state.discussion_tree.resolved_expanded = false state.discussion_tree.unresolved_expanded = false end +---Rebuilds the unlinked discussion tree, which contains all notes and draft notes. M.rebuild_unlinked_discussion_tree = function() if M.unlinked_bufnr == nil then return end - M.switch_can_edit_bufs(true) + common.switch_can_edit_bufs(true, M.linked_bufnr, M.unlinked_bufnr) vim.api.nvim_buf_set_lines(M.unlinked_bufnr, 0, -1, false, {}) - local unlinked_discussion_tree_nodes = discussions_tree.add_discussions_to_table(M.unlinked_discussions, true) + local existing_note_nodes = + discussions_tree.add_discussions_to_table(state.DISCUSSION_DATA.unlinked_discussions, true) + local draft_comment_nodes = draft_notes.add_draft_notes_to_table(true) + + -- Combine draft notes with regular notes + local all_nodes = {} + for _, draft_node in ipairs(draft_comment_nodes) do + table.insert(all_nodes, draft_node) + end + for _, node in ipairs(existing_note_nodes) do + table.insert(all_nodes, node) + end + local unlinked_discussion_tree = NuiTree({ - nodes = unlinked_discussion_tree_nodes, + nodes = all_nodes, bufnr = M.unlinked_bufnr, - prepare_node = nui_tree_prepare_node, + prepare_node = tree_utils.nui_tree_prepare_node, }) unlinked_discussion_tree:render() M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_bufnr, true) M.unlinked_discussion_tree = unlinked_discussion_tree - M.switch_can_edit_bufs(false) + common.switch_can_edit_bufs(false, M.linked_bufnr, M.unlinked_bufnr) state.unlinked_discussion_tree.resolved_expanded = false state.unlinked_discussion_tree.unresolved_expanded = false end -M.switch_can_edit_bufs = function(bool) - u.switch_can_edit_buf(M.unlinked_bufnr, bool) - u.switch_can_edit_buf(M.linked_bufnr, bool) - vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.unlinked_bufnr }) - vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_bufnr }) -end - +---Adds a discussion to the global state. Works for both notes (unlinked) and diff-linked comments, M.add_discussion = function(arg) local discussion = arg.data.discussion if arg.unlinked then - if type(M.unlinked_discussions) ~= "table" then - M.unlinked_discussions = {} + if type(state.DISCUSSION_DATA.unlinked_discussions) ~= "table" then + state.DISCUSSION_DATA.unlinked_discussions = {} end - table.insert(M.unlinked_discussions, 1, discussion) + table.insert(state.DISCUSSION_DATA.unlinked_discussions, 1, discussion) M.rebuild_unlinked_discussion_tree() - return - end - if type(M.discussions) ~= "table" then - M.discussions = {} + else + if type(state.DISCUSSION_DATA.discussions) ~= "table" then + state.DISCUSSION_DATA.discussions = {} + end + table.insert(state.DISCUSSION_DATA.discussions, 1, discussion) + M.rebuild_discussion_tree() end - table.insert(M.discussions, 1, discussion) - M.rebuild_discussion_tree() end +---Creates the split for the discussion tree and returns it, with both buffer numbers +---@return NuiSplit +---@return integer +---@return integer M.create_split_and_bufs = function() local position = state.settings.discussion_tree.position local size = state.settings.discussion_tree.size @@ -735,82 +507,68 @@ M.create_split_and_bufs = function() return split, linked_bufnr, unlinked_bufnr end -M.add_empty_titles = function(args) - M.switch_can_edit_bufs(true) - 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 - ----Check if type of node is note or note body ----@param node NuiTree.Node? ----@return boolean -M.is_node_note = function(node) - if node and (node.type == "note_body" or node.type == "note") then - return true - else - return false - end -end - ---Check if type of current node is note or note body ---@param tree NuiTree ---@return boolean M.is_current_node_note = function(tree) - return M.is_node_note(tree:get_node()) + return common.is_node_note(tree:get_node()) end M.set_tree_keymaps = function(tree, bufnr, unlinked) - vim.keymap.set("n", state.settings.discussion_tree.toggle_tree_type, function() - M.toggle_tree_type(unlinked) - end, { buffer = bufnr, desc = "Toggle tree type between `simple` and `by_file_name`" }) + if not unlinked then + vim.keymap.set("n", state.settings.discussion_tree.jump_to_file, function() + if M.is_current_node_note(tree) then + common.jump_to_file(tree) + end + end, { buffer = bufnr, desc = "Jump to file" }) + vim.keymap.set("n", state.settings.discussion_tree.jump_to_reviewer, function() + if M.is_current_node_note(tree) then + common.jump_to_reviewer(tree, M.refresh_view) + end + end, { buffer = bufnr, desc = "Jump to reviewer" }) + vim.keymap.set("n", state.settings.discussion_tree.toggle_tree_type, function() + M.toggle_tree_type() + end, { buffer = bufnr, desc = "Toggle tree type between `simple` and `by_file_name`" }) + end vim.keymap.set("n", state.settings.discussion_tree.edit_comment, function() if M.is_current_node_note(tree) then M.edit_comment(tree, unlinked) end end, { buffer = bufnr, desc = "Edit comment" }) + vim.keymap.set("n", state.settings.discussion_tree.publish_draft, function() + if M.is_draft_note(tree) then + draft_notes.publish_draft(tree) + end + end, { buffer = bufnr, desc = "Publish draft" }) vim.keymap.set("n", state.settings.discussion_tree.delete_comment, function() if M.is_current_node_note(tree) then - M.delete_comment(tree, unlinked) + M.delete_comment(tree) end end, { buffer = bufnr, desc = "Delete comment" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_resolved, function() - if M.is_current_node_note(tree) then + if M.is_current_node_note(tree) and not M.is_draft_note(tree) then M.toggle_discussion_resolved(tree) end end, { buffer = bufnr, desc = "Toggle resolved" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_node, function() - M.toggle_node(tree) + tree_utils.toggle_node(tree) end, { buffer = bufnr, desc = "Toggle node" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_all_discussions, function() - M.toggle_nodes(tree, unlinked, { + tree_utils.toggle_nodes(M.split.winid, tree, unlinked, { toggle_resolved = true, toggle_unresolved = true, keep_current_open = state.settings.discussion_tree.keep_current_open, }) end, { buffer = bufnr, desc = "Toggle all nodes" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_resolved_discussions, function() - M.toggle_nodes(tree, unlinked, { + tree_utils.toggle_nodes(M.split.winid, tree, unlinked, { toggle_resolved = true, toggle_unresolved = false, keep_current_open = state.settings.discussion_tree.keep_current_open, }) end, { buffer = bufnr, desc = "Toggle resolved nodes" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_unresolved_discussions, function() - M.toggle_nodes(tree, unlinked, { + tree_utils.toggle_nodes(M.split.winid, tree, unlinked, { toggle_resolved = false, toggle_unresolved = true, keep_current_open = state.settings.discussion_tree.keep_current_open, @@ -822,31 +580,19 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) end end, { buffer = bufnr, desc = "Reply" }) vim.keymap.set("n", state.settings.discussion_tree.switch_view, function() - switch_view_type() + winbar.switch_view_type() end, { buffer = bufnr, desc = "Switch view type" }) vim.keymap.set("n", state.settings.help, function() help.open() end, { buffer = bufnr, desc = "Open help popup" }) - if not unlinked then - vim.keymap.set("n", state.settings.discussion_tree.jump_to_file, function() - if M.is_current_node_note(tree) then - M.jump_to_file(tree) - end - end, { buffer = bufnr, desc = "Jump to file" }) - vim.keymap.set("n", state.settings.discussion_tree.jump_to_reviewer, function() - if M.is_current_node_note(tree) then - M.jump_to_reviewer(tree) - end - end, { buffer = bufnr, desc = "Jump to reviewer" }) - end vim.keymap.set("n", state.settings.discussion_tree.open_in_browser, function() - M.open_in_browser(tree) + common.open_in_browser(tree) end, { buffer = bufnr, desc = "Open the note in your browser" }) vim.keymap.set("n", state.settings.discussion_tree.copy_node_url, function() - M.copy_node_url(tree) + common.copy_node_url(tree) end, { buffer = bufnr, desc = "Copy the URL of the current node to clipboard" }) vim.keymap.set("n", "p", function() - M.print_node(tree) + common.print_node(tree) end, { buffer = bufnr, desc = "Print current node (for debugging)" }) vim.keymap.set("n", state.settings.discussion_tree.add_emoji, function() M.add_emoji_to_note(tree, unlinked) @@ -858,6 +604,10 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) emoji.init_popup(tree, bufnr) end +---Redraws the header of a node in a tree when it's been toggled to resolved/unresolved +---@param tree NuiTree +---@param note NuiTree.Node +---@param mark_resolved boolean 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" @@ -888,18 +638,6 @@ M.redraw_resolved_status = function(tree, note, mark_resolved) tree:render() end ----Restore cursor position to the original node if possible -M.restore_cursor_position = function(tree, original_node, root_node) - local _, line_number = tree:get_node("-" .. tostring(original_node.id)) - -- If current_node is has been collapsed, get line number of root node instead - if line_number == nil and root_node then - _, line_number = tree:get_node("-" .. tostring(root_node.id)) - end - if line_number ~= nil then - vim.api.nvim_win_set_cursor(M.split.winid, { line_number, 0 }) - end -end - ---Replace text in discussion after note update. ---@param data Discussion[]|UnlinkedDiscussion[] ---@param discussion_id string @@ -917,96 +655,54 @@ M.replace_text = function(data, discussion_id, note_id, text) end end ----Get root node ----@param tree NuiTree ----@param node NuiTree.Node? ----@return NuiTree.Node? -M.get_root_node = function(tree, node) - if not node then - return nil - end - if node.type == "note_body" or node.type == "note" and not node.is_root then - local parent_id = node:get_parent_id() - return M.get_root_node(tree, tree:get_node(parent_id)) - elseif node.is_root then - return node - end -end - ----Get note node ----@param tree NuiTree ----@param node NuiTree.Node? ----@return NuiTree.Node? -M.get_note_node = function(tree, node) - if not node then - return nil - end - - if node.type == "note_body" then - local parent_id = node:get_parent_id() - if parent_id == nil then - return node - end - return M.get_note_node(tree, tree:get_node(parent_id)) - elseif node.type == "note" then - return node - end -end - +---Given some note data, adds it to the tree and re-renders the tree +---@param tree any +---@param note any +---@param discussion_id any M.add_reply_to_tree = function(tree, note, discussion_id) - local note_node = discussions_tree.build_note(note) + local note_node = tree_utils.build_note(note) note_node:expand() tree:add_node(note_node, discussion_id and ("-" .. discussion_id) or nil) tree:render() end ----@param tree NuiTree -M.get_url = function(tree) - local current_node = tree:get_node() - local note_node = M.get_note_node(tree, current_node) - if note_node == nil then - return - end - local url = note_node.url - if url == nil then - u.notify("Could not get URL of note", vim.log.levels.ERROR) - return - end - return url -end - ----@param tree NuiTree -M.open_in_browser = function(tree) - local url = M.get_url(tree) - if url ~= nil then - u.open_in_browser(url) +---Toggle comments tree type between "simple" and "by_file_name" +M.toggle_tree_type = function() + if state.settings.discussion_tree.tree_type == "simple" then + state.settings.discussion_tree.tree_type = "by_file_name" + else + state.settings.discussion_tree.tree_type = "simple" end + M.rebuild_discussion_tree() end +---Indicates whether the node under the cursor is a draft note or not ---@param tree NuiTree -M.copy_node_url = function(tree) - local url = M.get_url(tree) - if url ~= nil then - vim.fn.setreg("+", url) - u.notify("Copied '" .. url .. "' to clipboard", vim.log.levels.INFO) - end +---@return boolean +M.is_draft_note = function(tree) + local current_node = tree:get_node() + local root_node = common.get_root_node(tree, current_node) + return root_node ~= nil and root_node.is_draft end +---Opens a popup prompting the user to choose an emoji to attach to the current node +---@param tree any +---@param unlinked boolean M.add_emoji_to_note = function(tree, unlinked) local node = tree:get_node() - local note_node = M.get_note_node(tree, node) - local root_node = M.get_root_node(tree, node) + local note_node = common.get_note_node(tree, node) + local root_node = common.get_root_node(tree, node) local note_id = tonumber(note_node.is_root and root_node.root_note_id or note_node.id) local note_id_str = tostring(note_id) local emojis = require("gitlab.emoji").emoji_list emoji.pick_emoji(emojis, function(name) local body = { emoji = name, note_id = note_id } job.run_job("/mr/awardable/note/", "POST", body, function(data) - if M.emojis[note_id_str] == nil then - M.emojis[note_id_str] = {} - table.insert(M.emojis[note_id_str], data.Emoji) + if state.DISCUSSION_DATA.emojis[note_id_str] == nil then + state.DISCUSSION_DATA.emojis[note_id_str] = {} + table.insert(state.DISCUSSION_DATA.emojis[note_id_str], data.Emoji) else - table.insert(M.emojis[note_id_str], data.Emoji) + table.insert(state.DISCUSSION_DATA.emojis[note_id_str], data.Emoji) end if unlinked then M.rebuild_unlinked_discussion_tree() @@ -1018,17 +714,20 @@ M.add_emoji_to_note = function(tree, unlinked) end) end +---Opens a popup prompting the user to choose an emoji to remove from the current node +---@param tree any +---@param unlinked boolean M.delete_emoji_from_note = function(tree, unlinked) local node = tree:get_node() - local note_node = M.get_note_node(tree, node) - local root_node = M.get_root_node(tree, node) + local note_node = common.get_note_node(tree, node) + local root_node = common.get_root_node(tree, node) local note_id = tonumber(note_node.is_root and root_node.root_note_id or note_node.id) local note_id_str = tostring(note_id) local e = require("gitlab.emoji") local emojis = {} - local current_emojis = M.emojis[note_id_str] + local current_emojis = state.DISCUSSION_DATA.emojis[note_id_str] for _, current_emoji in ipairs(current_emojis) do if state.USER.id == current_emoji.user.id then table.insert(emojis, e.emoji_map[current_emoji.name]) @@ -1045,12 +744,12 @@ M.delete_emoji_from_note = function(tree, unlinked) end job.run_job(string.format("/mr/awardable/note/%d/%d", note_id, awardable_id), "DELETE", nil, function(_) local keep = {} -- Emojis to keep after deletion in the UI - for _, saved in ipairs(M.emojis[note_id_str]) do + for _, saved in ipairs(state.DISCUSSION_DATA.emojis[note_id_str]) do if saved.name ~= name or saved.user.id ~= state.USER.id then table.insert(keep, saved) end end - M.emojis[note_id_str] = keep + state.DISCUSSION_DATA.emojis[note_id_str] = keep if unlinked then M.rebuild_unlinked_discussion_tree() else @@ -1062,10 +761,4 @@ M.delete_emoji_from_note = function(tree, unlinked) end) end --- For developers! -M.print_node = function(tree) - local current_node = tree:get_node() - vim.print(current_node) -end - return M diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index 50ea6763..024c4475 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -1,149 +1,22 @@ -local state = require("gitlab.state") +-- This module contains tree code specific to the discussion tree, that +-- is not used in the draft notes tree local u = require("gitlab.utils") +local common = require("gitlab.actions.common") +local state = require("gitlab.state") local NuiTree = require("nui.tree") +local NuiLine = require("nui.line") local M = {} -local attach_uuid = function(str) - return { text = str, id = u.uuid() } -end - ----Create path node ----@param relative_path string ----@param full_path string ----@param child_nodes NuiTree.Node[]? ----@return NuiTree.Node -local function create_path_node(relative_path, full_path, child_nodes) - return NuiTree.Node({ - text = relative_path, - path = full_path, - id = full_path, - type = "path", - icon = " ", - icon_hl = "GitlabDirectoryIcon", - text_hl = "GitlabDirectory", - }, child_nodes or {}) -end - ----Create file name node ----@param file_name string ----@param full_file_path string ----@param child_nodes NuiTree.Node[]? ----@return NuiTree.Node -local function create_file_name_node(file_name, full_file_path, child_nodes) - local icon, icon_hl = u.get_icon(file_name) - return NuiTree.Node({ - text = file_name, - file_name = full_file_path, - id = full_file_path, - type = "file_name", - icon = icon, - icon_hl = icon_hl, - text_hl = "GitlabFileName", - }, child_nodes or {}) -end - ----Sort list of nodes (in place) of type "path" or "file_name" ----@param nodes NuiTree.Node[] -local function sort_nodes(nodes) - table.sort(nodes, function(node1, node2) - if node1.type == "path" and node2.type == "path" then - return node1.path < node2.path - elseif node1.type == "file_name" and node2.type == "file_name" then - return node1.file_name < node2.file_name - elseif node1.type == "path" and node2.type == "file_name" then - return true - else - return false - end - end) -end - ----Merge path nodes which have only single path child ----@param node NuiTree.Node -local function flatten_nodes(node) - if node.type ~= "path" then - return - end - for _, child in ipairs(node.__children) do - flatten_nodes(child) - end - if #node.__children == 1 and node.__children[1].type == "path" then - local child = node.__children[1] - node.__children = child.__children - node.id = child.id - node.path = child.path - node.text = node.text .. u.path_separator .. child.text - end - sort_nodes(node.__children) -end - ----Build note header from note. ----@param note Note ----@return string -M.build_note_header = function(note) - return "@" .. note.author.username .. " " .. u.time_since(note.created_at) -end - ----Build note node body ----@param note Note ----@param resolve_info table? ----@return string ----@return NuiTree.Node[] -local function build_note_body(note, resolve_info) - local text_nodes = {} - for bodyLine in u.split_by_new_lines(note.body) do - local line = attach_uuid(bodyLine) - table.insert( - text_nodes, - NuiTree.Node({ - 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, - type = "note_body", - }, {}) - ) - end - - local resolve_symbol = "" - if resolve_info ~= nil and resolve_info.resolvable then - resolve_symbol = resolve_info.resolved and state.settings.discussion_tree.resolved - or state.settings.discussion_tree.unresolved - end - - local noteHeader = M.build_note_header(note) .. " " .. resolve_symbol - - return noteHeader, text_nodes -end - ----Build note node ----@param note Note ----@param resolve_info table? ----@return NuiTree.Node ----@return string ----@return NuiTree.Node[] -M.build_note = function(note, resolve_info) - local text, text_nodes = build_note_body(note, resolve_info) - local note_node = NuiTree.Node({ - text = text, - id = note.id, - 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), - url = state.INFO.web_url .. "#note_" .. note.id, - type = "note", - }, text_nodes) - - return note_node, text, text_nodes -end - ---Create nodes for NuiTree from discussions ---@param items Discussion[] ---@param unlinked boolean? False or nil means that discussions are linked to code lines ---@return NuiTree.Node[] M.add_discussions_to_table = function(items, unlinked) local t = {} + if items == vim.NIL then + items = {} + end for _, discussion in ipairs(items) do local discussion_children = {} @@ -206,10 +79,85 @@ M.add_discussions_to_table = function(items, unlinked) return t end + return M.create_node_list_by_file_name(t) +end + +---Create path node +---@param relative_path string +---@param full_path string +---@param child_nodes NuiTree.Node[]? +---@return NuiTree.Node +local function create_path_node(relative_path, full_path, child_nodes) + return NuiTree.Node({ + text = relative_path, + path = full_path, + id = full_path, + type = "path", + icon = " ", + icon_hl = "GitlabDirectoryIcon", + text_hl = "GitlabDirectory", + }, child_nodes or {}) +end + +---Sort list of nodes (in place) of type "path" or "file_name" +---@param nodes NuiTree.Node[] +local function sort_nodes(nodes) + table.sort(nodes, function(node1, node2) + if node1.type == "path" and node2.type == "path" then + return node1.path < node2.path + elseif node1.type == "file_name" and node2.type == "file_name" then + return node1.file_name < node2.file_name + elseif node1.type == "path" and node2.type == "file_name" then + return true + else + return false + end + end) +end + +---Merge path nodes which have only single path child +---@param node NuiTree.Node +local function flatten_nodes(node) + if node.type ~= "path" then + return + end + for _, child in ipairs(node.__children) do + flatten_nodes(child) + end + if #node.__children == 1 and node.__children[1].type == "path" then + local child = node.__children[1] + node.__children = child.__children + node.id = child.id + node.path = child.path + node.text = node.text .. u.path_separator .. child.text + end + sort_nodes(node.__children) +end + +---Create file name node +---@param file_name string +---@param full_file_path string +---@param child_nodes NuiTree.Node[]? +---@return NuiTree.Node +local function create_file_name_node(file_name, full_file_path, child_nodes) + local icon, icon_hl = u.get_icon(file_name) + return NuiTree.Node({ + text = file_name, + file_name = full_file_path, + id = full_file_path, + type = "file_name", + icon = icon, + icon_hl = icon_hl, + text_hl = "GitlabFileName", + }, child_nodes or {}) +end + +local create_disscussions_by_file_name = function(node_list) -- Create all the folder and file name nodes. local discussion_by_file_name = {} local top_level_path_to_node = {} - for _, node in ipairs(t) do + + for _, node in ipairs(node_list) do local path = "" local parent_node = nil local path_parts = u.split_path(node.file_name) @@ -274,13 +222,280 @@ M.add_discussions_to_table = function(items, unlinked) end end + return discussion_by_file_name +end + +M.create_node_list_by_file_name = function(node_list) + -- Create all the folder and file name nodes. + local discussion_by_file_name = create_disscussions_by_file_name(node_list) + -- Flatten empty folders for _, node in ipairs(discussion_by_file_name) do flatten_nodes(node) end + sort_nodes(discussion_by_file_name) return discussion_by_file_name end +local attach_uuid = function(str) + return { text = str, id = u.uuid() } +end + +---Build note node body +---@param note Note|DraftNote +---@param resolve_info table? +---@return string +---@return NuiTree.Node[] +local function build_note_body(note, resolve_info) + local text_nodes = {} + for bodyLine in u.split_by_new_lines(note.body or note.note) do + local line = attach_uuid(bodyLine) + table.insert( + text_nodes, + NuiTree.Node({ + 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, + type = "note_body", + }, {}) + ) + end + + local resolve_symbol = "" + if resolve_info ~= nil and resolve_info.resolvable then + resolve_symbol = resolve_info.resolved and state.settings.discussion_tree.resolved + or state.settings.discussion_tree.unresolved + end + + local noteHeader = common.build_note_header(note) .. " " .. resolve_symbol + + return noteHeader, text_nodes +end + +---Build note node +---@param note Note|DraftNote +---@param resolve_info table? +---@return NuiTree.Node +---@return string +---@return NuiTree.Node[] +M.build_note = function(note, resolve_info) + local text, text_nodes = build_note_body(note, resolve_info) + local note_node = NuiTree.Node({ + text = text, + is_draft = note.note ~= nil, + id = note.id, + 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), + url = state.INFO.web_url .. "#note_" .. note.id, + type = "note", + }, text_nodes) + + return note_node, text, text_nodes +end + +---Inspired by default func https://github.com/MunifTanjim/nui.nvim/blob/main/lua/nui/tree/util.lua#L38 +M.nui_tree_prepare_node = function(node) + if not node.text then + error("missing node.text") + end + + local texts = node.text + if type(node.text) ~= "table" or node.text.content then + texts = { node.text } + end + + local lines = {} + + for i, text in ipairs(texts) do + local line = NuiLine() + + line:append(string.rep(" ", node._depth - 1)) + + if i == 1 and node:has_children() then + line:append(node:is_expanded() and " " or " ") + if node.icon then + line:append(node.icon .. " ", node.icon_hl) + end + else + line:append(" ") + end + + line:append(text, node.text_hl) + + local note_id = tostring(node.is_root and node.root_note_id or node.id) + + local e = require("gitlab.emoji") + + ---@type Emoji[] + local emojis = state.DISCUSSION_DATA.emojis[note_id] + local placed_emojis = {} + if emojis ~= nil then + for _, v in ipairs(emojis) do + local icon = e.emoji_map[v.name] + if icon ~= nil and not u.contains(placed_emojis, icon.moji) then + line:append(" ") + line:append(icon.moji) + table.insert(placed_emojis, icon.moji) + end + end + end + + table.insert(lines, line) + end + + return lines +end + +---@class ToggleNodesOptions +---@field toggle_resolved boolean Whether to toggle resolved discussions. +---@field toggle_unresolved boolean Whether to toggle unresolved discussions. +---@field keep_current_open boolean Whether to keep the current discussion open even if it should otherwise be closed. + +---This function (settings.discussion_tree.toggle_nodes) expands/collapses all nodes and their children according to the opts. +---@param tree NuiTree +---@param winid integer +---@param unlinked boolean +---@param opts ToggleNodesOptions +M.toggle_nodes = function(winid, tree, unlinked, opts) + local current_node = tree:get_node() + if current_node == nil then + return + end + local root_node = common.get_root_node(tree, current_node) + for _, node in ipairs(tree:get_nodes()) do + if opts.toggle_resolved then + if + (unlinked and state.unlinked_discussion_tree.resolved_expanded) + or (not unlinked and state.discussion_tree.resolved_expanded) + then + M.collapse_recursively(tree, node, root_node, opts.keep_current_open, true) + else + M.expand_recursively(tree, node, true) + end + end + if opts.toggle_unresolved then + if + (unlinked and state.unlinked_discussion_tree.unresolved_expanded) + or (not unlinked and state.discussion_tree.unresolved_expanded) + then + M.collapse_recursively(tree, node, root_node, opts.keep_current_open, false) + else + M.expand_recursively(tree, node, false) + end + end + end + -- Reset states of resolved discussions after toggling + if opts.toggle_resolved then + if unlinked then + state.unlinked_discussion_tree.resolved_expanded = not state.unlinked_discussion_tree.resolved_expanded + else + state.discussion_tree.resolved_expanded = not state.discussion_tree.resolved_expanded + end + end + -- Reset states of unresolved discussions after toggling + if opts.toggle_unresolved then + if unlinked then + state.unlinked_discussion_tree.unresolved_expanded = not state.unlinked_discussion_tree.unresolved_expanded + else + state.discussion_tree.unresolved_expanded = not state.discussion_tree.unresolved_expanded + end + end + tree:render() + M.restore_cursor_position(winid, tree, current_node, root_node) +end + +---Restore cursor position to the original node if possible +M.restore_cursor_position = function(winid, tree, original_node, root_node) + local _, line_number = tree:get_node("-" .. tostring(original_node.id)) + -- If current_node is has been collapsed, get line number of root node instead + if line_number == nil and root_node then + _, line_number = tree:get_node("-" .. tostring(root_node.id)) + end + if line_number ~= nil then + vim.api.nvim_win_set_cursor(winid, { line_number, 0 }) + end +end + +---This function (settings.discussion_tree.expand_recursively) expands a node and its children. +---@param tree NuiTree +---@param node NuiTree.Node +---@param is_resolved boolean If true, expand resolved discussions. If false, expand unresolved discussions. +M.expand_recursively = function(tree, node, is_resolved) + if node == nil then + return + end + if common.is_node_note(node) and common.get_root_node(tree, node).resolved == is_resolved then + node:expand() + end + local children = node:get_child_ids() + for _, child in ipairs(children) do + M.expand_recursively(tree, tree:get_node(child), is_resolved) + end +end + +---This function (settings.discussion_tree.collapse_recursively) collapses a node and its children. +---@param tree NuiTree +---@param node NuiTree.Node +---@param current_root_node NuiTree.Node The root node of the current node. +---@param keep_current_open boolean If true, the current node stays open, even if it should otherwise be collapsed. +---@param is_resolved boolean If true, collapse resolved discussions. If false, collapse unresolved discussions. +M.collapse_recursively = function(tree, node, current_root_node, keep_current_open, is_resolved) + if node == nil then + return + end + local root_node = common.get_root_node(tree, node) + if common.is_node_note(node) and root_node.resolved == is_resolved then + if keep_current_open and root_node == current_root_node then + return + end + node:collapse() + end + local children = node:get_child_ids() + for _, child in ipairs(children) do + M.collapse_recursively(tree, tree:get_node(child), current_root_node, keep_current_open, is_resolved) + end +end + +-- This function (settings.discussion_tree.toggle_node) expands/collapses the current node and its children +M.toggle_node = function(tree) + local node = tree:get_node() + if node == nil then + return + end + + -- Switch to the "note" node from "note_body" nodes to enable toggling discussions inside comments + if node.type == "note_body" then + node = tree:get_node(node:get_parent_id()) + end + 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() + if common.is_node_note(node) then + for _, child in ipairs(children) do + tree:get_node(child):collapse() + end + end + else + if common.is_node_note(node) then + for _, child in ipairs(children) do + tree:get_node(child):expand() + end + end + node:expand() + end + + tree:render() +end + return M diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index ada616c9..3611940a 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -1,6 +1,20 @@ -local M = {} -local state = require("gitlab.state") local List = require("gitlab.utils.list") +local state = require("gitlab.state") + +local M = { + bufnr_map = { + discussions = nil, + notes = nil, + }, + current_view_type = state.settings.discussion_tree.default_view, +} + +M.set_buffers = function(linked_bufnr, unlinked_bufnr) + M.bufnr_map = { + discussions = linked_bufnr, + notes = unlinked_bufnr, + } +end ---@param nodes Discussion[]|UnlinkedDiscussion[]|nil ---@return number, number @@ -30,36 +44,99 @@ local get_data = function(nodes) return total_resolvable, total_resolved end ----@param discussions Discussion[]|nil ----@param unlinked_discussions UnlinkedDiscussion[]|nil ----@param file_name string -local function content(discussions, unlinked_discussions, file_name) - local resolvable_discussions, resolved_discussions = get_data(discussions) - local resolvable_notes, resolved_notes = get_data(unlinked_discussions) +local function content() + local resolvable_discussions, resolved_discussions = get_data(state.DISCUSSION_DATA.discussions) + local resolvable_notes, resolved_notes = get_data(state.DISCUSSION_DATA.unlinked_discussions) + + local draft_notes = require("gitlab.actions.draft_notes") + local inline_draft_notes = List.new(state.DRAFT_NOTES):filter(draft_notes.has_position) + local unlinked_draft_notes = List.new(state.DRAFT_NOTES):filter(function(note) + return not draft_notes.has_position(note) + end) local t = { - name = file_name, resolvable_discussions = resolvable_discussions, resolved_discussions = resolved_discussions, + inline_draft_notes = #inline_draft_notes, + unlinked_draft_notes = #unlinked_draft_notes, resolvable_notes = resolvable_notes, resolved_notes = resolved_notes, help_keymap = state.settings.help, } - return state.settings.discussion_tree.winbar(t) + return M.make_winbar(t) end ---This function updates the winbar ----@param discussions Discussion[] ----@param unlinked_discussions UnlinkedDiscussion[] ----@param base_title string -M.update_winbar = function(discussions, unlinked_discussions, base_title) +M.update_winbar = function() local d = require("gitlab.actions.discussions") local winId = d.split.winid - local c = content(discussions, unlinked_discussions, base_title) + local c = content() if vim.wo[winId] then vim.wo[winId].winbar = c end end +---Builds the title string for both sections, using the count of resolvable and draft nodes +---@param base_title string +---@param resolvable_count integer +---@param resolved_count integer +---@param drafts_count integer +---@return string +local add_drafts_and_resolvable = function(base_title, resolvable_count, resolved_count, drafts_count) + if resolvable_count ~= 0 then + base_title = base_title .. string.format(" (%d/%d resolved", resolvable_count, resolved_count) + end + if drafts_count ~= 0 then + if resolvable_count ~= 0 then + base_title = base_title .. string.format("; %d drafts)", drafts_count) + else + base_title = base_title .. string.format(" (%d drafts)", drafts_count) + end + elseif resolvable_count ~= 0 then + base_title = base_title .. ")" + end + + return base_title +end + +---@param t WinbarTable +M.make_winbar = function(t) + local discussion_title = + add_drafts_and_resolvable("Inline Comments", t.resolvable_discussions, t.resolved_discussions, t.inline_draft_notes) + local notes_title = add_drafts_and_resolvable("Notes", t.resolvable_notes, t.resolved_notes, t.unlinked_draft_notes) + + -- Colorize the active tab + if M.current_view_type == "discussions" then + discussion_title = "%#Text#" .. discussion_title + notes_title = "%#Comment#" .. notes_title + elseif M.current_view_type == "notes" then + discussion_title = "%#Comment#" .. discussion_title + notes_title = "%#Text#" .. notes_title + end + + -- Join everything together and return it + local separator = "%#Comment#|" + local help = "%#Comment#%=Help: " .. t.help_keymap:gsub(" ", "") .. " " + return string.format(" %s %s %s %s", discussion_title, separator, notes_title, help) +end + +---Sets the current view type (if provided an argument) +---and then updates the view +---@param override any +M.switch_view_type = function(override) + if override then + M.current_view_type = override + else + if M.current_view_type == "discussions" then + M.current_view_type = "notes" + elseif M.current_view_type == "notes" then + M.current_view_type = "discussions" + end + end + + vim.api.nvim_set_current_buf(M.bufnr_map[M.current_view_type]) + M.update_winbar() +end + return M diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua new file mode 100755 index 00000000..2b254204 --- /dev/null +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -0,0 +1,239 @@ +-- This module is responsible for CRUD operations for the draft notes in the discussion tree. +-- That includes things like editing existing draft notes in the tree, and +-- and deleting them. Normal notes and comments are managed separately, +-- under lua/gitlab/actions/discussions/init.lua +local winbar = require("gitlab.actions.discussions.winbar") +local diagnostics = require("gitlab.indicators.diagnostics") +local common = require("gitlab.actions.common") +local discussion_tree = require("gitlab.actions.discussions.tree") +local job = require("gitlab.job") +local NuiTree = require("nui.tree") +local List = require("gitlab.utils.list") +local u = require("gitlab.utils") +local state = require("gitlab.state") + +local M = {} + +---@class AddDraftNoteOpts table +---@field draft_note DraftNote +---@field unlinked boolean + +---Adds a draft note to the draft notes state, then rebuilds the view +---@param opts AddDraftNoteOpts +M.add_draft_note = function(opts) + local new_draft_notes = state.DRAFT_NOTES + table.insert(new_draft_notes, opts.draft_note) + state.DRAFT_NOTES = new_draft_notes + local discussions = require("gitlab.actions.discussions") + if opts.unlinked then + discussions.rebuild_unlinked_discussion_tree() + else + discussions.rebuild_discussion_tree() + end + winbar.update_winbar() +end + +---Tells whether a draft note was left on a particular diff or is an unlinked note +---@param note DraftNote +M.has_position = function(note) + return note.position.new_path ~= nil or note.position.old_path ~= nil +end + +--- @param bufnr integer +M.set_bufnr = function(bufnr) + M.bufnr = bufnr +end + +---Returns a list of nodes to add to the discussion tree. Can filter and return only unlinked (note) nodes. +---@param unlinked boolean +---@return NuiTree.Node[] +M.add_draft_notes_to_table = function(unlinked) + local draft_notes = List.new(state.DRAFT_NOTES) + + local draft_note_nodes = draft_notes + ---@param note DraftNote + :filter(function(note) + if unlinked then + return not M.has_position(note) + end + return M.has_position(note) + end) + ---@param note DraftNote + :map(function(note) + local _, root_text, root_text_nodes = discussion_tree.build_note(note) + return NuiTree.Node({ + range = (type(note.position) == "table" and note.position.line_range or nil), + text = root_text, + type = "note", + is_root = true, + is_draft = true, + id = note.id, + root_note_id = note.id, + file_name = (type(note.position) == "table" and note.position.new_path or nil), + new_line = (type(note.position) == "table" and note.position.new_line or nil), + old_line = (type(note.position) == "table" and note.position.old_line or nil), + resolvable = false, + resolved = false, + url = state.INFO.web_url .. "#note_" .. note.id, + }, root_text_nodes) + end) + + return draft_note_nodes + + -- TODO: Combine draft_notes and normal discussion nodes in the complex discussion + -- tree. The code for that feature is a clusterfuck so this is difficult + -- if state.settings.discussion_tree.tree_type == "simple" then + -- return draft_note_nodes + -- end +end + +---Send edits will actually send the edits to Gitlab and refresh the draft_notes tree +M.send_edits = function(note_id) + return function(text) + local body = { note = text } + job.run_job(string.format("/mr/draft_notes/%d", note_id), "PATCH", body, function(data) + u.notify(data.message, vim.log.levels.INFO) + local has_position = false + local new_draft_notes = List.new(state.DRAFT_NOTES):map(function(note) + if note.id == note_id then + has_position = M.has_position(note) + note.note = text + end + return note + end) + state.DRAFT_NOTES = new_draft_notes + local discussions = require("gitlab.actions.discussions") + if has_position then + discussions.rebuild_discussion_tree() + else + discussions.rebuild_unlinked_discussion_tree() + end + winbar.update_winbar() + end) + end +end + +-- This function will actually send the deletion to Gitlab when you make a selection, and re-render the tree +M.send_deletion = function(tree) + local current_node = tree:get_node() + local note_node = common.get_note_node(tree, current_node) + local root_node = common.get_root_node(tree, current_node) + + if note_node == nil or root_node == nil then + u.notify("Could not get note or root node", vim.log.levels.ERROR) + return + end + + ---@type integer + local note_id = note_node.is_root and root_node.id or note_node.id + + job.run_job(string.format("/mr/draft_notes/%d", note_id), "DELETE", nil, function(data) + u.notify(data.message, vim.log.levels.INFO) + + local has_position = false + local new_draft_notes = List.new(state.DRAFT_NOTES):filter(function(note) + if note.id ~= note_id then + return true + else + has_position = M.has_position(note) + return false + end + end) + + state.DRAFT_NOTES = new_draft_notes + local discussions = require("gitlab.actions.discussions") + if has_position then + discussions.rebuild_discussion_tree() + else + discussions.rebuild_unlinked_discussion_tree() + end + + if state.settings.discussion_signs.enabled and state.DISCUSSION_DATA then + diagnostics.refresh_diagnostics() + end + + winbar.update_winbar() + end) +end + +-- This function will trigger a popup prompting you to publish the current draft comment +M.publish_draft = function(tree) + vim.ui.select({ "Confirm", "Cancel" }, { + prompt = "Publish current draft comment?", + }, function(choice) + if choice == "Confirm" then + M.confirm_publish_draft(tree) + end + end) +end + +-- This function will trigger a popup prompting you to publish all draft notes +M.publish_all_drafts = function() + vim.ui.select({ "Confirm", "Cancel" }, { + prompt = "Publish all drafts?", + }, function(choice) + if choice == "Confirm" then + M.confirm_publish_all_drafts() + end + end) +end + +---Publishes all draft notes and comments. Re-renders all discussion views. +M.confirm_publish_all_drafts = function() + local body = { publish_all = true } + job.run_job("/mr/draft_notes/publish", "POST", body, function(data) + u.notify(data.message, vim.log.levels.INFO) + state.DRAFT_NOTES = {} + local discussions = require("gitlab.actions.discussions") + discussions.refresh(function() + discussions.rebuild_discussion_tree() + discussions.rebuild_unlinked_discussion_tree() + winbar.update_winbar() + end) + end) +end + +---Publishes the current draft note that is being hovered over in the tree, +---and then makes an API call to refresh the relevant data for that tree +---and re-render it. +---@param tree NuiTree +M.confirm_publish_draft = function(tree) + local current_node = tree:get_node() + local note_node = common.get_note_node(tree, current_node) + local root_node = common.get_root_node(tree, current_node) + + if note_node == nil or root_node == nil then + u.notify("Could not get note or root node", vim.log.levels.ERROR) + return + end + + ---@type integer + local note_id = note_node.is_root and root_node.id or note_node.id + local body = { note = note_id, publish_all = false } + job.run_job("/mr/draft_notes/publish", "POST", body, function(data) + u.notify(data.message, vim.log.levels.INFO) + + local has_position = false + local new_draft_notes = List.new(state.DRAFT_NOTES):filter(function(note) + if note.id ~= note_id then + return true + else + has_position = M.has_position(note) + return false + end + end) + + state.DRAFT_NOTES = new_draft_notes + local discussions = require("gitlab.actions.discussions") + discussions.refresh(function() + if has_position then + discussions.rebuild_discussion_tree() + else + discussions.rebuild_unlinked_discussion_tree() + end + winbar.update_winbar() + end) + end) +end + +return M diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index a3747f95..d6fb99da 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -4,6 +4,7 @@ local Layout = require("nui.layout") local Popup = require("nui.popup") local job = require("gitlab.job") +local common = require("gitlab.actions.common") local u = require("gitlab.utils") local List = require("gitlab.utils.list") local state = require("gitlab.state") @@ -28,7 +29,7 @@ M.summary = function() end local title = state.INFO.title - local description_lines = M.build_description_lines() + local description_lines = common.build_content(state.INFO.description) local info_lines = state.settings.info.enabled and M.build_info_lines() or { "" } local layout, title_popup, description_popup, info_popup = M.create_layout(info_lines) @@ -71,22 +72,6 @@ M.summary = function() end) end --- Builds a lua list of strings that contain the MR description -M.build_description_lines = function() - local description_lines = {} - - local description = state.INFO.description - for line in u.split_by_new_lines(description) do - table.insert(description_lines, line) - end - -- TODO: @harrisoncramer Not sure whether the following line should be here at all. It definitely - -- didn't belong into the for loop, since it inserted an empty line after each line. But maybe - -- there is a purpose for an empty line at the end of the buffer? - table.insert(description_lines, "") - - return description_lines -end - -- Builds a lua list of strings that contain metadata about the current MR. Only builds the -- lines that users include in their state.settings.info.fields list. M.build_info_lines = function() diff --git a/lua/gitlab/async.lua b/lua/gitlab/async.lua index 0570d234..ebb1f7a1 100644 --- a/lua/gitlab/async.lua +++ b/lua/gitlab/async.lua @@ -36,8 +36,9 @@ function async:fetch(dependencies, i, argTable) end -- Call the API, set the data, and then call the next API - job.run_job(dependency.endpoint, "GET", dependency.body, function(data) - state[dependency.state] = data[dependency.key] + local body = dependency.body and dependency.body() or nil + job.run_job(dependency.endpoint, dependency.method or "GET", body, function(data) + state[dependency.state] = dependency.key and data[dependency.key] or data self:fetch(dependencies, i + 1, argTable) end) end diff --git a/lua/gitlab/colors.lua b/lua/gitlab/colors.lua index 44198c60..6baac09d 100644 --- a/lua/gitlab/colors.lua +++ b/lua/gitlab/colors.lua @@ -12,3 +12,4 @@ vim.api.nvim_set_hl(0, "GitlabDirectoryIcon", u.get_colors_for_group(discussion. vim.api.nvim_set_hl(0, "GitlabFileName", u.get_colors_for_group(discussion.file_name)) vim.api.nvim_set_hl(0, "GitlabResolved", u.get_colors_for_group(discussion.resolved)) vim.api.nvim_set_hl(0, "GitlabUnresolved", u.get_colors_for_group(discussion.unresolved)) +vim.api.nvim_set_hl(0, "GitlabDraft", u.get_colors_for_group(discussion.draft)) diff --git a/lua/gitlab/emoji.lua b/lua/gitlab/emoji.lua index 0d778a98..e1c9cf54 100644 --- a/lua/gitlab/emoji.lua +++ b/lua/gitlab/emoji.lua @@ -1,4 +1,5 @@ local u = require("gitlab.utils") +local common = require("gitlab.actions.common") local state = require("gitlab.state") local M = { @@ -70,15 +71,15 @@ M.init_popup = function(tree, bufnr) vim.api.nvim_create_autocmd({ "CursorHold" }, { callback = function() local node = tree:get_node() - if node == nil or not require("gitlab.actions.discussions").is_node_note(node) then + if node == nil or not common.is_node_note(node) then return end - local note_node = require("gitlab.actions.discussions").get_note_node(tree, node) - local root_node = require("gitlab.actions.discussions").get_root_node(tree, node) + local note_node = common.get_note_node(tree, node) + local root_node = common.get_root_node(tree, node) local note_id_str = tostring(note_node.is_root and root_node.root_note_id or note_node.id) + local emojis = state.DISCUSSION_DATA.emojis - local emojis = require("gitlab.actions.discussions").emojis local note_emojis = emojis[note_id_str] if note_emojis == nil then return diff --git a/lua/gitlab/hunks/init.lua b/lua/gitlab/hunks.lua similarity index 100% rename from lua/gitlab/hunks/init.lua rename to lua/gitlab/hunks.lua diff --git a/lua/gitlab/indicators/common.lua b/lua/gitlab/indicators/common.lua index b09a66bb..ce4b1a4c 100644 --- a/lua/gitlab/indicators/common.lua +++ b/lua/gitlab/indicators/common.lua @@ -5,30 +5,57 @@ local List = require("gitlab.utils.list") local M = {} +---@class NoteWithValues +---@field position NotePosition +---@field resolvable boolean|nil +---@field resolved boolean|nil +---@field created_at string|nil + +---@param note NoteWithValues +---@param file string +---@return boolean +local filter_discussions_and_notes = function(note, file) + ---Do not include unlinked notes + return note.position ~= nil + and (note.position.new_path == file or note.position.old_path == file) + ---Skip resolved discussions if user wants to + and not (state.settings.discussion_signs.skip_resolved_discussion and note.resolvable and note.resolved) + ---Skip discussions from old revisions + and not ( + state.settings.discussion_signs.skip_old_revision_discussion + and u.from_iso_format_date_to_timestamp(note.created_at) + <= u.from_iso_format_date_to_timestamp(state.MR_REVISIONS[1].created_at) + ) +end + ---Filter all discussions which are relevant for currently visible signs and diagnostics. ----@return Discussion[] -M.filter_placeable_discussions = function(all_discussions) - if type(all_discussions) ~= "table" then - return {} +---@return Discussion|DraftNote[] +M.filter_placeable_discussions = function() + local discussions = state.DISCUSSION_DATA.discussions + if type(discussions) ~= "table" then + discussions = {} + end + + local draft_notes = state.DRAFT_NOTES + if type(draft_notes) ~= "table" then + draft_notes = {} end + local file = reviewer.get_current_file() if not file then return {} end - return List.new(all_discussions):filter(function(discussion) + + local filtered_discussions = List.new(discussions):filter(function(discussion) local first_note = discussion.notes[1] - return type(first_note.position) == "table" - --Do not include unlinked notes - and (first_note.position.new_path == file or first_note.position.old_path == file) - --Skip resolved discussions if user wants to - and not (state.settings.discussion_signs.skip_resolved_discussion and first_note.resolvable and first_note.resolved) - --Skip discussions from old revisions - and not ( - state.settings.discussion_signs.skip_old_revision_discussion - and u.from_iso_format_date_to_timestamp(first_note.created_at) - <= u.from_iso_format_date_to_timestamp(state.MR_REVISIONS[1].created_at) - ) + return type(first_note.position) == "table" and filter_discussions_and_notes(first_note, file) + end) + + local filtered_draft_notes = List.new(draft_notes):filter(function(note) + return filter_discussions_and_notes(note, file) end) + + return u.join(filtered_discussions, filtered_draft_notes) end M.parse_line_code = function(line_code) @@ -37,24 +64,24 @@ M.parse_line_code = function(line_code) return tonumber(old_line), tonumber(new_line) end ----@param discussion Discussion +---@param d_or_n Discussion|DraftNote ---@return boolean -M.is_old_sha = function(discussion) - local first_note = discussion.notes[1] +M.is_old_sha = function(d_or_n) + local first_note = M.get_first_note(d_or_n) return first_note.position.old_line ~= nil end ----@param discussion Discussion +---@param discussion Discussion|DraftNote ---@return boolean M.is_new_sha = function(discussion) return not M.is_old_sha(discussion) end ----@param discussion Discussion +---@param d_or_n Discussion|DraftNote ---@return boolean -M.is_single_line = function(discussion) - local first_note = discussion.notes[1] - local line_range = first_note.position.line_range +M.is_single_line = function(d_or_n) + local first_note = M.get_first_note(d_or_n) + local line_range = first_note.position and first_note.position.line_range return line_range == nil end @@ -64,10 +91,10 @@ M.is_multi_line = function(discussion) return not M.is_single_line(discussion) end ----@param discussion Discussion ----@return Note -M.get_first_note = function(discussion) - return discussion.notes[1] +---@param d_or_n Discussion|DraftNote +---@return Note|DraftNote +M.get_first_note = function(d_or_n) + return d_or_n.notes and d_or_n.notes[1] or d_or_n end return M diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index c2c7a6c6..75284365 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -1,7 +1,7 @@ local u = require("gitlab.utils") local diffview_lib = require("diffview.lib") -local discussion_tree = require("gitlab.actions.discussions.tree") -local common = require("gitlab.indicators.common") +local indicators_common = require("gitlab.indicators.common") +local actions_common = require("gitlab.actions.common") local List = require("gitlab.utils.list") local state = require("gitlab.state") local discussion_sign_name = "gitlab_discussion" @@ -24,19 +24,23 @@ local display_opts = { ---Takes some range information and data about a discussion ---and creates a diagnostic to be placed in the reviewer ---@param range_info table ----@param discussion Discussion +---@param d_or_n Discussion|DraftNote ---@return Diagnostic -local function create_diagnostic(range_info, discussion) - local message = "" - for _, note in ipairs(discussion.notes) do - message = message .. discussion_tree.build_note_header(note) .. "\n" .. note.body .. "\n" +local function create_diagnostic(range_info, d_or_n) + local first_note = indicators_common.get_first_note(d_or_n) + local header = actions_common.build_note_header(first_note) + local message = header + if d_or_n.notes then + for _, note in ipairs(d_or_n.notes or {}) do + message = message .. actions_common.build_note_header(note) .. "\n" .. note.body .. "\n" + end end local diagnostic = { message = message, col = 0, severity = state.settings.discussion_signs.severity, - user_data = { discussion_id = discussion.id, header = discussion_tree.build_note_header(discussion.notes[1]) }, + user_data = { discussion_id = d_or_n.id, header = header }, source = "gitlab", code = "gitlab.nvim", } @@ -44,37 +48,37 @@ local function create_diagnostic(range_info, discussion) end ---Creates a single line diagnostic ----@param discussion Discussion +---@param d_or_n Discussion|DraftNote ---@return Diagnostic -local create_single_line_diagnostic = function(discussion) - local first_note = discussion.notes[1] +local create_single_line_diagnostic = function(d_or_n) + local first_note = indicators_common.get_first_note(d_or_n) return create_diagnostic({ lnum = (first_note.position.new_line or first_note.position.old_line or 1) - 1, - }, discussion) + }, d_or_n) end ---Creates a mutli-line line diagnostic ----@param discussion Discussion +---@param d_or_n Discussion|DraftNote ---@return Diagnostic -local create_multiline_diagnostic = function(discussion) - local first_note = discussion.notes[1] +local create_multiline_diagnostic = function(d_or_n) + local first_note = indicators_common.get_first_note(d_or_n) local line_range = first_note.position.line_range if line_range == nil then error("Parsing multi-line comment but note does not contain line range") end - local start_old_line, start_new_line = common.parse_line_code(line_range.start.line_code) + local start_old_line, start_new_line = indicators_common.parse_line_code(line_range.start.line_code) - if common.is_new_sha(discussion) then + if indicators_common.is_new_sha(d_or_n) then return create_diagnostic({ lnum = start_new_line - 1, end_lnum = first_note.position.new_line - 1, - }, discussion) + }, d_or_n) else return create_diagnostic({ lnum = start_old_line - 1, end_lnum = first_note.position.old_line - 1, - }, discussion) + }, d_or_n) end end @@ -105,12 +109,11 @@ local set_diagnostics_in_old_sha = function(namespace, diagnostics, opts) end ---Refresh the diagnostics for the currently reviewed file ----@param discussions Discussion[] -M.refresh_diagnostics = function(discussions) +M.refresh_diagnostics = function() local ok, err = pcall(function() require("gitlab.indicators.signs").clear_signs() M.clear_diagnostics() - local filtered_discussions = common.filter_placeable_discussions(discussions) + local filtered_discussions = indicators_common.filter_placeable_discussions() if filtered_discussions == nil then return end @@ -132,9 +135,9 @@ end ---@param discussions Discussion[] ---@return DiagnosticTable[] M.parse_new_diagnostics = function(discussions) - local new_diagnostics = List.new(discussions):filter(common.is_new_sha) - local single_line = new_diagnostics:filter(common.is_single_line):map(create_single_line_diagnostic) - local multi_line = new_diagnostics:filter(common.is_multi_line):map(create_multiline_diagnostic) + local new_diagnostics = List.new(discussions):filter(indicators_common.is_new_sha) + local single_line = new_diagnostics:filter(indicators_common.is_single_line):map(create_single_line_diagnostic) + local multi_line = new_diagnostics:filter(indicators_common.is_multi_line):map(create_multiline_diagnostic) return u.combine(single_line, multi_line) end @@ -143,9 +146,9 @@ end ---@param discussions Discussion[] ---@return DiagnosticTable[] M.parse_old_diagnostics = function(discussions) - local old_diagnostics = List.new(discussions):filter(common.is_old_sha) - local single_line = old_diagnostics:filter(common.is_single_line):map(create_single_line_diagnostic) - local multi_line = old_diagnostics:filter(common.is_multi_line):map(create_multiline_diagnostic) + local old_diagnostics = List.new(discussions):filter(indicators_common.is_old_sha) + local single_line = old_diagnostics:filter(indicators_common.is_single_line):map(create_single_line_diagnostic) + local multi_line = old_diagnostics:filter(indicators_common.is_multi_line):map(create_multiline_diagnostic) return u.combine(single_line, multi_line) end diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 874d8502..d11b4897 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -14,6 +14,7 @@ local comment = require("gitlab.actions.comment") local pipeline = require("gitlab.actions.pipeline") local create_mr = require("gitlab.actions.create_mr") local approvals = require("gitlab.actions.approvals") +local draft_notes = require("gitlab.actions.draft_notes") local labels = require("gitlab.actions.labels") local user = state.dependencies.user @@ -22,6 +23,8 @@ local labels_dep = state.dependencies.labels local project_members = state.dependencies.project_members local latest_pipeline = state.dependencies.latest_pipeline local revisions = state.dependencies.revisions +local draft_notes_dep = state.dependencies.draft_notes +local discussion_data = state.dependencies.discussion_data return { setup = function(args) @@ -63,10 +66,14 @@ return { pipeline = async.sequence({ latest_pipeline }, pipeline.open), merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge), -- Discussion Tree Actions 🌴 - toggle_discussions = async.sequence({ info, user }, discussions.toggle), - edit_comment = async.sequence({ info }, discussions.edit_comment), - delete_comment = async.sequence({ info }, discussions.delete_comment), + toggle_discussions = async.sequence({ + info, + user, + draft_notes_dep, + discussion_data, + }, discussions.toggle), toggle_resolved = async.sequence({ info }, discussions.toggle_discussion_resolved), + publish_all_drafts = draft_notes.publish_all_drafts, reply = async.sequence({ info }, discussions.reply), -- Other functions 🤷 state = state, diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 1aa6f971..e37dbab4 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -80,7 +80,7 @@ M.open = function() if state.settings.discussion_tree.auto_open then local discussions = require("gitlab.actions.discussions") discussions.close() - discussions.toggle() + require("gitlab").toggle_discussions() -- Fetches data and opens discussions end end @@ -91,7 +91,7 @@ M.close = function() discussions.close() end --- Jumps to the location provided in the reviewer window +--- Jumps to the location provided in the reviewer window ---@param file_name string ---@param line_number number ---@param new_buffer boolean @@ -172,6 +172,7 @@ M.get_reviewer_data = function() local old_line = vim.api.nvim_win_get_cursor(old_win)[1] local is_current_sha_focused = M.is_current_sha_focused() + local modification_type = hunks.get_modification_type(old_line, new_line, current_file, is_current_sha_focused) if modification_type == nil then u.notify("Error getting modification type", vim.log.levels.ERROR) @@ -206,9 +207,7 @@ M.is_current_sha_focused = function() local layout = view.cur_layout local b_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr) local a_win = u.get_window_id_by_buffer_id(layout.a.file.bufnr) - local current_win = vim.fn.win_getid() - - -- Handle cases where user navigates tabs in the middle of making a comment + local current_win = require("gitlab.actions.comment").current_win if a_win ~= current_win and b_win ~= current_win then current_win = M.stored_win M.stored_win = nil @@ -220,7 +219,7 @@ end ---@return string|nil M.get_current_file = function() local view = diffview_lib.get_current_view() - if not view then + if not view or not view.panel then return end return view.panel.cur_file.path diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 818c4298..d15afb59 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -88,6 +88,7 @@ M.settings = { delete_comment = "dd", open_in_browser = "b", copy_node_url = "u", + publish_draft = "P", reply = "r", toggle_node = "t", add_emoji = "Ea", @@ -104,24 +105,9 @@ M.settings = { unresolved = "-", tree_type = "simple", toggle_tree_type = "i", - ---@param t WinbarTable - winbar = function(t) - local discussions_content = t.resolvable_discussions ~= 0 - and string.format("Discussions (%d/%d)", t.resolved_discussions, t.resolvable_discussions) - or "Discussions" - local notes_content = t.resolvable_notes ~= 0 - and string.format("Notes (%d/%d)", t.resolved_notes, t.resolvable_notes) - or "Notes" - if t.name == "Discussions" then - notes_content = "%#Comment#" .. notes_content - discussions_content = "%#Text#" .. discussions_content - else - discussions_content = "%#Comment#" .. discussions_content - notes_content = "%#Text#" .. notes_content - end - local help = "%#Comment#%=Help: " .. t.help_keymap:gsub(" ", "") .. " " - return " " .. discussions_content .. " %#Comment#| " .. notes_content .. help - end, + }, + comments = { + default_to_draft = false, }, create_mr = { target = nil, @@ -188,6 +174,7 @@ M.settings = { file_name = "Normal", resolved = "DiagnosticSignOk", unresolved = "DiagnosticSignWarn", + draft = "DiffviewNonText", }, }, } @@ -332,17 +319,59 @@ end -- for each of the actions to occur. This is necessary because some Gitlab behaviors (like -- adding a reviewer) requires some initial state. M.dependencies = { - user = { endpoint = "/users/me", key = "user", state = "USER", refresh = false }, - info = { endpoint = "/mr/info", key = "info", state = "INFO", refresh = false }, - latest_pipeline = { endpoint = "/pipeline", key = "latest_pipeline", state = "PIPELINE", refresh = true }, - labels = { endpoint = "/mr/label", key = "labels", state = "LABELS", refresh = false }, - revisions = { endpoint = "/mr/revisions", key = "Revisions", state = "MR_REVISIONS", refresh = false }, + user = { + endpoint = "/users/me", + key = "user", + state = "USER", + refresh = false, + }, + info = { + endpoint = "/mr/info", + key = "info", + state = "INFO", + refresh = false, + }, + latest_pipeline = { + endpoint = "/pipeline", + key = "latest_pipeline", + state = "PIPELINE", + refresh = true, + }, + labels = { + endpoint = "/mr/label", + key = "labels", + state = "LABELS", + refresh = false, + }, + revisions = { + endpoint = "/mr/revisions", + key = "Revisions", + state = "MR_REVISIONS", + refresh = false, + }, + draft_notes = { + endpoint = "/mr/draft_notes/", + key = "draft_notes", + state = "DRAFT_NOTES", + refresh = false, + }, project_members = { endpoint = "/project/members", key = "ProjectMembers", state = "PROJECT_MEMBERS", refresh = false, }, + discussion_data = { + endpoint = "/mr/discussions/list", + state = "DISCUSSION_DATA", + refresh = false, + method = "POST", + body = function() + return { + blacklist = M.settings.discussion_tree.blacklist, + } + end, + }, } -- This function clears out all of the previously fetched data. It's used diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index d33caf98..e621c355 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -493,7 +493,7 @@ M.create_popup_state = function(title, settings, width, height, zindex) end ---Create view_opts for Box popups used inside popup Layouts ----@param title string The string to appear on top of the popup +---@param title string|nil The string to appear on top of the popup ---@param enter boolean Whether the pop should be focused after creation ---@return table M.create_box_popup_state = function(title, enter) @@ -738,6 +738,20 @@ M.open_in_browser = function(url) end end +---Combines two tables +---@param t1 table +---@param t2 table +---@return table +M.join = function(t1, t2) + local res = {} + for _, val in ipairs(t1) do + table.insert(res, val) + end + for _, val in ipairs(t2) do + table.insert(res, val) + end + return res +end ---Trims the trailing slash from a URL ---@param s string ---@return string diff --git a/lua/gitlab/utils/list.lua b/lua/gitlab/utils/list.lua index 71db0023..ea54b90c 100644 --- a/lua/gitlab/utils/list.lua +++ b/lua/gitlab/utils/list.lua @@ -21,12 +21,12 @@ end ---Filters a given list ---@generic T ----@param func fun(v: T):boolean +---@param func fun(v: T, i: integer):boolean ---@return List @Returns a new list of elements for which func returns true function List:filter(func) local result = List.new() - for _, v in ipairs(self) do - if func(v) == true then + for i, v in ipairs(self) do + if func(v, i) == true then table.insert(result, v) end end @@ -63,6 +63,19 @@ function List:slice(first, last, step) return sliced end +---Returns true if any of the elements can satisfy the callback +---@generic T +---@param func fun(v: T, i: integer):boolean +---@return List @Returns a boolean +function List:includes(func) + for i, v in ipairs(self) do + if func(v, i) == true then + return true + end + end + return false +end + function List:values() local result = {} for _, v in ipairs(self) do From e71e848b5e2577079c06f85ddda9f9c6612ea0f2 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sat, 20 Apr 2024 09:41:50 -0400 Subject: [PATCH 41/97] feat: 272 feature allow mr selection within neovim (#273) feat: Let users select a merge request directly within Neovim --- README.md | 15 +++++-- cmd/assignee_test.go | 8 ++-- cmd/info_test.go | 8 ++-- cmd/merge_requests.go | 55 +++++++++++++++++++++++++ cmd/merge_requests_test.go | 45 ++++++++++++++++++++ cmd/merge_test.go | 12 +++--- cmd/server.go | 1 + cmd/test.go | 31 ++++++++------ doc/gitlab.nvim.txt | 24 +++++++++-- lua/gitlab/actions/discussions/init.lua | 4 +- lua/gitlab/actions/merge_requests.lua | 54 ++++++++++++++++++++++++ lua/gitlab/git.lua | 4 ++ lua/gitlab/init.lua | 3 ++ lua/gitlab/reviewer/init.lua | 6 ++- lua/gitlab/state.lua | 14 ++++++- 15 files changed, 247 insertions(+), 37 deletions(-) create mode 100644 cmd/merge_requests.go create mode 100644 cmd/merge_requests_test.go create mode 100644 lua/gitlab/actions/merge_requests.lua diff --git a/README.md b/README.md index 881affae..67110f21 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ To view these help docs and to get more detailed help information, please run `: 1. Install Go 2. Add configuration (see Installation section) -3. Checkout your feature branch: `git checkout feature-branch` -4. Open Neovim -5. Run `:lua require("gitlab").review()` to open the reviewer pane +5. Run `:lua require("gitlab").choose_merge_request()` + +This will checkout the branch locally, and open the plugin's reviewer pane. For more detailed information about the Lua APIs please run `:h gitlab.nvim.api` @@ -115,7 +115,10 @@ require("gitlab").setup({ port = nil, -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically log_path = vim.fn.stdpath("cache") .. "/gitlab.nvim.log", -- Log path for the Go server config_path = nil, -- Custom path for `.gitlab.nvim` file, please read the "Connecting to Gitlab" section - debug = { go_request = false, go_response = false }, -- Which values to log + debug = { + go_request = false, + go_response = false, + }, attachment_dir = nil, -- The local directory for files (see the "summary" section) reviewer_settings = { diffview = { @@ -179,6 +182,9 @@ require("gitlab").setup({ comments = { default_to_draft = false, -- Whether to default a comment to a "draft" or not in the popup }, + choose_merge_request = { + open_reviewer = true, -- Open the reviewer window automatically after switching merge requests + }, info = { -- Show additional fields in the summary view enabled = true, horizontal = false, -- Display metadata to the left of the summary rather than underneath @@ -262,6 +268,7 @@ you need to set them up yourself. Here's what I'm using: ```lua local gitlab = require("gitlab") local gitlab_server = require("gitlab.server") +vim.keymap.set("n", "glb", gitlab.choose_merge_request) vim.keymap.set("n", "glr", gitlab.review) vim.keymap.set("n", "gls", gitlab.summary) vim.keymap.set("n", "glA", gitlab.approve) diff --git a/cmd/assignee_test.go b/cmd/assignee_test.go index 08bd5d13..61def41b 100644 --- a/cmd/assignee_test.go +++ b/cmd/assignee_test.go @@ -23,7 +23,7 @@ func updateAssigneesErr(pid interface{}, mergeRequest int, opt *gitlab.UpdateMer func TestAssigneeHandler(t *testing.T) { t.Run("Updates assignees", func(t *testing.T) { request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) - server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssignees}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssignees}) data := serveRequest(t, server, request, AssigneeUpdateResponse{}) assert(t, data.SuccessResponse.Message, "Assignees updated") assert(t, data.SuccessResponse.Status, http.StatusOK) @@ -31,7 +31,7 @@ func TestAssigneeHandler(t *testing.T) { t.Run("Disallows non-PUT method", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/assignee", nil) - server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssignees}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssignees}) data := serveRequest(t, server, request, ErrorResponse{}) assert(t, data.Status, http.StatusMethodNotAllowed) assert(t, data.Details, "Invalid request type") @@ -40,7 +40,7 @@ func TestAssigneeHandler(t *testing.T) { t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) - server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssigneesErr}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssigneesErr}) data := serveRequest(t, server, request, ErrorResponse{}) assert(t, data.Status, http.StatusInternalServerError) assert(t, data.Message, "Could not modify merge request assignees") @@ -49,7 +49,7 @@ func TestAssigneeHandler(t *testing.T) { t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) - server, _ := createRouterAndApi(fakeClient{updateMergeRequestFn: updateAssigneesNon200}) + server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssigneesNon200}) data := serveRequest(t, server, request, ErrorResponse{}) assert(t, data.Status, http.StatusSeeOther) assert(t, data.Message, "Could not modify merge request assignees") diff --git a/cmd/info_test.go b/cmd/info_test.go index e64f4eae..d6de9b2a 100644 --- a/cmd/info_test.go +++ b/cmd/info_test.go @@ -23,7 +23,7 @@ func getInfoErr(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsO func TestInfoHandler(t *testing.T) { t.Run("Returns normal information", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/mr/info", nil) - server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo}) + server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfo}) data := serveRequest(t, server, request, InfoResponse{}) assert(t, data.Info.Title, "Some Title") assert(t, data.SuccessResponse.Message, "Merge requests retrieved") @@ -32,21 +32,21 @@ func TestInfoHandler(t *testing.T) { t.Run("Disallows non-GET method", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/info", nil) - server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfo}) + server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfo}) data := serveRequest(t, server, request, ErrorResponse{}) checkBadMethod(t, *data, http.MethodGet) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/mr/info", nil) - server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoErr}) + server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfoErr}) data := serveRequest(t, server, request, ErrorResponse{}) checkErrorFromGitlab(t, *data, "Could not get project info") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/mr/info", nil) - server, _ := createRouterAndApi(fakeClient{getMergeRequestFn: getInfoNon200}) + server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfoNon200}) data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not get project info", "/mr/info") }) diff --git a/cmd/merge_requests.go b/cmd/merge_requests.go new file mode 100644 index 00000000..dff03de2 --- /dev/null +++ b/cmd/merge_requests.go @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/xanzy/go-gitlab" +) + +type ListMergeRequestResponse struct { + SuccessResponse + MergeRequests []*gitlab.MergeRequest `json:"merge_requests"` +} + +func (a *api) mergeRequestsHandler(w http.ResponseWriter, r *http.Request) { + + if r.Method != http.MethodGet { + w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) + handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed) + return + } + + options := gitlab.ListProjectMergeRequestsOptions{ + Scope: gitlab.Ptr("all"), + State: gitlab.Ptr("opened"), + } + + mergeRequests, _, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, &options) + if err != nil { + handleError(w, fmt.Errorf("Failed to list merge requests: %w", err), "Failed to list merge requests", http.StatusInternalServerError) + return + } + + if len(mergeRequests) == 0 { + handleError(w, errors.New("No merge requests found"), "No merge requests found", http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusOK) + response := ListMergeRequestResponse{ + SuccessResponse: SuccessResponse{ + Message: "Merge requests fetched successfully", + Status: http.StatusOK, + }, + MergeRequests: mergeRequests, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } + +} diff --git a/cmd/merge_requests_test.go b/cmd/merge_requests_test.go new file mode 100644 index 00000000..3a642b22 --- /dev/null +++ b/cmd/merge_requests_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "errors" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +func listProjectMergeRequests200(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { + return []*gitlab.MergeRequest{{ID: 1}}, &gitlab.Response{}, nil +} + +func listProjectMergeRequestsEmpty(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { + return []*gitlab.MergeRequest{}, &gitlab.Response{}, nil +} + +func listProjectMergeRequestsErr(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { + return nil, nil, errors.New("Some error") +} + +func TestMergeRequestHandler(t *testing.T) { + t.Run("Should fetch merge requests", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/merge_requests", nil) + server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequests200}) + data := serveRequest(t, server, request, ListMergeRequestResponse{}) + assert(t, data.Message, "Merge requests fetched successfully") + assert(t, data.Status, http.StatusOK) + }) + t.Run("Should handle an error", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/merge_requests", nil) + server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequestsErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Failed to list merge requests") + assert(t, data.Status, http.StatusInternalServerError) + }) + t.Run("Should handle not having any merge requests with 404", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/merge_requests", nil) + server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequestsEmpty}) + data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "No merge requests found") + assert(t, data.Status, http.StatusNotFound) + }) +} diff --git a/cmd/merge_test.go b/cmd/merge_test.go index 6d068217..a9339b0d 100644 --- a/cmd/merge_test.go +++ b/cmd/merge_test.go @@ -8,11 +8,11 @@ import ( "github.com/xanzy/go-gitlab" ) -func acceptAndMergeFn(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { +func acceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { return &gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil } -func acceptAndMergeFnErr(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { +func acceptMergeRequestErr(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { return nil, nil, errors.New("Some error from Gitlab") } @@ -23,7 +23,7 @@ func acceptAndMergeNon200(pid interface{}, mergeRequest int, opt *gitlab.AcceptM func TestAcceptAndMergeHandler(t *testing.T) { t.Run("Accepts and merges a merge request", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn}) + server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequest}) data := serveRequest(t, server, request, SuccessResponse{}) assert(t, data.Message, "MR merged successfully") assert(t, data.Status, http.StatusOK) @@ -31,21 +31,21 @@ func TestAcceptAndMergeHandler(t *testing.T) { t.Run("Disallows non-POST methods", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/mr/merge", AcceptMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn}) + server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequest}) data := serveRequest(t, server, request, ErrorResponse{}) checkBadMethod(t, *data, http.MethodPost) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFnErr}) + server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequestErr}) data := serveRequest(t, server, request, ErrorResponse{}) checkErrorFromGitlab(t, *data, "Could not merge MR") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeNon200}) + server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptAndMergeNon200}) data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not merge MR", "/mr/merge") }) diff --git a/cmd/server.go b/cmd/server.go index 7c102416..4bf20547 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -145,6 +145,7 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv m.HandleFunc("/job", a.jobHandler) m.HandleFunc("/project/members", a.projectMembersHandler) m.HandleFunc("/shutdown", a.shutdownHandler) + m.HandleFunc("/merge_requests", a.mergeRequestsHandler) m.Handle("/ping", http.HandlerFunc(pingHandler)) diff --git a/cmd/test.go b/cmd/test.go index be791892..e22d75e7 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -19,10 +19,10 @@ The FakeHandlerClient is used to create a fake gitlab client for testing our han type fakeClient struct { createMrFn func(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - getMergeRequestFn func(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - updateMergeRequestFn func(pid interface{}, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - acceptAndMergeFn func(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - unapprorveMergeRequestFn func(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + getMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) + updateMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) + acceptMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) + unapproveMergeRequest func(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) uploadFile func(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) getMergeRequestDiffVersions func(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) approveMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) @@ -47,6 +47,7 @@ type fakeClient struct { updateDraftNote func(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) publishAllDraftNotes func(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) publishDraftNote func(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + listProjectMergeRequests func(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) } type Author struct { @@ -64,19 +65,19 @@ func (f fakeClient) CreateMergeRequest(pid interface{}, opt *gitlab.CreateMergeR } func (f fakeClient) AcceptMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return f.acceptAndMergeFn(pid, mergeRequestIID, opt, options...) + return f.acceptMergeRequest(pid, mergeRequestIID, opt, options...) } func (f fakeClient) GetMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return f.getMergeRequestFn(pid, mergeRequestIID, opt, options...) + return f.getMergeRequest(pid, mergeRequestIID, opt, options...) } func (f fakeClient) UpdateMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return f.updateMergeRequestFn(pid, mergeRequestIID, opt, options...) + return f.updateMergeRequest(pid, mergeRequestIID, opt, options...) } func (f fakeClient) UnapproveMergeRequest(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return f.unapprorveMergeRequestFn(pid, mergeRequestIID, options...) + return f.unapproveMergeRequest(pid, mergeRequestIID, options...) } func (f fakeClient) UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { @@ -159,11 +160,6 @@ func (f fakeClient) CurrentUser(options ...gitlab.RequestOptionFunc) (*gitlab.Us return f.currentUser() } -/* This middleware function needs to return an ID for the rest of the handlers */ -func (f fakeClient) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { - return []*gitlab.MergeRequest{{ID: 1}}, &gitlab.Response{}, nil -} - func (f fakeClient) CreateMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.CreateAwardEmojiOptions, options ...gitlab.RequestOptionFunc) (*gitlab.AwardEmoji, *gitlab.Response, error) { return &gitlab.AwardEmoji{}, &gitlab.Response{}, nil } @@ -184,6 +180,15 @@ func (f fakeClient) PublishAllDraftNotes(pid interface{}, mergeRequest int, opti return f.publishAllDraftNotes(pid, mergeRequest) } +/* This middleware function needs to return an ID for the rest of the handlers */ +func (f fakeClient) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { + if f.listProjectMergeRequests == nil { + return []*gitlab.MergeRequest{{ID: 1}}, &gitlab.Response{}, nil + } else { + return f.listProjectMergeRequests(pid, opt) + } +} + /* The assert function is a helper function used to check two comparables */ func assert[T comparable](t *testing.T, got T, want T) { t.Helper() diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 5976dd9c..eff3bb78 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -51,9 +51,9 @@ QUICK START *gitlab.nvim.quick-start* 1. Install Go 2. Add configuration (see Installation section) -3. Checkout your feature branch: `git checkout feature-branch` -4. Open Neovim -5. Run `:lua require("gitlab").review()` to open the reviewer pane +5. Run `:lua require("gitlab").choose_merge_request()` + +This will checkout the branch locally, and up the plugin's reviewer pane. INSTALLATION *gitlab.nvim.installation* @@ -205,6 +205,9 @@ you call this function with no values the defaults will be used: comments = { default_to_draft = false, -- Whether to default a comment to a "draft" or not in the popup }, + choose_merge_request = { + open_reviewer = true, -- Open the reviewer window automatically after switching merge requests + }, info = { -- Show additional fields in the summary view enabled = true, horizontal = false, -- Display metadata to the left of the summary rather than underneath @@ -596,6 +599,21 @@ default arguments outlined under "Configuring the Plugin". require("gitlab").setup({ port = 8392 }) require("gitlab").setup({ discussion_tree = { blacklist = { "some_bot"} } }) +< + *gitlab.nvim.choose_merge_request* +gitlab.choose_merge_request({opts}) ~ + +Choose a merge request from a list of those open in your current project to review. +This command will automatically check out that branch locally, and optionally +open the reviewer pane. This is the default behavior. +>lua + require("gitlab").choose_merge_request() + require("gitlab").choose_merge_request({ open_reviewer = false }) +< + Parameters: ~ + • {opts}: (table|nil) Keyword arguments to configure the checkout. + • {open_reviewer}: (boolean) Whether to open the reviewer after + switching branches. True by default. < *gitlab.nvim.review* gitlab.review() ~ diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 9a0dcad6..b445fb58 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -114,7 +114,9 @@ M.toggle = function(callback) and type(state.DISCUSSION_DATA.draft_notes) ~= "table" then u.notify("No discussions, notes, or draft notes for this MR", vim.log.levels.WARN) - vim.api.nvim_buf_set_lines(M.split.bufnr, 0, -1, false, { "" }) + if M.split ~= nil then + vim.api.nvim_buf_set_lines(M.split.bufnr, 0, -1, false, { "" }) + end return end diff --git a/lua/gitlab/actions/merge_requests.lua b/lua/gitlab/actions/merge_requests.lua new file mode 100644 index 00000000..881321cc --- /dev/null +++ b/lua/gitlab/actions/merge_requests.lua @@ -0,0 +1,54 @@ +local state = require("gitlab.state") +local reviewer = require("gitlab.reviewer") +local git = require("gitlab.git") +local u = require("gitlab.utils") +local M = {} + +---@class SwitchOpts +---@field open_reviewer boolean + +---Opens up a select menu that lets you choose a different merge request. +---@param opts SwitchOpts|nil +M.choose_merge_request = function(opts) + if not git.has_clean_tree() then + u.notify("Your local branch has changes, please stash or commit and push", vim.log.levels.ERROR) + return + end + + if opts == nil then + opts = state.settings.choose_merge_request + end + + vim.ui.select(state.MERGE_REQUESTS, { + prompt = "Choose Merge Request", + format_item = function(mr) + return string.format("%s (%s)", mr.title, mr.author.name) + end, + }, function(choice) + if not choice then + return + end + + if reviewer.is_open then + reviewer.close() + end + + vim.schedule(function() + local err = git.switch_branch(choice.source_branch) + if err ~= "" then + u.notify(err, vim.log.levels.ERROR) + return + end + + vim.schedule(function() + require("gitlab.server").restart(function() + if opts.open_reviewer then + require("gitlab").review() + end + end) + end) + end) + end) +end + +return M diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index 8d48fb45..5a12fd8e 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -8,4 +8,8 @@ M.base_dir = function() return vim.fn.trim(vim.fn.system({ "git", "rev-parse", "--show-toplevel" })) end +M.switch_branch = function(branch) + return vim.fn.trim(vim.fn.system({ "git", "checkout", "-q", branch })) +end + return M diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index d11b4897..1c9bc8ea 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -6,6 +6,7 @@ local emoji = require("gitlab.emoji") local state = require("gitlab.state") local reviewer = require("gitlab.reviewer") local discussions = require("gitlab.actions.discussions") +local merge_requests = require("gitlab.actions.merge_requests") local merge = require("gitlab.actions.merge") local summary = require("gitlab.actions.summary") local data = require("gitlab.actions.data") @@ -23,6 +24,7 @@ local labels_dep = state.dependencies.labels local project_members = state.dependencies.project_members local latest_pipeline = state.dependencies.latest_pipeline local revisions = state.dependencies.revisions +local merge_requests_dep = state.dependencies.merge_requests local draft_notes_dep = state.dependencies.draft_notes local discussion_data = state.dependencies.discussion_data @@ -79,6 +81,7 @@ return { state = state, data = data.data, print_settings = state.print_settings, + choose_merge_request = async.sequence({ merge_requests_dep }, merge_requests.choose_merge_request), open_in_browser = async.sequence({ info }, function() local web_url = u.get_web_url() if web_url ~= nil then diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index e37dbab4..08efc099 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -12,6 +12,7 @@ local async = require("diffview.async") local diffview_lib = require("diffview.lib") local M = { + is_open = false, bufnr = nil, tabnr = nil, stored_win = nil, @@ -47,6 +48,8 @@ M.open = function() end vim.api.nvim_command(string.format("%s %s..%s", diffview_open_command, diff_refs.base_sha, diff_refs.head_sha)) + + M.is_open = true M.tabnr = vim.api.nvim_get_current_tabpage() if state.settings.reviewer_settings.diffview.imply_local and not has_clean_tree then @@ -74,6 +77,7 @@ M.open = function() end end require("diffview.config").user_emitter:on("view_closed", function(_, ...) + M.is_open = false on_diffview_closed(...) end) @@ -219,7 +223,7 @@ end ---@return string|nil M.get_current_file = function() local view = diffview_lib.get_current_view() - if not view or not view.panel then + if not view or not view.panel or not view.panel.cur_file then return end return view.panel.cur_file.path diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index d15afb59..7f579c22 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -43,7 +43,10 @@ end M.settings = { auth_provider = M.default_auth_provider, port = nil, -- choose random port - debug = { go_request = false, go_response = false }, + debug = { + go_request = false, + go_response = false, + }, log_path = (vim.fn.stdpath("cache") .. "/gitlab.nvim.log"), config_path = nil, reviewer = "diffview", @@ -119,6 +122,9 @@ M.settings = { border = "rounded", }, }, + choose_merge_request = { + open_reviewer = true, + }, info = { enabled = true, horizontal = false, @@ -361,6 +367,12 @@ M.dependencies = { state = "PROJECT_MEMBERS", refresh = false, }, + merge_requests = { + endpoint = "/merge_requests", + key = "merge_requests", + state = "MERGE_REQUESTS", + refresh = false, + }, discussion_data = { endpoint = "/mr/discussions/list", state = "DISCUSSION_DATA", From adc20bf6a0d5e2b95b1e7a1ae41b387a9405aedd Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sat, 20 Apr 2024 10:53:38 -0400 Subject: [PATCH 42/97] Fixes no draft notes, winbar issue (#275) fix: refresh issues with empty state when missing all draft notes or all regular notes --- lua/gitlab/actions/common.lua | 70 +++++++++++++++-------- lua/gitlab/actions/discussions/init.lua | 41 +++---------- lua/gitlab/actions/discussions/winbar.lua | 18 ++++-- lua/gitlab/actions/draft_notes/init.lua | 3 +- lua/gitlab/indicators/common.lua | 4 +- lua/gitlab/utils/init.lua | 7 +++ 6 files changed, 81 insertions(+), 62 deletions(-) diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua index ec961356..23e77646 100644 --- a/lua/gitlab/actions/common.lua +++ b/lua/gitlab/actions/common.lua @@ -1,6 +1,7 @@ -- This module contains code shared between at least two modules. This includes -- actions common to multiple tree types, as well as general utility functions -- that are specific to actions (like jumping to a file or opening a URL) +local List = require("gitlab.utils.list") local u = require("gitlab.utils") local reviewer = require("gitlab.reviewer") local common_indicators = require("gitlab.indicators.common") @@ -38,29 +39,52 @@ M.build_content = function(content) return description_lines end ----@class TitleArg ----@field bufnr integer ----@field title string ----@field data table - ----@param title_args TitleArg[] -M.add_empty_titles = function(title_args) - for _, v in ipairs(title_args) do - M.switch_can_edit_bufs(true, v.bufnr) - local ns_id = vim.api.nvim_create_namespace("GitlabNamespace") - vim.cmd("highlight default TitleHighlight guifg=#787878") - - -- Set empty title if applicable - if type(v.data) ~= "table" or #v.data == 0 then - vim.api.nvim_buf_set_lines(v.bufnr, 0, 1, false, { v.title }) - local linnr = 1 - vim.api.nvim_buf_set_extmark( - v.bufnr, - ns_id, - linnr - 1, - 0, - { end_row = linnr - 1, end_col = string.len(v.title), hl_group = "TitleHighlight" } - ) +M.add_empty_titles = function() + local draft_notes = require("gitlab.actions.draft_notes") + local discussions = require("gitlab.actions.discussions") + local linked, unlinked, drafts = + List.new(u.ensure_table(state.DISCUSSION_DATA and state.DISCUSSION_DATA.discussions)), + List.new(u.ensure_table(state.DISCUSSION_DATA and state.DISCUSSION_DATA.unlinked_discussions)), + List.new(u.ensure_table(state.DRAFT_NOTES)) + + local position_drafts = drafts:filter(function(note) + return draft_notes.has_position(note) + end) + local non_positioned_drafts = drafts:filter(function(note) + return not draft_notes.has_position(note) + end) + + local fields = { + { + bufnr = discussions.linked_bufnr, + count = #linked + #position_drafts, + title = "No Discussions for this MR", + }, + { + bufnr = discussions.unlinked_bufnr, + count = #unlinked + #non_positioned_drafts, + title = "No Notes (Unlinked Discussions) for this MR", + }, + } + + for _, v in ipairs(fields) do + if v.bufnr ~= nil then + M.switch_can_edit_bufs(true, v.bufnr) + local ns_id = vim.api.nvim_create_namespace("GitlabNamespace") + vim.cmd("highlight default TitleHighlight guifg=#787878") + + -- Set empty title if applicable + if v.count == 0 then + vim.api.nvim_buf_set_lines(v.bufnr, 0, 1, false, { v.title }) + local linnr = 1 + vim.api.nvim_buf_set_extmark( + v.bufnr, + ns_id, + linnr - 1, + 0, + { end_row = linnr - 1, end_col = string.len(v.title), hl_group = "TitleHighlight" } + ) + end end end end diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index b445fb58..22412f46 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -37,10 +37,9 @@ local M = { ---@param callback function|nil M.load_discussions = function(callback) job.run_job("/mr/discussions/list", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data) - state.DISCUSSION_DATA.discussions = data.discussions ~= vim.NIL and data.discussions or {} - state.DISCUSSION_DATA.unlinked_discussions = data.unlinked_discussions ~= vim.NIL and data.unlinked_discussions - or {} - state.DISCUSSION_DATA.emojis = data.emojis ~= vim.NIL and data.emojis or {} + state.DISCUSSION_DATA.discussions = u.ensure_table(data.discussions) + state.DISCUSSION_DATA.unlinked_discussions = u.ensure_table(data.unlinked_discussions) + state.DISCUSSION_DATA.emojis = u.ensure_table(data.emojis) if type(callback) == "function" then callback() end @@ -91,12 +90,11 @@ end --- Take existing data and refresh the diagnostics, the winbar, and the signs M.refresh_view = function() - if state.settings.discussion_signs.enabled and state.DISCUSSION_DATA then + if state.settings.discussion_signs.enabled then diagnostics.refresh_diagnostics() end - if M.split_visible and state.DISCUSSION_DATA then - winbar.update_winbar() - end + winbar.update_winbar() + common.add_empty_titles() end ---Opens the discussion tree, sets the keybindings. It also @@ -108,17 +106,9 @@ M.toggle = function(callback) return end - if - type(state.DISCUSSION_DATA.discussions) ~= "table" - and type(state.DISCUSSION_DATA.unlinked_discussions) ~= "table" - and type(state.DISCUSSION_DATA.draft_notes) ~= "table" - then - u.notify("No discussions, notes, or draft notes for this MR", vim.log.levels.WARN) - if M.split ~= nil then - vim.api.nvim_buf_set_lines(M.split.bufnr, 0, -1, false, { "" }) - end - return - end + state.DISCUSSION_DATA.discussions = u.ensure_table(state.DISCUSSION_DATA.discussions) + state.DISCUSSION_DATA.unlinked_discussions = u.ensure_table(state.DISCUSSION_DATA.unlinked_discussions) + state.DRAFT_NOTES = u.ensure_table(state.DRAFT_NOTES) -- Make buffers, get and set buffer numbers, set filetypes local split, linked_bufnr, unlinked_bufnr = M.create_split_and_bufs() @@ -145,19 +135,6 @@ M.toggle = function(callback) M.rebuild_discussion_tree() M.rebuild_unlinked_discussion_tree() - common.add_empty_titles({ - { - bufnr = M.linked_bufnr, - data = state.DISCUSSION_DATA.discussions, - title = "No Discussions for this MR", - }, - { - bufnr = M.unlinked_bufnr, - data = state.DISCUSSION_DATA.unlinked_discussions, - title = "No Notes (Unlinked Discussions) for this MR", - }, - }) - -- Set default buffer local default_buffer = winbar.bufnr_map[state.settings.discussion_tree.default_view] vim.api.nvim_set_current_buf(default_buffer) diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index 3611940a..4a1edcd3 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -70,11 +70,21 @@ end ---This function updates the winbar M.update_winbar = function() local d = require("gitlab.actions.discussions") - local winId = d.split.winid - local c = content() - if vim.wo[winId] then - vim.wo[winId].winbar = c + if d.split == nil then + return + end + + local win_id = d.split.winid + if win_id == nil then + return end + + if not vim.api.nvim_win_is_valid(win_id) then + return + end + + local c = content() + vim.api.nvim_set_option_value("winbar", c, { scope = "local", win = win_id }) end ---Builds the title string for both sections, using the count of resolvable and draft nodes diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index 2b254204..a0d61110 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -21,7 +21,7 @@ local M = {} ---Adds a draft note to the draft notes state, then rebuilds the view ---@param opts AddDraftNoteOpts M.add_draft_note = function(opts) - local new_draft_notes = state.DRAFT_NOTES + local new_draft_notes = u.ensure_table(state.DRAFT_NOTES) table.insert(new_draft_notes, opts.draft_note) state.DRAFT_NOTES = new_draft_notes local discussions = require("gitlab.actions.discussions") @@ -153,6 +153,7 @@ M.send_deletion = function(tree) end winbar.update_winbar() + common.add_empty_titles() end) end diff --git a/lua/gitlab/indicators/common.lua b/lua/gitlab/indicators/common.lua index ce4b1a4c..e68ee48d 100644 --- a/lua/gitlab/indicators/common.lua +++ b/lua/gitlab/indicators/common.lua @@ -31,12 +31,12 @@ end ---Filter all discussions which are relevant for currently visible signs and diagnostics. ---@return Discussion|DraftNote[] M.filter_placeable_discussions = function() - local discussions = state.DISCUSSION_DATA.discussions + local discussions = u.ensure_table(state.DISCUSSION_DATA and state.DISCUSSION_DATA.discussions or {}) if type(discussions) ~= "table" then discussions = {} end - local draft_notes = state.DRAFT_NOTES + local draft_notes = u.ensure_table(state.DRAFT_NOTES) if type(draft_notes) ~= "table" then draft_notes = {} end diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index e621c355..49608c9e 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -759,4 +759,11 @@ M.trim_slash = function(s) return (s:gsub("/+$", "")) end +M.ensure_table = function(data) + if data == vim.NIL or data == nil then + return {} + end + return data +end + return M From 2c2d0eb8460f1a7cf397861ac8267912034d0dec Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sat, 20 Apr 2024 11:44:16 -0400 Subject: [PATCH 43/97] Fix: Restore focus (#277) fix: restores focus to recent buffer after making comment --- lua/gitlab/actions/comment.lua | 4 +++- lua/gitlab/actions/draft_notes/init.lua | 5 ----- lua/gitlab/indicators/diagnostics.lua | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 1598d1af..36fda7dd 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -99,10 +99,10 @@ local function create_comment_layout(opts) opts = {} end + M.current_win = vim.api.nvim_get_current_win() M.comment_popup = Popup(u.create_popup_state("Comment", state.settings.popup.comment)) M.draft_popup = Popup(u.create_box_popup_state("Draft", false)) M.start_line, M.end_line = u.get_visual_selection_boundaries() - M.current_win = vim.api.nvim_get_current_win() local internal_layout = Layout.Box({ Layout.Box(M.comment_popup, { grow = 1 }), @@ -131,10 +131,12 @@ local function create_comment_layout(opts) state.set_popup_keymaps(M.draft_popup, function() local text = u.get_buffer_text(M.comment_popup.bufnr) confirm_create_comment(text, range, unlinked) + vim.api.nvim_set_current_win(M.current_win) end, miscellaneous.toggle_bool, popup_opts) state.set_popup_keymaps(M.comment_popup, function(text) confirm_create_comment(text, range, unlinked) + vim.api.nvim_set_current_win(M.current_win) end, miscellaneous.attach_file, popup_opts) vim.schedule(function() diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index a0d61110..7ab85f16 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -39,11 +39,6 @@ M.has_position = function(note) return note.position.new_path ~= nil or note.position.old_path ~= nil end ---- @param bufnr integer -M.set_bufnr = function(bufnr) - M.bufnr = bufnr -end - ---Returns a list of nodes to add to the discussion tree. Can filter and return only unlinked (note) nodes. ---@param unlinked boolean ---@return NuiTree.Node[] diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index cba373c5..b6e7b8b8 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -53,7 +53,7 @@ end local create_single_line_diagnostic = function(d_or_n) local first_note = indicators_common.get_first_note(d_or_n) local linnr = (indicators_common.is_new_sha(d_or_n) and first_note.position.new_line or first_note.position.old_line) - or 1 + or 1 return create_diagnostic({ lnum = linnr - 1, }, d_or_n) From 9ec134d42b1ffda8c292652067c28529d6fb2273 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sun, 21 Apr 2024 11:12:04 -0400 Subject: [PATCH 44/97] fix: draft note edits + reply keybinding (#279) fix: issue with editing draft notes; jumping to correct position for draft notes; and replying (not supported) to draft notes. --- cmd/draft_notes.go | 6 ++-- cmd/draft_notes_test.go | 2 +- lua/gitlab/actions/common.lua | 43 +++++++++++++++++++++---- lua/gitlab/actions/discussions/init.lua | 4 +++ lua/gitlab/actions/draft_notes/init.lua | 8 +++-- lua/gitlab/indicators/diagnostics.lua | 4 +-- 6 files changed, 53 insertions(+), 14 deletions(-) diff --git a/cmd/draft_notes.go b/cmd/draft_notes.go index f002a5e9..6d8518f9 100644 --- a/cmd/draft_notes.go +++ b/cmd/draft_notes.go @@ -22,7 +22,8 @@ type PostDraftNoteRequest struct { } type UpdateDraftNoteRequest struct { - Note string `json:"note"` + Note string `json:"note"` + Position gitlab.PositionOptions } type DraftNotePublishRequest struct { @@ -242,7 +243,8 @@ func (a *api) updateDraftNote(w http.ResponseWriter, r *http.Request) { } opt := gitlab.UpdateDraftNoteOptions{ - Note: &updateDraftNoteRequest.Note, + Note: &updateDraftNoteRequest.Note, + Position: &updateDraftNoteRequest.Position, } draftNote, res, err := a.client.UpdateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id, &opt) diff --git a/cmd/draft_notes_test.go b/cmd/draft_notes_test.go index ed7cc5ac..d4506d47 100644 --- a/cmd/draft_notes_test.go +++ b/cmd/draft_notes_test.go @@ -106,7 +106,7 @@ func updateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.Up func TestEditDraftNote(t *testing.T) { t.Run("Edits draft note", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", UpdateDraftNoteRequest{Note: "Some new note"}) + request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", UpdateDraftNoteRequest{Note: "Some new note", Position: gitlab.PositionOptions{}}) server, _ := createRouterAndApi(fakeClient{updateDraftNote: updateDraftNote}) data := serveRequest(t, server, request, SuccessResponse{}) assert(t, data.Message, "Draft note updated") diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua index 23e77646..25ab394f 100644 --- a/lua/gitlab/actions/common.lua +++ b/lua/gitlab/actions/common.lua @@ -4,6 +4,7 @@ local List = require("gitlab.utils.list") local u = require("gitlab.utils") local reviewer = require("gitlab.reviewer") +local indicators_common = require("gitlab.indicators.common") local common_indicators = require("gitlab.indicators.common") local state = require("gitlab.state") local M = {} @@ -176,7 +177,7 @@ end ---Takes a node and returns the line where the note is positioned in the new SHA. If ---the line is not in the new SHA, returns nil ----@param node any +---@param node NuiTree.Node ---@return number|nil local function get_new_line(node) ---@type GitlabLineRange|nil @@ -191,7 +192,7 @@ end ---Takes a node and returns the line where the note is positioned in the old SHA. If ---the line is not in the old SHA, returns nil ----@param node any +---@param node NuiTree.Node ---@return number|nil local function get_old_line(node) ---@type GitlabLineRange|nil @@ -204,6 +205,36 @@ local function get_old_line(node) return start_old_line end +---@param id string|integer +---@return integer|nil +M.get_line_number = function(id) + ---@type Discussion|DraftNote|nil + local d_or_n + d_or_n = List.new(state.DISCUSSION_DATA.discussions or {}):find(function(d) + return d.id == id + end) or List.new(state.DRAFT_NOTES or {}):find(function(d) + return d.id == id + end) + + if d_or_n == nil then + return + end + + local first_note = indicators_common.get_first_note(d_or_n) + return (indicators_common.is_new_sha(d_or_n) and first_note.position.new_line or first_note.position.old_line) or 1 +end + +---@param root_node NuiTree.Node +---@return integer|nil +M.get_line_number_from_node = function(root_node) + if root_node.range then + local start_old_line, start_new_line = common_indicators.parse_line_code(root_node.range.start.line_code) + return root_node.old_line and start_old_line or start_new_line + else + return M.get_line_number(root_node.id) + 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(tree, callback) local node = tree:get_node() @@ -212,10 +243,10 @@ M.jump_to_reviewer = function(tree, callback) u.notify("Could not get discussion node", vim.log.levels.ERROR) return end - local line_number = (root_node.new_line or root_node.old_line or 1) - if root_node.range then - local start_old_line, start_new_line = common_indicators.parse_line_code(root_node.range.start.line_code) - line_number = root_node.old_line and start_old_line or start_new_line + local line_number = M.get_line_number_from_node(root_node) + if line_number == nil then + u.notify("Could not get line number", vim.log.levels.ERROR) + return end reviewer.jump(root_node.file_name, line_number, root_node.old_line == nil) callback() diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 22412f46..adfbe56b 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -215,6 +215,10 @@ 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(tree) + if M.is_draft_note(tree) then + u.notify("Gitlab does not support replying to draft notes", vim.log.levels.WARN) + return + end local reply_popup = Popup(u.create_popup_state("Reply", state.settings.popup.reply)) local node = tree:get_node() local discussion_node = common.get_root_node(tree, node) diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index 7ab85f16..6ea4aa34 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -85,11 +85,15 @@ end ---Send edits will actually send the edits to Gitlab and refresh the draft_notes tree M.send_edits = function(note_id) return function(text) - local body = { note = text } + local all_notes = List.new(state.DRAFT_NOTES) + local the_note = all_notes:find(function(note) + return note.id == note_id + end) + local body = { note = text, position = the_note.position } job.run_job(string.format("/mr/draft_notes/%d", note_id), "PATCH", body, function(data) u.notify(data.message, vim.log.levels.INFO) local has_position = false - local new_draft_notes = List.new(state.DRAFT_NOTES):map(function(note) + local new_draft_notes = all_notes:map(function(note) if note.id == note_id then has_position = M.has_position(note) note.note = text diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index b6e7b8b8..f263df77 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -51,9 +51,7 @@ end ---@param d_or_n Discussion|DraftNote ---@return Diagnostic local create_single_line_diagnostic = function(d_or_n) - local first_note = indicators_common.get_first_note(d_or_n) - local linnr = (indicators_common.is_new_sha(d_or_n) and first_note.position.new_line or first_note.position.old_line) - or 1 + local linnr = actions_common.get_line_number(d_or_n.id) return create_diagnostic({ lnum = linnr - 1, }, d_or_n) From 681c29ee87e47d6c51322ec8a735b7619030cadb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sun, 21 Apr 2024 17:40:45 +0200 Subject: [PATCH 45/97] Feat: Check feature branch is up-to-date on remote (#278) feat: check the up-to-date status of the remote feature branch before creating a MR, starting a review, or opening the MR summary --- lua/gitlab/actions/create_mr.lua | 4 ++ lua/gitlab/actions/summary.lua | 3 ++ lua/gitlab/git.lua | 69 ++++++++++++++++++++++++++++++++ lua/gitlab/reviewer/init.lua | 2 + lua/gitlab/utils/init.lua | 43 ++++---------------- 5 files changed, 85 insertions(+), 36 deletions(-) diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua index 5c82936c..72431b41 100644 --- a/lua/gitlab/actions/create_mr.lua +++ b/lua/gitlab/actions/create_mr.lua @@ -43,6 +43,10 @@ end --- continue working on it. ---@param args? Mr M.start = function(args) + if not git.current_branch_up_to_date_on_remote("ERROR") then + return + end + if M.started then vim.ui.select({ "Yes", "No" }, { prompt = "Continue your previous MR?" }, function(choice) if choice == "Yes" then diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index d6fb99da..0f68b554 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -3,6 +3,7 @@ -- send edits to the description back to Gitlab local Layout = require("nui.layout") local Popup = require("nui.popup") +local git = require("gitlab.git") local job = require("gitlab.job") local common = require("gitlab.actions.common") local u = require("gitlab.utils") @@ -70,6 +71,8 @@ M.summary = function() vim.api.nvim_set_current_buf(description_popup.bufnr) end) + + git.current_branch_up_to_date_on_remote("WARN") end -- Builds a lua list of strings that contain metadata about the current MR. Only builds the diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index 5a12fd8e..6a5b6710 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -1,3 +1,5 @@ +local List = require("gitlab.utils.list") + local M = {} M.has_clean_tree = function() @@ -12,4 +14,71 @@ M.switch_branch = function(branch) return vim.fn.trim(vim.fn.system({ "git", "checkout", "-q", branch })) end +---Return the name of the current branch +---@return string|nil +M.get_current_branch = function() + local handle = io.popen("git branch --show-current 2>&1") + if handle then + return handle:read() + else + require("gitlab.utils").notify("Error running 'git branch' command.", vim.log.levels.ERROR) + end +end + +---Return the list of names of all remote-tracking branches or an empty list. +---@return table +M.get_all_remote_branches = function() + local all_branches = {} + local handle = io.popen("git branch -r 2>&1") + if not handle then + require("gitlab.utils").notify("Error running 'git branch' command.", vim.log.levels.ERROR) + return all_branches + end + + for line in handle:lines() do + table.insert(all_branches, line) + end + handle:close() + + return List.new(all_branches) + :map(function(line) + -- Trim "origin/" + return line:match("origin/(%S+)") + end) + :filter(function(branch) + -- Don't include the HEAD pointer + return not branch:match("^HEAD$") + end) +end + +---Returns true if `branch` is up-to-date on remote, false otherwise. +---@return boolean|nil +M.current_branch_up_to_date_on_remote = function(log_level) + local current_branch = M.get_current_branch() + local handle = io.popen("git branch -r --contains " .. current_branch .. " 2>&1") + if not handle then + require("gitlab.utils").notify("Error running 'git branch' command.", vim.log.levels.ERROR) + return nil + end + + local remote_branches_with_current_head = {} + for line in handle:lines() do + table.insert(remote_branches_with_current_head, line) + end + handle:close() + + local current_head_on_remote = List.new(remote_branches_with_current_head):filter(function(line) + return line == " origin/" .. current_branch + end) + local remote_up_to_date = #current_head_on_remote == 1 + + if not remote_up_to_date then + require("gitlab.utils").notify( + "You have local commits that are not on origin. Have you forgotten to push?", + vim.log.levels[log_level] + ) + end + return remote_up_to_date +end + return M diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 08efc099..580f3d95 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -86,6 +86,8 @@ M.open = function() discussions.close() require("gitlab").toggle_discussions() -- Fetches data and opens discussions end + + git.current_branch_up_to_date_on_remote("WARN") end -- Closes the reviewer and cleans up diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 49608c9e..f45e93f7 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -1,3 +1,4 @@ +local git = require("gitlab.git") local List = require("gitlab.utils.list") local has_devicons, devicons = pcall(require, "nvim-web-devicons") local M = {} @@ -656,46 +657,16 @@ M.make_comma_separated_readable = function(str) return string.gsub(str, ",", ", ") end ----Return the name of the current branch ----@return string|nil -M.get_current_branch = function() - local handle = io.popen("git branch --show-current 2>&1") - if handle then - return handle:read() - else - M.notify("Error running 'git branch' command.", vim.log.levels.ERROR) - end -end - ----Return the list of names of all remote-tracking branches +---Return the list of possible merge targets. +---@return table|nil M.get_all_merge_targets = function() - local handle = io.popen("git branch -r 2>&1") - if not handle then - M.notify("Error running 'git branch' command.", vim.log.levels.ERROR) - return - end - - local current_branch = M.get_current_branch() + local current_branch = git.get_current_branch() if not current_branch then return end - - local lines = {} - for line in handle:lines() do - table.insert(lines, line) - end - handle:close() - - -- Trim "origin/" and don't include the HEAD pointer - local branches = List.new(lines) - :map(function(line) - return line:match("origin/(%S+)") - end) - :filter(function(branch) - return not branch:match("^HEAD$") and branch ~= current_branch - end) - - return branches + return List.new(git.get_all_remote_branches()):filter(function(branch) + return branch ~= current_branch + end) end ---Select a git branch and perform callback with the branch as an argument From fa16905fab17f5f655f959d469a22d0ef5cbf91b Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sun, 21 Apr 2024 20:47:32 -0400 Subject: [PATCH 46/97] feat: wrap CLI commands, toggle draft/live mode (#280) feat: better error checking in git commands feat: allow toggling between live mode and draft mode, adds "D" keybinding to discussion tree --- README.md | 9 ++- cmd/main.go | 3 +- doc/gitlab.nvim.txt | 11 ++-- lua/gitlab/actions/comment.lua | 9 ++- lua/gitlab/actions/common.lua | 5 +- lua/gitlab/actions/create_mr.lua | 7 +- lua/gitlab/actions/discussions/init.lua | 4 ++ lua/gitlab/actions/discussions/winbar.lua | 26 +++++++- lua/gitlab/actions/merge_requests.lua | 10 +-- lua/gitlab/actions/pipeline.lua | 5 +- lua/gitlab/actions/summary.lua | 2 +- lua/gitlab/git.lua | 80 +++++++++++++++++------ lua/gitlab/reviewer/init.lua | 7 +- lua/gitlab/state.lua | 31 +++++---- lua/gitlab/utils/init.lua | 25 ++++--- 15 files changed, 153 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 67110f21..980f77b4 100644 --- a/README.md +++ b/README.md @@ -93,13 +93,13 @@ gitlab_url=https://my-personal-gitlab-instance.com/ The plugin will look for the `.gitlab.nvim` file in the root of the current project by default. However, you may provide a custom path to the configuration file via the `config_path` option. This must be an absolute path to the directory that holds your `.gitlab.nvim` file. In case even more control over the auth config is needed, there is the possibility to override the `auth_provider` settings field. It should be -a function that returns the `token` as well as the `gitlab_url` value. If the `gitlab_url` is `nil`, `https://gitlab.com` is used as default. +a function that returns the `token` as well as the `gitlab_url` value, and a nilable error. If the `gitlab_url` is `nil`, `https://gitlab.com` is used as default. Here an example how to use a custom `auth_provider`: ```lua require("gitlab").setup({ auth_provider = function() - return "my_token", "https://custom.gitlab.instance.url" + return "my_token", "https://custom.gitlab.instance.url", nil end, } ``` @@ -176,12 +176,11 @@ require("gitlab").setup({ unresolved = '-', -- Symbol to show next to unresolved discussions tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name" + draft_mode = false, -- Whether comments are posted as drafts as part of a review + toggle_draft_mode = "D" -- Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately) winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) -- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar. }, - comments = { - default_to_draft = false, -- Whether to default a comment to a "draft" or not in the popup - }, choose_merge_request = { open_reviewer = true, -- Open the reviewer window automatically after switching merge requests }, diff --git a/cmd/main.go b/cmd/main.go index 5c072e3a..02e1fae6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,9 +5,10 @@ import ( ) func main() { + log.SetFlags(0) gitInfo, err := extractGitInfo(RefreshProjectInfo, GetProjectUrlFromNativeGitCmd, GetCurrentBranchNameFromNativeGitCmd) if err != nil { - log.Fatalf("Failure initializing plugin with `git` commands: %v", err) + log.Fatalf("Failure initializing plugin: %v", err) } err, client := initGitlabClient() diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index eff3bb78..6f5f2196 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -124,14 +124,16 @@ configure your connection to Gitlab. In case even more control over the auth config is needed, there is the possibility to override the `auth_provider` settings field. It should be -a function that returns the `token` as well as the `gitlab_url` value. +a function that returns the `token` as well as the `gitlab_url` value and +a nilable error value. + If the `gitlab_url` is `nil`, `https://gitlab.com` is used as default. Here an example how to use a custom `auth_provider`: >lua require("gitlab").setup({ auth_provider = function() - return "my_token", "https://custom.gitlab.instance.url" + return "my_token", "https://custom.gitlab.instance.url", nil end, } < @@ -199,12 +201,11 @@ you call this function with no values the defaults will be used: unresolved = '-', -- Symbol to show next to unresolved discussions tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name" + draft_mode = false, -- Whether comments are posted as drafts as part of a review + toggle_draft_mode = "D" -- Toggle between draft mode and regular mode, where comments are posted immediately winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) -- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar. }, - comments = { - default_to_draft = false, -- Whether to default a comment to a "draft" or not in the popup - }, choose_merge_request = { open_reviewer = true, -- Open the reviewer window automatically after switching merge requests }, diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 36fda7dd..d66ffb2e 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -140,8 +140,8 @@ local function create_comment_layout(opts) end, miscellaneous.attach_file, popup_opts) vim.schedule(function() - local default_to_draft = state.settings.comments.default_to_draft - vim.api.nvim_buf_set_lines(M.draft_popup.bufnr, 0, -1, false, { u.bool_to_string(default_to_draft) }) + 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 @@ -150,7 +150,10 @@ 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 = git.has_clean_tree() + local has_clean_tree, err = git.has_clean_tree() + if err ~= nil then + return + end local is_modified = vim.api.nvim_buf_get_option(0, "modified") if state.settings.reviewer_settings.diffview.imply_local and (is_modified or not has_clean_tree) then u.notify( diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua index 25ab394f..a5046dbd 100644 --- a/lua/gitlab/actions/common.lua +++ b/lua/gitlab/actions/common.lua @@ -32,10 +32,7 @@ end ---@param content string ---@return table M.build_content = function(content) - local description_lines = {} - for line in u.split_by_new_lines(content) do - table.insert(description_lines, line) - end + local description_lines = u.lines_into_table(content) table.insert(description_lines, "") return description_lines end diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua index 72431b41..5f214b15 100644 --- a/lua/gitlab/actions/create_mr.lua +++ b/lua/gitlab/actions/create_mr.lua @@ -43,7 +43,7 @@ end --- continue working on it. ---@param args? Mr M.start = function(args) - if not git.current_branch_up_to_date_on_remote("ERROR") then + if not git.current_branch_up_to_date_on_remote(vim.log.levels.ERROR) then return end @@ -87,7 +87,10 @@ M.pick_target = function(mr) end local function make_template_path(t) - local base_dir = git.base_dir() + local base_dir, err = git.base_dir() + if err ~= nil then + return + end return base_dir .. state.settings.file_separator .. ".gitlab" diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index adfbe56b..b5415a58 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -528,6 +528,10 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) M.delete_comment(tree) end end, { buffer = bufnr, desc = "Delete comment" }) + vim.keymap.set("n", state.settings.discussion_tree.toggle_draft_mode, function() + state.settings.discussion_tree.draft_mode = not state.settings.discussion_tree.draft_mode + winbar.update_winbar() + end, { buffer = bufnr, desc = "Toggle between draft mode and live mode" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_resolved, function() if M.is_current_node_note(tree) and not M.is_draft_note(tree) then M.toggle_discussion_resolved(tree) diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index 4a1edcd3..ee0abd56 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -125,10 +125,32 @@ M.make_winbar = function(t) notes_title = "%#Text#" .. notes_title end + local mode = M.get_mode() + -- Join everything together and return it local separator = "%#Comment#|" - local help = "%#Comment#%=Help: " .. t.help_keymap:gsub(" ", "") .. " " - return string.format(" %s %s %s %s", discussion_title, separator, notes_title, help) + local end_section = "%=" + local help = "%#Comment#Help: " .. t.help_keymap:gsub(" ", "") .. " " + return string.format( + " %s %s %s %s %s %s %s", + discussion_title, + separator, + notes_title, + end_section, + mode, + separator, + help + ) +end + +---Returns a string for the winbar indicating the mode type, live or draft +---@return string +M.get_mode = function() + if state.settings.discussion_tree.draft_mode then + return "%#DiagnosticWarn#Draft Mode" + else + return "%#DiagnosticOK#Live Mode" + end end ---Sets the current view type (if provided an argument) diff --git a/lua/gitlab/actions/merge_requests.lua b/lua/gitlab/actions/merge_requests.lua index 881321cc..127d380c 100644 --- a/lua/gitlab/actions/merge_requests.lua +++ b/lua/gitlab/actions/merge_requests.lua @@ -10,7 +10,10 @@ local M = {} ---Opens up a select menu that lets you choose a different merge request. ---@param opts SwitchOpts|nil M.choose_merge_request = function(opts) - if not git.has_clean_tree() then + local has_clean_tree, clean_tree_err = git.has_clean_tree() + if clean_tree_err ~= nil then + return + elseif has_clean_tree ~= "" then u.notify("Your local branch has changes, please stash or commit and push", vim.log.levels.ERROR) return end @@ -34,9 +37,8 @@ M.choose_merge_request = function(opts) end vim.schedule(function() - local err = git.switch_branch(choice.source_branch) - if err ~= "" then - u.notify(err, vim.log.levels.ERROR) + local _, branch_switch_err = git.switch_branch(choice.source_branch) + if branch_switch_err ~= nil then return end diff --git a/lua/gitlab/actions/pipeline.lua b/lua/gitlab/actions/pipeline.lua index 045fa73e..bddc1949 100644 --- a/lua/gitlab/actions/pipeline.lua +++ b/lua/gitlab/actions/pipeline.lua @@ -143,10 +143,7 @@ M.see_logs = function() return end - local lines = {} - for line in u.split_by_new_lines(file) do - table.insert(lines, line) - end + local lines = u.lines_into_table(file) if #lines == 0 then u.notify("Log trace lines could not be parsed", vim.log.levels.ERROR) diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 0f68b554..5d35dc11 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -72,7 +72,7 @@ M.summary = function() vim.api.nvim_set_current_buf(description_popup.bufnr) end) - git.current_branch_up_to_date_on_remote("WARN") + git.current_branch_up_to_date_on_remote(vim.log.levels.WARN) end -- Builds a lua list of strings that contain metadata about the current MR. Only builds the diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index 6a5b6710..503564e3 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -2,45 +2,75 @@ local List = require("gitlab.utils.list") local M = {} +---Runs a system command, captures the output (if it exists) and handles errors +---@param command table +---@return string|nil, string|nil +local run_system = function(command) + local u = require("gitlab.utils") + local result = vim.fn.trim(vim.fn.system(command)) + if vim.v.shell_error ~= 0 then + u.notify(result, vim.log.levels.ERROR) + return nil, result + end + return result, nil +end + +---Returns all branches for the current repository +---@return string|nil, string|nil +M.branches = function() + return run_system({ "git", "branch" }) +end + +---Checks whether the tree has any changes that haven't been pushed to the remote +---@return string|nil, string|nil M.has_clean_tree = function() - return vim.fn.trim(vim.fn.system({ "git", "status", "--short", "--untracked-files=no" })) == "" + return run_system({ "git", "status", "--short", "--untracked-files=no" }) end +---Gets the base directory of the current project +---@return string|nil, string|nil M.base_dir = function() - return vim.fn.trim(vim.fn.system({ "git", "rev-parse", "--show-toplevel" })) + return run_system({ "git", "rev-parse", "--show-toplevel" }) end +---Switches the current project to the given branch +---@return string|nil, string|nil M.switch_branch = function(branch) - return vim.fn.trim(vim.fn.system({ "git", "checkout", "-q", branch })) + return run_system({ "git", "checkout", "-q", branch }) end ---Return the name of the current branch ----@return string|nil +---@return string|nil, string|nil M.get_current_branch = function() - local handle = io.popen("git branch --show-current 2>&1") - if handle then - return handle:read() - else - require("gitlab.utils").notify("Error running 'git branch' command.", vim.log.levels.ERROR) + return run_system({ "git", "branch", "--show-current" }) +end + +---Return the list of possible merge targets. +---@return table|nil +M.get_all_merge_targets = function() + local current_branch, err = M.get_current_branch() + if not current_branch or err ~= nil then + return end + return List.new(M.get_all_remote_branches()):filter(function(branch) + return branch ~= current_branch + end) end ---Return the list of names of all remote-tracking branches or an empty list. ----@return table +---@return table, string|nil M.get_all_remote_branches = function() - local all_branches = {} - local handle = io.popen("git branch -r 2>&1") - if not handle then - require("gitlab.utils").notify("Error running 'git branch' command.", vim.log.levels.ERROR) - return all_branches + local all_branches, err = M.branches() + if err ~= nil then + return {}, err end - - for line in handle:lines() do - table.insert(all_branches, line) + if all_branches == nil then + return {}, "Something went wrong getting branches for this repository" end - handle:close() - return List.new(all_branches) + local u = require("gitlab.utils") + local lines = u.lines_into_table(all_branches) + return List.new(lines) :map(function(line) -- Trim "origin/" return line:match("origin/(%S+)") @@ -51,7 +81,15 @@ M.get_all_remote_branches = function() end) end +---Return whether something +---@param current_branch string +---@return string|nil, string|nil +M.contains_branch = function(current_branch) + return run_system({ "git", "branch", "-r", "--contains", current_branch }) +end + ---Returns true if `branch` is up-to-date on remote, false otherwise. +---@param log_level integer ---@return boolean|nil M.current_branch_up_to_date_on_remote = function(log_level) local current_branch = M.get_current_branch() @@ -75,7 +113,7 @@ M.current_branch_up_to_date_on_remote = function(log_level) if not remote_up_to_date then require("gitlab.utils").notify( "You have local commits that are not on origin. Have you forgotten to push?", - vim.log.levels[log_level] + log_level ) end return remote_up_to_date diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 580f3d95..a3ba61f4 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -42,7 +42,10 @@ M.open = function() end local diffview_open_command = "DiffviewOpen" - local has_clean_tree = git.has_clean_tree() + local has_clean_tree, err = git.has_clean_tree() + if err ~= nil then + return + end if state.settings.reviewer_settings.diffview.imply_local and has_clean_tree then diffview_open_command = diffview_open_command .. " --imply-local" end @@ -87,7 +90,7 @@ M.open = function() require("gitlab").toggle_discussions() -- Fetches data and opens discussions end - git.current_branch_up_to_date_on_remote("WARN") + git.current_branch_up_to_date_on_remote(vim.log.levels.WARN) end -- Closes the reviewer and cleans up diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 7f579c22..c58db7a3 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -3,21 +3,22 @@ -- This module is also responsible for ensuring that the state of the plugin -- is valid via dependencies +local git = require("gitlab.git") local u = require("gitlab.utils") local M = {} M.emoji_map = nil +---Returns a gitlab token, and a gitlab URL. Used to connect to gitlab. +---@return string|nil, string|nil, string|nil M.default_auth_provider = function() - local base_path - if M.settings.config_path ~= nil then - base_path = M.settings.config_path - else - base_path = vim.fn.trim(vim.fn.system({ "git", "rev-parse", "--show-toplevel" })) - if vim.v.shell_error ~= 0 then - u.notify(string.format("Could not get base directory: %s", base_path), vim.log.levels.ERROR) - return false - end + local base_path, err = M.settings.config_path, nil + if base_path == nil then + base_path, err = git.base_dir() + end + + if err ~= nil then + return "", "" end local config_file_path = base_path .. M.settings.file_separator .. ".gitlab.nvim" @@ -36,7 +37,7 @@ M.default_auth_provider = function() local auth_token = file_properties.auth_token or os.getenv("GITLAB_TOKEN") local gitlab_url = file_properties.gitlab_url or os.getenv("GITLAB_URL") - return auth_token, gitlab_url + return auth_token, gitlab_url, err end -- These are the default settings for the plugin @@ -108,9 +109,8 @@ M.settings = { unresolved = "-", tree_type = "simple", toggle_tree_type = "i", - }, - comments = { - default_to_draft = false, + toggle_draft_mode = "D", + draft_mode = false, }, create_mr = { target = nil, @@ -243,7 +243,10 @@ M.setPluginConfiguration = function() return true end - local token, url = M.settings.auth_provider() + local token, url, err = M.settings.auth_provider() + if err ~= nil then + return + end M.settings.auth_token = token M.settings.gitlab_url = u.trim_slash(url or "https://gitlab.com") diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index f45e93f7..dd9a4656 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -203,6 +203,17 @@ M.split_by_new_lines = function(s) return s:gmatch("(.-)\n") -- Match 0 or more (as few as possible) characters followed by a new line. end +---Takes a string of lines and returns a table of lines +---@param s string The string to parse +---@return table +M.lines_into_table = function(s) + local lines = {} + for line in M.split_by_new_lines(s) do + table.insert(lines, line) + end + return lines +end + -- Reverses the order of elements in a list ---@param list table The list to reverse ---@return table @@ -657,22 +668,10 @@ M.make_comma_separated_readable = function(str) return string.gsub(str, ",", ", ") end ----Return the list of possible merge targets. ----@return table|nil -M.get_all_merge_targets = function() - local current_branch = git.get_current_branch() - if not current_branch then - return - end - return List.new(git.get_all_remote_branches()):filter(function(branch) - return branch ~= current_branch - end) -end - ---Select a git branch and perform callback with the branch as an argument ---@param cb function The callback to perform with the selected branch M.select_target_branch = function(cb) - local all_branch_names = M.get_all_merge_targets() + local all_branch_names = git.get_all_merge_targets() if not all_branch_names then return end From 8affee7f6fd5bf4dca317be4dd12d81566bebcf9 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Mon, 22 Apr 2024 16:32:01 -0400 Subject: [PATCH 47/97] fix: Update readme w/ correct packer installation (#285) fix: We require some state from Packer, this fixes the docs to reflect a way to load that state prior to running the gitlab plugin. Fixes #94. --- README.md | 33 +++++++++++++++++++-------------- doc/gitlab.nvim.txt | 33 +++++++++++++++++++-------------- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 980f77b4..063517f2 100644 --- a/README.md +++ b/README.md @@ -56,20 +56,25 @@ return { And with Packer: ```lua -use { - 'harrisoncramer/gitlab.nvim', - requires = { - "MunifTanjim/nui.nvim", - "nvim-lua/plenary.nvim", - "sindrets/diffview.nvim", - "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. - "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. - }, - run = function() require("gitlab.server").build(true) end, - config = function() - require("gitlab").setup() - end, -} + use { + "harrisoncramer/gitlab.nvim", + requires = { + "MunifTanjim/nui.nvim", + "nvim-lua/plenary.nvim", + "sindrets/diffview.nvim" + "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. + "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. + }, + build = function() + require("gitlab.server").build() + end, + branch = "develop", + config = function() + require("diffview") -- We require some global state from diffview + local gitlab = require("gitlab") + gitlab.setup() + end, + } ``` ## Connecting to Gitlab diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 6f5f2196..435b4837 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -78,20 +78,25 @@ With Lazy: < And with Packer: >lua - use { - 'harrisoncramer/gitlab.nvim', - requires = { - "MunifTanjim/nui.nvim", - "nvim-lua/plenary.nvim", - "sindrets/diffview.nvim", - "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. - "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. - }, - run = function() require("gitlab.server").build(true) end, - config = function() - require("gitlab").setup() - end, - } + use { + "harrisoncramer/gitlab.nvim", + requires = { + "MunifTanjim/nui.nvim", + "nvim-lua/plenary.nvim", + "sindrets/diffview.nvim" + "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. + "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. + }, + build = function() + require("gitlab.server").build() + end, + branch = "develop", + config = function() + require("diffview") -- We require some global state from diffview + local gitlab = require("gitlab") + gitlab.setup() + end, + } < CONNECTING TO GITLAB *gitlab.nvim.connecting-to-gitlab* From 9c22fd1734e7de6ca7afc688367bad780028bd87 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Mon, 22 Apr 2024 16:32:54 -0400 Subject: [PATCH 48/97] fix: non-existent buffer comments (#284) fix: do not allow line-linked comments on non-existent buffers --- lua/gitlab/actions/comment.lua | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index d66ffb2e..d811fa1a 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -163,6 +163,10 @@ M.create_comment = function() return end + if not M.sha_exists() then + return + end + local layout = create_comment_layout() layout:mount() end @@ -173,6 +177,9 @@ M.create_multiline_comment = function() if not u.check_visual_mode() then return end + if not M.sha_exists() then + return + end local layout = create_comment_layout({ ranged = true, unlinked = false }) layout:mount() @@ -230,6 +237,9 @@ 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() @@ -242,4 +252,15 @@ M.create_comment_suggestion = function() 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 From 8a546affea87781eb0ed66beacd891b592c8765c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Tue, 23 Apr 2024 20:59:07 +0200 Subject: [PATCH 49/97] Feat: Make toggle_draft_mode available outside discussion tree (#281) * Feat: Make toggle_draft_mode available outside discussion tree --- README.md | 1 + doc/gitlab.nvim.txt | 20 +++++++++++++++----- lua/gitlab/actions/discussions/init.lua | 9 +++++++-- lua/gitlab/init.lua | 3 +-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 063517f2..1f9ad5c6 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,7 @@ vim.keymap.set("n", "glo", gitlab.open_in_browser) vim.keymap.set("n", "glM", gitlab.merge) vim.keymap.set("n", "glu", gitlab.copy_mr_url) vim.keymap.set("n", "glP", gitlab.publish_all_drafts) +vim.keymap.set("n", "glD", gitlab.toggle_draft_mode) ``` For more information about each of these commands, and about the APIs in general, run `:h gitlab.nvim.api` diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 435b4837..5526fd42 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -326,13 +326,16 @@ Just like the summary, all the different kinds of comments are saved via the DRAFT NOTES *gitlab.nvim.draft-comments* -When you publish a "draft" of any of the above resources (configurable via the -`state.settings.comments.default_to_draft` setting) the comment will be added -to a review. You may publish all draft comments via the `gitlab.publish_all_drafts()` -function, and you can publish an individual comment or note by pressing the +When you publish a "draft" of any of the above resources, the comment will be +added to a review. You can configure the default commenting mode (draft vs +live) via the `state.settings.discussion_tree.draft_mode` setting, and you can +toggle the setting with the `state.settings.discussion_tree.toggle_draft_mode` +keybinding, or by calling the `gitlab.toggle_draft_mode()` function. You may +publish all draft comments via the `gitlab.publish_all_drafts()` function, and +you can publish an individual comment or note by pressing the `state.settings.discussion_tree.publish_draft` keybinding. -Draft notes do not support editing, replying, or emojis. +Draft notes do not support replying or emojis. TEMPORARY REGISTERS *gitlab.nvim.temp-registers* @@ -565,6 +568,7 @@ in normal mode): vim.keymap.set("n", "glM", gitlab.merge) vim.keymap.set("n", "glu", gitlab.copy_mr_url) vim.keymap.set("n", "glP", gitlab.publish_all_drafts) + vim.keymap.set("n", "glD", gitlab.toggle_draft_mode) < TROUBLESHOOTING *gitlab.nvim.troubleshooting* @@ -769,6 +773,12 @@ comments visible. >lua require("gitlab").publish_all_drafts() < + *gitlab.nvim.toggle_draft_mode* +gitlab.toggle_draft_mode() ~ + +Toggles between draft mode, where comments and notes are added to a review as +drafts, and regular (or live) mode, where comments are posted immediately. + *gitlab.nvim.add_assignee* gitlab.add_assignee() ~ diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index b5415a58..3894bf54 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -529,8 +529,7 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) end end, { buffer = bufnr, desc = "Delete comment" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_draft_mode, function() - state.settings.discussion_tree.draft_mode = not state.settings.discussion_tree.draft_mode - winbar.update_winbar() + M.toggle_draft_mode() end, { buffer = bufnr, desc = "Toggle between draft mode and live mode" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_resolved, function() if M.is_current_node_note(tree) and not M.is_draft_note(tree) then @@ -663,6 +662,12 @@ M.toggle_tree_type = function() M.rebuild_discussion_tree() end +---Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately) +M.toggle_draft_mode = function() + state.settings.discussion_tree.draft_mode = not state.settings.discussion_tree.draft_mode + winbar.update_winbar() +end + ---Indicates whether the node under the cursor is a draft note or not ---@param tree NuiTree ---@return boolean diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 1c9bc8ea..e43b9230 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -74,9 +74,8 @@ return { draft_notes_dep, discussion_data, }, discussions.toggle), - toggle_resolved = async.sequence({ info }, discussions.toggle_discussion_resolved), + toggle_draft_mode = discussions.toggle_draft_mode, publish_all_drafts = draft_notes.publish_all_drafts, - reply = async.sequence({ info }, discussions.reply), -- Other functions 🤷 state = state, data = data.data, From 80fbceeb2e3182079520474e569995d0b132ede0 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Tue, 23 Apr 2024 15:01:25 -0400 Subject: [PATCH 50/97] feat: allow refreshing discussion data (#288) * feat: implements refresh function for discussion tree view * fix: corrects collapsing tree nodes on create/edit --- README.md | 1 + doc/gitlab.nvim.txt | 1 + lua/gitlab/actions/comment.lua | 105 +++++-- lua/gitlab/actions/common.lua | 2 +- lua/gitlab/actions/discussions/init.lua | 390 +++++++++--------------- lua/gitlab/actions/discussions/tree.lua | 10 + lua/gitlab/actions/draft_notes/init.lua | 132 +++----- lua/gitlab/actions/summary.lua | 5 +- lua/gitlab/indicators/diagnostics.lua | 4 +- lua/gitlab/init.lua | 5 + lua/gitlab/state.lua | 22 +- lua/gitlab/utils/init.lua | 2 +- 12 files changed, 318 insertions(+), 361 deletions(-) diff --git a/README.md b/README.md index 1f9ad5c6..1a4be079 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ require("gitlab").setup({ jump_to_reviewer = "m", -- Jump to the location in the reviewer window edit_comment = "e", -- Edit comment delete_comment = "dd", -- Delete comment + refresh_data = "a", -- Refreshes the data in the view by hitting Gitlab's APIs again reply = "r", -- Reply to comment toggle_node = "t", -- Opens or closes the discussion add_emoji = "Ea" -- Add an emoji to the note/comment diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 5526fd42..140b94ff 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -189,6 +189,7 @@ you call this function with no values the defaults will be used: jump_to_reviewer = "m", -- Jump to the location in the reviewer window edit_comment = "e", -- Edit comment delete_comment = "dd", -- Delete comment + refresh_data = "a", -- Refreshes the data in the view by hitting Gitlab's APIs again reply = "r", -- Reply to comment toggle_node = "t", -- Opens or closes the discussion toggle_all_discussions = "T", -- Open or close separately both resolved and unresolved discussions diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index d811fa1a..31cb35d1 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -17,31 +17,53 @@ 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.popup.perform_action keybinding ---@param text string comment text ---@param visual_range LineRange | nil range of visual selection or nil ----@param unlinked boolean | nil if true, the comment is not linked to a line -local confirm_create_comment = function(text, visual_range, unlinked) +---@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 reply to a discussion + if discussion_id ~= nil 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 then local body = { comment = text } local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment" - job.run_job(endpoint, "POST", body, function(data) + 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.add_draft_note({ draft_note = data.draft_note, unlinked = true }) + draft_notes.load_draft_notes(function() + discussions.rebuild_view(unlinked) + end) else - discussions.add_discussion({ data = data, unlinked = true }) + discussions.rebuild_view(unlinked) end - discussions.refresh() end) return end @@ -73,20 +95,54 @@ local confirm_create_comment = function(text, visual_range, unlinked) line_range = location_data.line_range, } + -- Creating a comment (linked to specific changes) local endpoint = is_draft and "/mr/draft_notes/" or "/mr/comment" - job.run_job(endpoint, "POST", body, function(data) + 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.add_draft_note({ draft_note = data.draft_note, unlinked = false }) + draft_notes.load_draft_notes(function() + discussions.rebuild_view(unlinked) + end) else - discussions.add_discussion({ data = data, has_position = true }) + discussions.rebuild_view(unlinked) end - discussions.refresh() 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 discussion_id string|nil ---@field unlinked boolean ---This function sets up the layout and popups needed to create a comment, note and @@ -94,13 +150,16 @@ end ---window panes, and for the non-primary sections. ---@param opts LayoutOpts|nil ---@return NuiLayout -local function create_comment_layout(opts) +M.create_comment_layout = function(opts) if opts == nil then opts = {} 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("Comment", state.settings.popup.comment)) + 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() @@ -128,14 +187,16 @@ local function create_comment_layout(opts) 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 text 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) + confirm_create_comment(text, range, unlinked, opts.discussion_id) vim.api.nvim_set_current_win(M.current_win) end, miscellaneous.toggle_bool, popup_opts) + ---Keybinding for focus on draft section state.set_popup_keymaps(M.comment_popup, function(text) - confirm_create_comment(text, range, unlinked) + confirm_create_comment(text, range, unlinked, opts.discussion_id) vim.api.nvim_set_current_win(M.current_win) end, miscellaneous.attach_file, popup_opts) @@ -144,6 +205,14 @@ local function create_comment_layout(opts) 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 @@ -167,7 +236,7 @@ M.create_comment = function() return end - local layout = create_comment_layout() + local layout = M.create_comment_layout({ ranged = false, unlinked = false }) layout:mount() end @@ -181,14 +250,14 @@ M.create_multiline_comment = function() return end - local layout = create_comment_layout({ ranged = true, unlinked = false }) + local layout = M.create_comment_layout({ ranged = true, 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 = create_comment_layout({ ranged = false, unlinked = true }) + local layout = M.create_comment_layout({ ranged = false, unlinked = true }) layout:mount() end @@ -243,7 +312,7 @@ M.create_comment_suggestion = function() local suggestion_lines, range_length = build_suggestion() - local layout = create_comment_layout({ ranged = range_length > 0, unlinked = false }) + local layout = M.create_comment_layout({ ranged = range_length > 0, unlinked = false }) layout:mount() vim.schedule(function() if suggestion_lines then diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua index a5046dbd..1210e1cf 100644 --- a/lua/gitlab/actions/common.lua +++ b/lua/gitlab/actions/common.lua @@ -207,7 +207,7 @@ end M.get_line_number = function(id) ---@type Discussion|DraftNote|nil local d_or_n - d_or_n = List.new(state.DISCUSSION_DATA.discussions or {}):find(function(d) + d_or_n = List.new(state.DISCUSSION_DATA and state.DISCUSSION_DATA.discussions or {}):find(function(d) return d.id == id end) or List.new(state.DRAFT_NOTES or {}):find(function(d) return d.id == id diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 3894bf54..74a21c1c 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -29,18 +29,37 @@ local M = { linked_bufnr = nil, ---@type number unlinked_bufnr = nil, - ---@type number + ---@type NuiTree|nil discussion_tree = nil, + ---@type NuiTree|nil + unlinked_discussion_tree = nil, } +---Re-fetches all discussions and re-renders the relevant view +---@param unlinked boolean +---@param all boolean|nil +M.rebuild_view = function(unlinked, all) + M.load_discussions(function() + if all then + M.rebuild_unlinked_discussion_tree() + M.rebuild_discussion_tree() + elseif unlinked then + M.rebuild_unlinked_discussion_tree() + else + M.rebuild_discussion_tree() + end + M.refresh_diagnostics_and_winbar() + end) +end + ---Makes API call to get the discussion data, stores it in the state, and calls the callback ---@param callback function|nil M.load_discussions = function(callback) - job.run_job("/mr/discussions/list", "POST", { blacklist = state.settings.discussion_tree.blacklist }, function(data) + state.load_new_state("discussion_data", function(data) state.DISCUSSION_DATA.discussions = u.ensure_table(data.discussions) state.DISCUSSION_DATA.unlinked_discussions = u.ensure_table(data.unlinked_discussions) state.DISCUSSION_DATA.emojis = u.ensure_table(data.emojis) - if type(callback) == "function" then + if callback ~= nil then callback() end end) @@ -50,7 +69,7 @@ end M.initialize_discussions = function() signs.setup_signs() reviewer.set_callback_for_file_changed(function() - M.refresh_view() + M.refresh_diagnostics_and_winbar() M.modifiable(false) end) reviewer.set_callback_for_reviewer_enter(function() @@ -77,19 +96,8 @@ M.modifiable = function(bool) end end ----Refresh discussion data, signs, diagnostics, and winbar with new data from API ---- and rebuild the entire view -M.refresh = function(cb) - M.load_discussions(function() - M.refresh_view() - if cb ~= nil then - cb() - end - end) -end - --- Take existing data and refresh the diagnostics, the winbar, and the signs -M.refresh_view = function() +M.refresh_diagnostics_and_winbar = function() if state.settings.discussion_signs.enabled then diagnostics.refresh_diagnostics() end @@ -146,7 +154,7 @@ M.toggle = function(callback) end vim.schedule(function() - M.refresh_view() + M.refresh_diagnostics_and_winbar() end) end @@ -219,74 +227,46 @@ M.reply = function(tree) u.notify("Gitlab does not support replying to draft notes", vim.log.levels.WARN) return end - local reply_popup = Popup(u.create_popup_state("Reply", state.settings.popup.reply)) + local node = tree:get_node() local discussion_node = common.get_root_node(tree, node) - local id = tostring(discussion_node.id) - reply_popup:mount() - state.set_popup_keymaps( - reply_popup, - M.send_reply(tree, id), - miscellaneous.attach_file, - miscellaneous.editable_popup_opts - ) -end - --- This function will send the reply to the Go API -M.send_reply = function(tree, discussion_id) - return function(text) - local body = { discussion_id = discussion_id, reply = text } - job.run_job("/mr/reply", "POST", body, function(data) - u.notify("Sent reply!", vim.log.levels.INFO) - M.add_reply_to_tree(tree, data.note, discussion_id) - M.load_discussions() - end) + if discussion_node == nil then + u.notify("Could not get discussion root", vim.log.levels.ERROR) + return end + + local discussion_id = tostring(discussion_node.id) + local comment = require("gitlab.actions.comment") + local unlinked = tree.bufnr == M.unlinked_bufnr + local layout = comment.create_comment_layout({ ranged = false, discussion_id = discussion_id, unlinked = unlinked }) + layout:mount() end -- This function (settings.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment -M.delete_comment = function(tree) +M.delete_comment = function(tree, unlinked) vim.ui.select({ "Confirm", "Cancel" }, { prompt = "Delete comment?", }, function(choice) if choice == "Confirm" then - M.send_deletion(tree) - end - end) -end - --- This function will actually send the deletion to Gitlab --- when you make a selection, and re-render the tree -M.send_deletion = function(tree) - local current_node = tree:get_node() - - local note_node = common.get_note_node(tree, current_node) - local root_node = common.get_root_node(tree, current_node) - if note_node == nil or root_node == nil then - u.notify("Could not get note or root node", vim.log.levels.ERROR) - return - end - - ---@type integer - local note_id = note_node.is_root and root_node.root_note_id or note_node.id + local current_node = tree:get_node() + local note_node = common.get_note_node(tree, current_node) + local root_node = common.get_root_node(tree, current_node) + if note_node == nil or root_node == nil then + u.notify("Could not get note or root node", vim.log.levels.ERROR) + return + end - if root_node.is_draft then - draft_notes.send_deletion(tree) - else - local body = { discussion_id = root_node.id, note_id = tonumber(note_id) } - job.run_job("/mr/comment", "DELETE", body, function(data) - u.notify(data.message, vim.log.levels.INFO) - if note_node.is_root then - -- Replace root node w/ current node's contents... - tree:remove_node("-" .. root_node.id) + ---@type integer + local note_id = note_node.is_root and root_node.root_note_id or note_node.id + if root_node.is_draft then + draft_notes.confirm_delete_draft_note(note_id, unlinked) else - tree:remove_node("-" .. note_id) + local comment = require("gitlab.actions.comment") + comment.confirm_delete_comment(note_id, root_node.id, unlinked) end - tree:render() - M.refresh() - end) - end + end + end) end -- This function (settings.discussion_tree.edit_comment) will open the edit popup for the current comment in the discussion tree @@ -317,41 +297,23 @@ M.edit_comment = function(tree, unlinked) -- Draft notes module handles edits for draft notes if root_node.is_draft then - state.set_popup_keymaps(edit_popup, draft_notes.send_edits(root_node.id), nil, miscellaneous.editable_popup_opts) + state.set_popup_keymaps( + edit_popup, + draft_notes.confirm_edit_draft_note(root_node.id, unlinked), + nil, + miscellaneous.editable_popup_opts + ) else + local comment = require("gitlab.actions.comment") state.set_popup_keymaps( edit_popup, - M.send_edits(tostring(root_node.id), tonumber(note_node.root_note_id or note_node.id), unlinked), + comment.confirm_edit_comment(tostring(root_node.id), tonumber(note_node.root_note_id or note_node.id), unlinked), nil, miscellaneous.editable_popup_opts ) end end ----This function sends the edited comment to the Go server ----@param discussion_id string ----@param note_id integer ----@param unlinked boolean -M.send_edits = 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) - if unlinked then - M.replace_text(state.DISCUSSION_DATA.unlinked_discussions, discussion_id, note_id, text) - M.rebuild_unlinked_discussion_tree() - else - M.replace_text(state.DISCUSSION_DATA.discussions, discussion_id, note_id, text) - M.rebuild_discussion_tree() - end - end) - end -end - -- This function (settings.discussion_tree.toggle_discussion_resolved) will toggle the resolved status of the current discussion and send the change to the Go server M.toggle_discussion_resolved = function(tree) local note = tree:get_node() @@ -374,8 +336,61 @@ M.toggle_discussion_resolved = function(tree) job.run_job("/mr/discussions/resolve", "PUT", body, function(data) u.notify(data.message, vim.log.levels.INFO) - M.redraw_resolved_status(tree, note, not note.resolved) - M.refresh() + local unlinked = tree.bufnr == M.unlinked_bufnr + M.rebuild_view(unlinked) + end) +end + +---Opens a popup prompting the user to choose an emoji to attach to the current node +---@param tree any +---@param unlinked boolean +M.add_emoji_to_note = function(tree, unlinked) + local node = tree:get_node() + local note_node = common.get_note_node(tree, node) + local root_node = common.get_root_node(tree, node) + local note_id = tonumber(note_node.is_root and root_node.root_note_id or note_node.id) + local emojis = require("gitlab.emoji").emoji_list + emoji.pick_emoji(emojis, function(name) + local body = { emoji = name, note_id = note_id } + job.run_job("/mr/awardable/note/", "POST", body, function() + u.notify("Emoji added", vim.log.levels.INFO) + M.rebuild_view(unlinked) + end) + end) +end + +---Opens a popup prompting the user to choose an emoji to remove from the current node +---@param tree any +---@param unlinked boolean +M.delete_emoji_from_note = function(tree, unlinked) + local node = tree:get_node() + local note_node = common.get_note_node(tree, node) + local root_node = common.get_root_node(tree, node) + local note_id = tonumber(note_node.is_root and root_node.root_note_id or note_node.id) + local note_id_str = tostring(note_id) + + local e = require("gitlab.emoji") + + local emojis = {} + local current_emojis = state.DISCUSSION_DATA.emojis[note_id_str] + for _, current_emoji in ipairs(current_emojis) do + if state.USER.id == current_emoji.user.id then + table.insert(emojis, e.emoji_map[current_emoji.name]) + end + end + + emoji.pick_emoji(emojis, function(name) + local awardable_id + for _, current_emoji in ipairs(current_emojis) do + if current_emoji.name == name and current_emoji.user.id == state.USER.id then + awardable_id = current_emoji.id + break + end + end + job.run_job(string.format("/mr/awardable/note/%d/%d", note_id, awardable_id), "DELETE", nil, function() + u.notify("Emoji removed", vim.log.levels.INFO) + M.rebuild_view(unlinked) + end) end) end @@ -383,13 +398,30 @@ end -- 🌲 Helper Functions -- +---Used to collect all nodes in a tree prior to rebuilding it, so that they +---can be re-expanded before render +---@param tree any +---@return table +M.gather_expanded_node_ids = function(tree) + -- Gather all nodes for later expansion, after rebuild + local ids = {} + for id, node in pairs(tree and tree.nodes.by_id or {}) do + if node._is_expanded then + table.insert(ids, id) + end + end + return ids +end + ---Rebuilds the discussion tree, which contains all comments and draft comments ---linked to specific places in the code. M.rebuild_discussion_tree = function() if M.linked_bufnr == nil then return end + local expanded_node_ids = M.gather_expanded_node_ids(M.discussion_tree) common.switch_can_edit_bufs(true, M.linked_bufnr, M.unlinked_bufnr) + vim.api.nvim_buf_set_lines(M.linked_bufnr, 0, -1, false, {}) local existing_comment_nodes = discussions_tree.add_discussions_to_table(state.DISCUSSION_DATA.discussions, false) local draft_comment_nodes = draft_notes.add_draft_notes_to_table(false) @@ -408,6 +440,10 @@ M.rebuild_discussion_tree = function() bufnr = M.linked_bufnr, prepare_node = tree_utils.nui_tree_prepare_node, }) + -- Re-expand already expanded nodes + for _, id in ipairs(expanded_node_ids) do + tree_utils.open_node_by_id(discussion_tree, id) + end discussion_tree:render() M.set_tree_keymaps(discussion_tree, M.linked_bufnr, false) @@ -423,6 +459,7 @@ M.rebuild_unlinked_discussion_tree = function() if M.unlinked_bufnr == nil then return end + local expanded_node_ids = M.gather_expanded_node_ids(M.unlinked_discussion_tree) common.switch_can_edit_bufs(true, M.linked_bufnr, M.unlinked_bufnr) vim.api.nvim_buf_set_lines(M.unlinked_bufnr, 0, -1, false, {}) local existing_note_nodes = @@ -443,7 +480,13 @@ M.rebuild_unlinked_discussion_tree = function() bufnr = M.unlinked_bufnr, prepare_node = tree_utils.nui_tree_prepare_node, }) + + -- Re-expand already expanded nodes + for _, id in ipairs(expanded_node_ids) do + tree_utils.open_node_by_id(unlinked_discussion_tree, id) + end unlinked_discussion_tree:render() + M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_bufnr, true) M.unlinked_discussion_tree = unlinked_discussion_tree common.switch_can_edit_bufs(false, M.linked_bufnr, M.unlinked_bufnr) @@ -498,6 +541,7 @@ M.is_current_node_note = function(tree) end M.set_tree_keymaps = function(tree, bufnr, unlinked) + ---Keybindings only relevant for linked (comment) view if not unlinked then vim.keymap.set("n", state.settings.discussion_tree.jump_to_file, function() if M.is_current_node_note(tree) then @@ -506,13 +550,19 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) end, { buffer = bufnr, desc = "Jump to file" }) vim.keymap.set("n", state.settings.discussion_tree.jump_to_reviewer, function() if M.is_current_node_note(tree) then - common.jump_to_reviewer(tree, M.refresh_view) + common.jump_to_reviewer(tree, M.refresh_diagnostics_and_winbar) end end, { buffer = bufnr, desc = "Jump to reviewer" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_tree_type, function() M.toggle_tree_type() end, { buffer = bufnr, desc = "Toggle tree type between `simple` and `by_file_name`" }) end + + vim.keymap.set("n", state.settings.discussion_tree.refresh_data, function() + u.notify("Refreshing data...", vim.log.levels.INFO) + draft_notes.rebuild_view(unlinked, false) + end, { buffer = bufnr, desc = "Refreshes the view with Gitlab's APIs" }) + vim.keymap.set("n", state.settings.discussion_tree.edit_comment, function() if M.is_current_node_note(tree) then M.edit_comment(tree, unlinked) @@ -525,7 +575,7 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) end, { buffer = bufnr, desc = "Publish draft" }) vim.keymap.set("n", state.settings.discussion_tree.delete_comment, function() if M.is_current_node_note(tree) then - M.delete_comment(tree) + M.delete_comment(tree, unlinked) end end, { buffer = bufnr, desc = "Delete comment" }) vim.keymap.set("n", state.settings.discussion_tree.toggle_draft_mode, function() @@ -590,68 +640,6 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) emoji.init_popup(tree, bufnr) end ----Redraws the header of a node in a tree when it's been toggled to resolved/unresolved ----@param tree NuiTree ----@param note NuiTree.Node ----@param mark_resolved boolean -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) - tree.nodes.by_id["-" .. note.id][key] = val - end - - local has_symbol = function(s) - return state.settings.discussion_tree[s] ~= nil and state.settings.discussion_tree[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.settings.discussion_tree[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.settings.discussion_tree[target])) - end - - tree:render() -end - ----Replace text in discussion after note update. ----@param data Discussion[]|UnlinkedDiscussion[] ----@param discussion_id string ----@param note_id integer ----@param text string -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 - end - end - end - end -end - ----Given some note data, adds it to the tree and re-renders the tree ----@param tree any ----@param note any ----@param discussion_id any -M.add_reply_to_tree = function(tree, note, discussion_id) - local note_node = tree_utils.build_note(note) - note_node:expand() - tree:add_node(note_node, discussion_id and ("-" .. discussion_id) or nil) - tree:render() -end - ---Toggle comments tree type between "simple" and "by_file_name" M.toggle_tree_type = function() if state.settings.discussion_tree.tree_type == "simple" then @@ -677,80 +665,4 @@ M.is_draft_note = function(tree) return root_node ~= nil and root_node.is_draft end ----Opens a popup prompting the user to choose an emoji to attach to the current node ----@param tree any ----@param unlinked boolean -M.add_emoji_to_note = function(tree, unlinked) - local node = tree:get_node() - local note_node = common.get_note_node(tree, node) - local root_node = common.get_root_node(tree, node) - local note_id = tonumber(note_node.is_root and root_node.root_note_id or note_node.id) - local note_id_str = tostring(note_id) - local emojis = require("gitlab.emoji").emoji_list - emoji.pick_emoji(emojis, function(name) - local body = { emoji = name, note_id = note_id } - job.run_job("/mr/awardable/note/", "POST", body, function(data) - if state.DISCUSSION_DATA.emojis[note_id_str] == nil then - state.DISCUSSION_DATA.emojis[note_id_str] = {} - table.insert(state.DISCUSSION_DATA.emojis[note_id_str], data.Emoji) - else - table.insert(state.DISCUSSION_DATA.emojis[note_id_str], data.Emoji) - end - if unlinked then - M.rebuild_unlinked_discussion_tree() - else - M.rebuild_discussion_tree() - end - u.notify("Emoji added", vim.log.levels.INFO) - end) - end) -end - ----Opens a popup prompting the user to choose an emoji to remove from the current node ----@param tree any ----@param unlinked boolean -M.delete_emoji_from_note = function(tree, unlinked) - local node = tree:get_node() - local note_node = common.get_note_node(tree, node) - local root_node = common.get_root_node(tree, node) - local note_id = tonumber(note_node.is_root and root_node.root_note_id or note_node.id) - local note_id_str = tostring(note_id) - - local e = require("gitlab.emoji") - - local emojis = {} - local current_emojis = state.DISCUSSION_DATA.emojis[note_id_str] - for _, current_emoji in ipairs(current_emojis) do - if state.USER.id == current_emoji.user.id then - table.insert(emojis, e.emoji_map[current_emoji.name]) - end - end - - emoji.pick_emoji(emojis, function(name) - local awardable_id - for _, current_emoji in ipairs(current_emojis) do - if current_emoji.name == name and current_emoji.user.id == state.USER.id then - awardable_id = current_emoji.id - break - end - end - job.run_job(string.format("/mr/awardable/note/%d/%d", note_id, awardable_id), "DELETE", nil, function(_) - local keep = {} -- Emojis to keep after deletion in the UI - for _, saved in ipairs(state.DISCUSSION_DATA.emojis[note_id_str]) do - if saved.name ~= name or saved.user.id ~= state.USER.id then - table.insert(keep, saved) - end - end - state.DISCUSSION_DATA.emojis[note_id_str] = keep - if unlinked then - M.rebuild_unlinked_discussion_tree() - else - M.rebuild_discussion_tree() - end - e.init_popup(tree, unlinked and M.unlinked_bufnr or M.linked_bufnr) - u.notify("Emoji removed", vim.log.levels.INFO) - end) - end) -end - return M diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index 024c4475..d7613b04 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -460,6 +460,16 @@ M.collapse_recursively = function(tree, node, current_root_node, keep_current_op end end +---Expands a given node in a given tree by it's ID +---@param tree NuiTree +---@param id string +M.open_node_by_id = function(tree, id) + local node = tree:get_node(id) + if node then + node:expand() + end +end + -- This function (settings.discussion_tree.toggle_node) expands/collapses the current node and its children M.toggle_node = function(tree) local node = tree:get_node() diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index 6ea4aa34..ca27dc67 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -2,8 +2,6 @@ -- That includes things like editing existing draft notes in the tree, and -- and deleting them. Normal notes and comments are managed separately, -- under lua/gitlab/actions/discussions/init.lua -local winbar = require("gitlab.actions.discussions.winbar") -local diagnostics = require("gitlab.indicators.diagnostics") local common = require("gitlab.actions.common") local discussion_tree = require("gitlab.actions.discussions.tree") local job = require("gitlab.job") @@ -14,25 +12,30 @@ local state = require("gitlab.state") local M = {} +---Re-fetches all draft notes (and non-draft notes) and re-renders the relevant views +---@param unlinked boolean +---@param all boolean +M.rebuild_view = function(unlinked, all) + M.load_draft_notes(function() + local discussions = require("gitlab.actions.discussions") + discussions.rebuild_view(unlinked, all) + end) +end + +---Makes API call to get the discussion data, stores it in the state, and calls the callback +---@param callback function|nil +M.load_draft_notes = function(callback) + state.load_new_state("draft_notes", function() + if callback ~= nil then + callback() + end + end) +end + ---@class AddDraftNoteOpts table ---@field draft_note DraftNote ---@field unlinked boolean ----Adds a draft note to the draft notes state, then rebuilds the view ----@param opts AddDraftNoteOpts -M.add_draft_note = function(opts) - local new_draft_notes = u.ensure_table(state.DRAFT_NOTES) - table.insert(new_draft_notes, opts.draft_note) - state.DRAFT_NOTES = new_draft_notes - local discussions = require("gitlab.actions.discussions") - if opts.unlinked then - discussions.rebuild_unlinked_discussion_tree() - else - discussions.rebuild_discussion_tree() - end - winbar.update_winbar() -end - ---Tells whether a draft note was left on a particular diff or is an unlinked note ---@param note DraftNote M.has_position = function(note) @@ -82,8 +85,11 @@ M.add_draft_notes_to_table = function(unlinked) -- end end ----Send edits will actually send the edits to Gitlab and refresh the draft_notes tree -M.send_edits = function(note_id) +---Will actually send the edits to Gitlab and refresh the draft_notes tree +---@param note_id integer +---@param unlinked boolean +---@return function +M.confirm_edit_draft_note = function(note_id, unlinked) return function(text) local all_notes = List.new(state.DRAFT_NOTES) local the_note = all_notes:find(function(note) @@ -92,67 +98,18 @@ M.send_edits = function(note_id) local body = { note = text, position = the_note.position } job.run_job(string.format("/mr/draft_notes/%d", note_id), "PATCH", body, function(data) u.notify(data.message, vim.log.levels.INFO) - local has_position = false - local new_draft_notes = all_notes:map(function(note) - if note.id == note_id then - has_position = M.has_position(note) - note.note = text - end - return note - end) - state.DRAFT_NOTES = new_draft_notes - local discussions = require("gitlab.actions.discussions") - if has_position then - discussions.rebuild_discussion_tree() - else - discussions.rebuild_unlinked_discussion_tree() - end - winbar.update_winbar() + M.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 -M.send_deletion = function(tree) - local current_node = tree:get_node() - local note_node = common.get_note_node(tree, current_node) - local root_node = common.get_root_node(tree, current_node) - - if note_node == nil or root_node == nil then - u.notify("Could not get note or root node", vim.log.levels.ERROR) - return - end - - ---@type integer - local note_id = note_node.is_root and root_node.id or note_node.id - +---This function will actually send the deletion to Gitlab when you make a selection, and re-render the tree +---@param note_id integer +---@param unlinked boolean +M.confirm_delete_draft_note = function(note_id, unlinked) job.run_job(string.format("/mr/draft_notes/%d", note_id), "DELETE", nil, function(data) u.notify(data.message, vim.log.levels.INFO) - - local has_position = false - local new_draft_notes = List.new(state.DRAFT_NOTES):filter(function(note) - if note.id ~= note_id then - return true - else - has_position = M.has_position(note) - return false - end - end) - - state.DRAFT_NOTES = new_draft_notes - local discussions = require("gitlab.actions.discussions") - if has_position then - discussions.rebuild_discussion_tree() - else - discussions.rebuild_unlinked_discussion_tree() - end - - if state.settings.discussion_signs.enabled and state.DISCUSSION_DATA then - diagnostics.refresh_diagnostics() - end - - winbar.update_winbar() - common.add_empty_titles() + M.rebuild_view(unlinked) end) end @@ -185,11 +142,7 @@ M.confirm_publish_all_drafts = function() u.notify(data.message, vim.log.levels.INFO) state.DRAFT_NOTES = {} local discussions = require("gitlab.actions.discussions") - discussions.refresh(function() - discussions.rebuild_discussion_tree() - discussions.rebuild_unlinked_discussion_tree() - winbar.update_winbar() - end) + discussions.rebuild_view(false, true) end) end @@ -213,26 +166,9 @@ M.confirm_publish_draft = function(tree) job.run_job("/mr/draft_notes/publish", "POST", body, function(data) u.notify(data.message, vim.log.levels.INFO) - local has_position = false - local new_draft_notes = List.new(state.DRAFT_NOTES):filter(function(note) - if note.id ~= note_id then - return true - else - has_position = M.has_position(note) - return false - end - end) - - state.DRAFT_NOTES = new_draft_notes local discussions = require("gitlab.actions.discussions") - discussions.refresh(function() - if has_position then - discussions.rebuild_discussion_tree() - else - discussions.rebuild_unlinked_discussion_tree() - end - winbar.update_winbar() - end) + local unlinked = tree.bufnr == discussions.unlinked_bufnr + M.rebuild_view(unlinked) end) end diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 5d35dc11..1bcedc55 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -91,7 +91,10 @@ M.build_info_lines = function() branch = { title = "Branch", content = info.source_branch }, labels = { title = "Labels", content = table.concat(info.labels, ", ") }, target_branch = { title = "Target Branch", content = info.target_branch }, - delete_branch = { title = "Delete Source Branch", content = (info.force_remove_source_branch and "Yes" or "No") }, + delete_branch = { + title = "Delete Source Branch", + content = (info.force_remove_source_branch and "Yes" or "No"), + }, squash = { title = "Squash Commits", content = (info.squash and "Yes" or "No") }, pipeline = { title = "Pipeline Status", diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index f263df77..08741002 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -32,8 +32,10 @@ local function create_diagnostic(range_info, d_or_n) local message = header if d_or_n.notes then for _, note in ipairs(d_or_n.notes or {}) do - message = message .. actions_common.build_note_header(note) .. "\n" .. note.body .. "\n" + message = message .. "\n" .. note.body .. "\n" end + else + message = message .. "\n" .. d_or_n.note .. "\n" end local diagnostic = { diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index e43b9230..368ba8d0 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -76,6 +76,11 @@ return { }, discussions.toggle), toggle_draft_mode = discussions.toggle_draft_mode, publish_all_drafts = draft_notes.publish_all_drafts, + refresh_data = function() + -- This also rebuilds the regular views + u.notify("Refreshing data...", vim.log.levels.INFO) + draft_notes.rebuild_view(false, true) + end, -- Other functions 🤷 state = state, data = data.data, diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index c58db7a3..4bf2abee 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -73,9 +73,7 @@ M.settings = { border = "rounded", opacity = 1.0, edit = nil, - reply = nil, comment = nil, - note = nil, help = nil, pipeline = nil, squash_message = nil, @@ -90,6 +88,7 @@ M.settings = { jump_to_reviewer = "m", edit_comment = "e", delete_comment = "dd", + refresh_data = "a", open_in_browser = "b", copy_node_url = "u", publish_draft = "P", @@ -377,6 +376,7 @@ M.dependencies = { refresh = false, }, discussion_data = { + -- key is missing here... endpoint = "/mr/discussions/list", state = "DISCUSSION_DATA", refresh = false, @@ -389,6 +389,24 @@ M.dependencies = { }, } +M.load_new_state = function(dep, cb) + local job = require("gitlab.job") + local dependency = M.dependencies[dep] + job.run_job( + dependency.endpoint, + dependency.method or "GET", + dependency.body and dependency.body() or nil, + function(data) + if dependency.key then + M[dependency.state] = u.ensure_table(data[dependency.key]) + end + if type(cb) == "function" then + cb(data) -- To set data manually... + end + end + ) +end + -- This function clears out all of the previously fetched data. It's used -- to reset the plugin state when the Go server is restarted M.clear_data = function() diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index dd9a4656..8c0e6e81 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -472,7 +472,7 @@ end ---Get the popup view_opts ---@param title string The string to appear on top of the popup ----@param settings table User defined popup settings +---@param settings table|nil User defined popup settings ---@param width number? Override default width ---@param height number? Override default height ---@return table From 8382d1f5032ac52575571d143fb19c61ffd22736 Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Tue, 23 Apr 2024 17:50:11 -0400 Subject: [PATCH 51/97] Always refresh discussion data for discussion view toggle --- lua/gitlab/init.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 368ba8d0..8e4c4d0c 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -71,8 +71,8 @@ return { toggle_discussions = async.sequence({ info, user, - draft_notes_dep, - discussion_data, + u.merge(draft_notes_dep, { refresh = true }), + u.merge(discussion_data, { refresh = true }), }, discussions.toggle), toggle_draft_mode = discussions.toggle_draft_mode, publish_all_drafts = draft_notes.publish_all_drafts, From fcd8dbc01ed6d009b89e73b122090fa5e4a02f90 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Tue, 23 Apr 2024 20:52:15 -0400 Subject: [PATCH 52/97] feat: support draft note replies (#290) * Add support for draft note replies, e.g. replies to existing notes and comments in draft form --- cmd/draft_notes.go | 11 ++- cmd/reply.go | 1 + lua/gitlab/actions/comment.lua | 25 ++++-- lua/gitlab/actions/discussions/init.lua | 31 +++---- lua/gitlab/actions/discussions/tree.lua | 15 +++- lua/gitlab/actions/draft_notes/init.lua | 106 ++++++++++++------------ 6 files changed, 104 insertions(+), 85 deletions(-) diff --git a/cmd/draft_notes.go b/cmd/draft_notes.go index 6d8518f9..68c16954 100644 --- a/cmd/draft_notes.go +++ b/cmd/draft_notes.go @@ -17,7 +17,8 @@ as when they are creating a normal comment, but the Gitlab endpoints + resources we handle are different */ type PostDraftNoteRequest struct { - Comment string `json:"comment"` + Comment string `json:"comment"` + DiscussionId string `json:"discussion_id,omitempty"` PositionData } @@ -143,9 +144,11 @@ func (a *api) postDraftNote(w http.ResponseWriter, r *http.Request) { opt := gitlab.CreateDraftNoteOptions{ Note: &postDraftNoteRequest.Comment, - // TODO: Support posting replies as drafts and rendering draft replies in the discussion tree - // instead of the notes tree - // InReplyToDiscussionID *string `url:"in_reply_to_discussion_id,omitempty" json:"in_reply_to_discussion_id,omitempty"` + } + + // Draft notes can be posted in "response" to existing discussions + if postDraftNoteRequest.DiscussionId != "" { + opt.InReplyToDiscussionID = gitlab.Ptr(postDraftNoteRequest.DiscussionId) } if postDraftNoteRequest.FileName != "" { diff --git a/cmd/reply.go b/cmd/reply.go index 556184d8..4d104fdc 100644 --- a/cmd/reply.go +++ b/cmd/reply.go @@ -12,6 +12,7 @@ import ( type ReplyRequest struct { DiscussionId string `json:"discussion_id"` Reply string `json:"reply"` + IsDraft bool `json:"is_draft"` } type ReplyResponse struct { diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 31cb35d1..93ff9ff7 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -35,8 +35,8 @@ local confirm_create_comment = function(text, visual_range, unlinked, discussion local is_draft = M.draft_popup and u.string_to_bool(u.get_buffer_text(M.draft_popup.bufnr)) - -- Creating a reply to a discussion - if discussion_id ~= nil then + -- 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) @@ -52,7 +52,7 @@ local confirm_create_comment = function(text, visual_range, unlinked, discussion end -- Creating a note (unlinked comment) - if unlinked then + 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() @@ -83,9 +83,7 @@ local confirm_create_comment = function(text, visual_range, unlinked, discussion end local revision = state.MR_REVISIONS[1] - local body = { - type = "text", - comment = text, + local position_data = { file_name = reviewer_data.file_name, base_commit_sha = revision.base_commit_sha, start_commit_sha = revision.start_commit_sha, @@ -95,7 +93,20 @@ local confirm_create_comment = function(text, visual_range, unlinked, discussion line_range = location_data.line_range, } - -- Creating a comment (linked to specific changes) + -- 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) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 74a21c1c..67c9f5f1 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -258,10 +258,10 @@ M.delete_comment = function(tree, unlinked) end ---@type integer - local note_id = note_node.is_root and root_node.root_note_id or note_node.id - if root_node.is_draft then - draft_notes.confirm_delete_draft_note(note_id, unlinked) + if M.is_draft_note(tree) then + draft_notes.confirm_delete_draft_note(note_node.id, unlinked) else + local note_id = note_node.is_root and root_node.root_note_id or note_node.id local comment = require("gitlab.actions.comment") comment.confirm_delete_comment(note_id, root_node.id, unlinked) end @@ -296,10 +296,11 @@ M.edit_comment = function(tree, unlinked) vim.api.nvim_buf_set_lines(currentBuffer, 0, -1, false, lines) -- Draft notes module handles edits for draft notes - if root_node.is_draft then + if M.is_draft_note(tree) then + vim.print(note_node) state.set_popup_keymaps( edit_popup, - draft_notes.confirm_edit_draft_note(root_node.id, unlinked), + draft_notes.confirm_edit_draft_note(note_node.id, unlinked), nil, miscellaneous.editable_popup_opts ) @@ -427,13 +428,7 @@ M.rebuild_discussion_tree = function() local draft_comment_nodes = draft_notes.add_draft_notes_to_table(false) -- Combine inline draft notes with regular comments - local all_nodes = {} - for _, draft_node in ipairs(draft_comment_nodes) do - table.insert(all_nodes, draft_node) - end - for _, node in ipairs(existing_comment_nodes) do - table.insert(all_nodes, node) - end + local all_nodes = u.join(draft_comment_nodes, existing_comment_nodes) local discussion_tree = NuiTree({ nodes = all_nodes, @@ -467,13 +462,7 @@ M.rebuild_unlinked_discussion_tree = function() local draft_comment_nodes = draft_notes.add_draft_notes_to_table(true) -- Combine draft notes with regular notes - local all_nodes = {} - for _, draft_node in ipairs(draft_comment_nodes) do - table.insert(all_nodes, draft_node) - end - for _, node in ipairs(existing_note_nodes) do - table.insert(all_nodes, node) - end + local all_nodes = u.join(draft_comment_nodes, existing_note_nodes) local unlinked_discussion_tree = NuiTree({ nodes = all_nodes, @@ -661,6 +650,10 @@ end ---@return boolean M.is_draft_note = function(tree) local current_node = tree:get_node() + local note_node = common.get_note_node(tree, current_node) + if note_node and note_node.is_draft then + return true + end local root_node = common.get_root_node(tree, current_node) return root_node ~= nil and root_node.is_draft end diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index d7613b04..a8954e9a 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -2,6 +2,7 @@ -- is not used in the draft notes tree local u = require("gitlab.utils") local common = require("gitlab.actions.common") +local List = require("gitlab.utils.list") local state = require("gitlab.state") local NuiTree = require("nui.tree") local NuiLine = require("nui.line") @@ -56,8 +57,20 @@ M.add_discussions_to_table = function(items, unlinked) end end + -- Attaches draft notes that are replies to their parent discussions + local draft_replies = List.new(state.DRAFT_NOTES or {}) + :filter(function(note) + return note.discussion_id == discussion.id + end) + :map(function(note) + local result = M.build_note(note) + return result + end) + + local all_children = u.join(discussion_children, draft_replies) + -- Creates the first node in the discussion, and attaches children - local body = u.spread(root_text_nodes, discussion_children) + local body = u.spread(root_text_nodes, all_children) local root_node = NuiTree.Node({ range = range, text = root_text, diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index ca27dc67..1122b967 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -14,7 +14,7 @@ local M = {} ---Re-fetches all draft notes (and non-draft notes) and re-renders the relevant views ---@param unlinked boolean ----@param all boolean +---@param all boolean|nil M.rebuild_view = function(unlinked, all) M.load_draft_notes(function() local discussions = require("gitlab.actions.discussions") @@ -32,59 +32,6 @@ M.load_draft_notes = function(callback) end) end ----@class AddDraftNoteOpts table ----@field draft_note DraftNote ----@field unlinked boolean - ----Tells whether a draft note was left on a particular diff or is an unlinked note ----@param note DraftNote -M.has_position = function(note) - return note.position.new_path ~= nil or note.position.old_path ~= nil -end - ----Returns a list of nodes to add to the discussion tree. Can filter and return only unlinked (note) nodes. ----@param unlinked boolean ----@return NuiTree.Node[] -M.add_draft_notes_to_table = function(unlinked) - local draft_notes = List.new(state.DRAFT_NOTES) - - local draft_note_nodes = draft_notes - ---@param note DraftNote - :filter(function(note) - if unlinked then - return not M.has_position(note) - end - return M.has_position(note) - end) - ---@param note DraftNote - :map(function(note) - local _, root_text, root_text_nodes = discussion_tree.build_note(note) - return NuiTree.Node({ - range = (type(note.position) == "table" and note.position.line_range or nil), - text = root_text, - type = "note", - is_root = true, - is_draft = true, - id = note.id, - root_note_id = note.id, - file_name = (type(note.position) == "table" and note.position.new_path or nil), - new_line = (type(note.position) == "table" and note.position.new_line or nil), - old_line = (type(note.position) == "table" and note.position.old_line or nil), - resolvable = false, - resolved = false, - url = state.INFO.web_url .. "#note_" .. note.id, - }, root_text_nodes) - end) - - return draft_note_nodes - - -- TODO: Combine draft_notes and normal discussion nodes in the complex discussion - -- tree. The code for that feature is a clusterfuck so this is difficult - -- if state.settings.discussion_tree.tree_type == "simple" then - -- return draft_note_nodes - -- end -end - ---Will actually send the edits to Gitlab and refresh the draft_notes tree ---@param note_id integer ---@param unlinked boolean @@ -172,4 +119,55 @@ M.confirm_publish_draft = function(tree) end) end +--- Helper functions +---Tells whether a draft note was left on a particular diff or is an unlinked note +---@param note DraftNote +M.has_position = function(note) + return note.position.new_path ~= nil or note.position.old_path ~= nil +end + +---Builds a note for the discussion tree for draft notes that are roots +---of their own discussions, e.g. not replies +---@param note DraftNote +---@return NuiTree.Node +M.build_root_draft_note = function(note) + local _, root_text, root_text_nodes = discussion_tree.build_note(note) + return NuiTree.Node({ + range = (type(note.position) == "table" and note.position.line_range or nil), + text = root_text, + type = "note", + is_root = true, + is_draft = true, + id = note.id, + root_note_id = note.id, + file_name = (type(note.position) == "table" and note.position.new_path or nil), + new_line = (type(note.position) == "table" and note.position.new_line or nil), + old_line = (type(note.position) == "table" and note.position.old_line or nil), + resolvable = false, + resolved = false, + url = state.INFO.web_url .. "#note_" .. note.id, + }, root_text_nodes) +end + +---Returns a list of nodes to add to the discussion tree. Can filter and return only unlinked (note) nodes. +---@param unlinked boolean +---@return NuiTree.Node[] +M.add_draft_notes_to_table = function(unlinked) + local draft_notes = List.new(state.DRAFT_NOTES) + local draft_note_nodes = draft_notes + ---@param note DraftNote + :filter(function(note) + if unlinked then + return not M.has_position(note) + end + return M.has_position(note) + end) + :filter(function(note) + return note.discussion_id == "" -- Do not include draft replies + end) + :map(M.build_root_draft_note) + + return draft_note_nodes +end + return M From 475b950eb5ea4e6ffe926bfabd6ed54875d9cb5e Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Tue, 23 Apr 2024 21:25:29 -0400 Subject: [PATCH 53/97] remove print note --- lua/gitlab/actions/discussions/init.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 67c9f5f1..e80c389d 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -297,7 +297,6 @@ M.edit_comment = function(tree, unlinked) -- Draft notes module handles edits for draft notes if M.is_draft_note(tree) then - vim.print(note_node) state.set_popup_keymaps( edit_popup, draft_notes.confirm_edit_draft_note(note_node.id, unlinked), From 81d7f0c3cf901648794873bc35906056024c0052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 24 Apr 2024 21:04:02 +0200 Subject: [PATCH 54/97] Fix: Use base_sha for calculating diff hunks (#291) * fix: The base_sha should be used for calculating the correct diff hunks because it is also used for the DiffviewOpen command --- lua/gitlab/hunks.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/gitlab/hunks.lua b/lua/gitlab/hunks.lua index f90d6f1c..15e7e8b5 100644 --- a/lua/gitlab/hunks.lua +++ b/lua/gitlab/hunks.lua @@ -91,9 +91,9 @@ end ---Parse git diff hunks. ---@param file_path string Path to file. ----@param base_branch string Git base branch of merge request. +---@param base_sha string Git base SHA of merge request. ---@return HunksAndDiff -local parse_hunks_and_diff = function(file_path, base_branch) +local parse_hunks_and_diff = function(file_path, base_sha) local hunks = {} local all_diff_output = {} @@ -101,7 +101,7 @@ local parse_hunks_and_diff = function(file_path, base_branch) local diff_job = Job:new({ command = "git", - args = { "diff", "--minimal", "--unified=0", "--no-color", base_branch, "--", file_path }, + args = { "diff", "--minimal", "--unified=0", "--no-color", base_sha, "--", file_path }, on_exit = function(j, return_code) if return_code == 0 then all_diff_output = j:result() @@ -225,7 +225,7 @@ end ---@param is_current_sha_focused boolean ---@return string|nil function M.get_modification_type(old_line, new_line, current_file, is_current_sha_focused) - local hunk_and_diff_data = parse_hunks_and_diff(current_file, state.INFO.target_branch) + local hunk_and_diff_data = parse_hunks_and_diff(current_file, state.INFO.diff_refs.base_sha) if hunk_and_diff_data.hunks == nil then return end From 85737e9b86d2160df3770ffc10f16168427865dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Fri, 26 Apr 2024 01:13:05 +0200 Subject: [PATCH 55/97] fix: Use backticks in code suggestions (#292) fix: Use backticks in code suggestions (#292) --- lua/gitlab/actions/comment.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 93ff9ff7..4620b9d0 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -284,8 +284,8 @@ local build_suggestion = function() 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, "^```$") then + for _, line in ipairs(selected_lines) do + if string.match(line, "^```%S*$") then backticks = "````" break end From 3592d9ead1d954db2593ae266b4650e9253f54d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sun, 5 May 2024 16:58:20 +0200 Subject: [PATCH 56/97] Fix: Copy node url to clipboard (#295) --- lua/gitlab/actions/common.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua index 1210e1cf..8d8c6409 100644 --- a/lua/gitlab/actions/common.lua +++ b/lua/gitlab/actions/common.lua @@ -113,7 +113,7 @@ end ---@param tree NuiTree M.copy_node_url = function(tree) local url = M.get_url(tree) - if url == nil then + if url ~= nil then vim.fn.setreg("+", url) u.notify("Copied '" .. url .. "' to clipboard", vim.log.levels.INFO) end From a8b82b16523808d28eb0524a891d7cd337e0f574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sun, 5 May 2024 16:58:52 +0200 Subject: [PATCH 57/97] Fix: List remote branches for merge targets (#293) --- lua/gitlab/git.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index 503564e3..76c869ba 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -18,7 +18,7 @@ end ---Returns all branches for the current repository ---@return string|nil, string|nil M.branches = function() - return run_system({ "git", "branch" }) + return run_system({ "git", "branch", "-r" }) end ---Checks whether the tree has any changes that haven't been pushed to the remote From 3dabc55aeef1ea3e058c6237765d8cde51c90537 Mon Sep 17 00:00:00 2001 From: August Masquelier <31262046+levouh@users.noreply.github.com> Date: Sun, 5 May 2024 09:05:14 -0600 Subject: [PATCH 58/97] fix: consider remote branches (#297) * fix(mr.create): consider remote branches * fix(utils): prevent require loop --- lua/gitlab/git.lua | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index 76c869ba..debe8314 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -6,6 +6,7 @@ local M = {} ---@param command table ---@return string|nil, string|nil local run_system = function(command) + -- Load here to prevent loop local u = require("gitlab.utils") local result = vim.fn.trim(vim.fn.system(command)) if vim.v.shell_error ~= 0 then @@ -16,9 +17,12 @@ local run_system = function(command) end ---Returns all branches for the current repository +---@param args table|nil extra arguments for `git branch` ---@return string|nil, string|nil -M.branches = function() - return run_system({ "git", "branch", "-r" }) +M.branches = function(args) + -- Load here to prevent loop + local u = require("gitlab.utils") + return run_system(u.combine({ "git", "branch" }, args or {})) end ---Checks whether the tree has any changes that haven't been pushed to the remote @@ -60,7 +64,7 @@ end ---Return the list of names of all remote-tracking branches or an empty list. ---@return table, string|nil M.get_all_remote_branches = function() - local all_branches, err = M.branches() + local all_branches, err = M.branches({ "--remotes" }) if err ~= nil then return {}, err end From 2c7d983ff0460865ba26fbf6233ce9e3b42630ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sun, 5 May 2024 17:10:06 +0200 Subject: [PATCH 59/97] Fix: Save popup contents to temp_registers (#294) fix: save popup contents to temp registers --- lua/gitlab/actions/comment.lua | 13 ++++--------- lua/gitlab/actions/miscellaneous.lua | 8 ++++++++ lua/gitlab/actions/summary.lua | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 4620b9d0..b4c9aa18 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -188,28 +188,23 @@ M.create_comment_layout = function(opts) }, }, internal_layout) - local popup_opts = { - action_before_close = true, - action_before_exit = false, - } - 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 text section + ---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, popup_opts) + end, miscellaneous.toggle_bool, miscellaneous.non_editable_popup_opts) - ---Keybinding for focus on draft section + ---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, popup_opts) + end, miscellaneous.attach_file, miscellaneous.editable_popup_opts) vim.schedule(function() local draft_mode = state.settings.discussion_tree.draft_mode diff --git a/lua/gitlab/actions/miscellaneous.lua b/lua/gitlab/actions/miscellaneous.lua index 868b4be5..5cf42738 100644 --- a/lua/gitlab/actions/miscellaneous.lua +++ b/lua/gitlab/actions/miscellaneous.lua @@ -35,9 +35,17 @@ M.attach_file = function() end M.editable_popup_opts = { + action_before_close = true, + action_before_exit = false, save_to_temp_register = true, } +M.non_editable_popup_opts = { + action_before_close = true, + action_before_exit = false, + save_to_temp_register = false, +} + -- Get the index of the next popup when cycling forward local function next_index(i, n, count) count = count > 0 and count or 1 diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 1bcedc55..18761090 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -63,7 +63,7 @@ M.summary = function() description_popup, M.edit_summary, miscellaneous.attach_file, - { cb = exit, action_before_close = true } + { cb = exit, action_before_close = true, save_to_temp_register = true } ) state.set_popup_keymaps(title_popup, M.edit_summary, nil, { cb = exit, action_before_close = true }) state.set_popup_keymaps(info_popup, M.edit_summary, nil, { cb = exit, action_before_close = true }) From 75e5536939a877160bec607a1db3b02e591eeb69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Mon, 10 Jun 2024 20:44:00 +0200 Subject: [PATCH 60/97] Fix: Ranged comment signs not showing (#305) fix: show signs for diagnostic range feat: enable hiding diagnostic sign docs: unify gitlab.nvim.txt with README --- README.md | 1 + doc/gitlab.nvim.txt | 26 +++++++++++++++++++------- lua/gitlab/indicators/diagnostics.lua | 17 ++++++++++------- lua/gitlab/indicators/signs.lua | 8 +++----- lua/gitlab/state.lua | 1 + 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 1a4be079..43a01676 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ require("gitlab").setup({ skip_resolved_discussion = false, -- Show diagnostics for resolved discussions severity = vim.diagnostic.severity.INFO, -- ERROR, WARN, INFO, or HINT virtual_text = false, -- Whether to show the comment text inline as floating virtual text + use_diagnostic_signs = true, -- Show diagnostic sign (depending on the `severity` setting, e.g., I for INFO) along with the comment icon priority = 100, -- Higher will override LSP warnings, etc icons = { comment = "→|", diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 140b94ff..802a7362 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -160,6 +160,9 @@ you call this function with no values the defaults will be used: imply_local = false, -- If true, will attempt to use --imply_local option when calling |:DiffviewOpen| }, }, + connection_settings = { + insecure = false, -- Like curl's --insecure option, ignore bad x509 certificates on connection + }, help = "g?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) popup = { -- The popup for comment creation, editing, and replying keymaps = { @@ -192,12 +195,14 @@ you call this function with no values the defaults will be used: refresh_data = "a", -- Refreshes the data in the view by hitting Gitlab's APIs again reply = "r", -- Reply to comment toggle_node = "t", -- Opens or closes the discussion + add_emoji = "Ea" -- Add an emoji to the note/comment + add_emoji = "Ed" -- Remove an emoji from a note/comment toggle_all_discussions = "T", -- Open or close separately both resolved and unresolved discussions toggle_resolved_discussions = "R", -- Open or close all resolved discussions toggle_unresolved_discussions = "U", -- Open or close all unresolved discussions keep_current_open = false, -- If true, current discussion stays open even if it should otherwise be closed when toggling - toggle_resolved = "p" -- Toggles the resolved status of the whole discussion publish_draft = "P", -- Publishes the currently focused note/comment + toggle_resolved = "p" -- Toggles the resolved status of the whole discussion position = "left", -- "top", "right", "bottom" or "left" open_in_browser = "b" -- Jump to the URL of the current note/discussion copy_node_url = "u", -- Copy the URL of the current node to clipboard @@ -208,7 +213,7 @@ you call this function with no values the defaults will be used: tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name" draft_mode = false, -- Whether comments are posted as drafts as part of a review - toggle_draft_mode = "D" -- Toggle between draft mode and regular mode, where comments are posted immediately + toggle_draft_mode = "D" -- Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately) winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) -- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar. }, @@ -218,7 +223,7 @@ you call this function with no values the defaults will be used: info = { -- Show additional fields in the summary view enabled = true, horizontal = false, -- Display metadata to the left of the summary rather than underneath - fields = { -- The fields listed here will be displayed, in whatever order you choose + fields = { -- The fields listed here will be displayed, in whatever order you choose "author", "created_at", "updated_at", @@ -240,6 +245,7 @@ you call this function with no values the defaults will be used: skip_resolved_discussion = false, -- Show diagnostics for resolved discussions severity = vim.diagnostic.severity.INFO, -- ERROR, WARN, INFO, or HINT virtual_text = false, -- Whether to show the comment text inline as floating virtual text + use_diagnostic_signs = true, -- Show diagnostic sign (depending on the `severity` setting, e.g., I for INFO) along with the comment icon priority = 100, -- Higher will override LSP warnings, etc icons = { comment = "→|", @@ -275,7 +281,7 @@ you call this function with no values the defaults will be used: directory = "Directory", directory_icon = "DiffviewFolderSign", file_name = "Normal", - } + } } }) < @@ -413,6 +419,7 @@ have been added to a review. These are the default settings: skip_resolved_discussion = false, -- Show diagnostics for resolved discussions severity = vim.diagnostic.severity.INFO, -- ERROR, WARN, INFO, or HINT virtual_text = false, -- Whether to show the comment text inline as floating virtual text + use_diagnostic_signs = true, -- Show diagnostic sign (depending on the `severity` setting, e.g., I for INFO) along with the comment icon priority = 100, -- Higher will override LSP warnings, etc icons = { comment = "→|", @@ -420,14 +427,19 @@ have been added to a review. These are the default settings: }, }, -When the cursor is on diagnostic line you can view discussion thread by using `vim.diagnostic.show()` +When the cursor is on a diagnostic line you can view the discussion thread by +using `vim.diagnostic.show()`. -You can also jump to discussion tree for the given comment: +You can also jump to the discussion tree for the given comment: >lua require("gitlab").move_to_discussion_tree_from_diagnostic() +Since nvim 0.10 you can use these two function anywhere in the diagnostic +range. In previous versions, you have to move the cursor to the first line of +the diagnostic. + You may skip resolved discussions by toggling `discussion_signs.skip_resolved_discussion` -in your setup function to true. By default, discussions from this plugin +in your setup function to `true`. By default, discussions from this plugin are shown at the INFO severity level (see :h vim.diagnostic.severity). diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index 08741002..42454ac6 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -15,11 +15,14 @@ M.clear_diagnostics = function() end -- Display options for the diagnostic -local display_opts = { - virtual_text = state.settings.discussion_signs.virtual_text, - severity_sort = true, - underline = false, -} +local create_display_opts = function() + return { + virtual_text = state.settings.discussion_signs.virtual_text, + severity_sort = true, + underline = false, + signs = state.settings.discussion_signs.use_diagnostic_signs, + } +end ---Takes some range information and data about a discussion ---and creates a diagnostic to be placed in the reviewer @@ -121,10 +124,10 @@ M.refresh_diagnostics = function() end local new_diagnostics = M.parse_new_diagnostics(filtered_discussions) - set_diagnostics_in_new_sha(diagnostics_namespace, new_diagnostics, display_opts) + set_diagnostics_in_new_sha(diagnostics_namespace, new_diagnostics, create_display_opts()) local old_diagnostics = M.parse_old_diagnostics(filtered_discussions) - set_diagnostics_in_old_sha(diagnostics_namespace, old_diagnostics, display_opts) + set_diagnostics_in_old_sha(diagnostics_namespace, old_diagnostics, create_display_opts()) end) if not ok then diff --git a/lua/gitlab/indicators/signs.lua b/lua/gitlab/indicators/signs.lua index 2b3ac19e..abf56b97 100644 --- a/lua/gitlab/indicators/signs.lua +++ b/lua/gitlab/indicators/signs.lua @@ -2,7 +2,6 @@ local u = require("gitlab.utils") local state = require("gitlab.state") local List = require("gitlab.utils.list") local discussion_sign_name = require("gitlab.indicators.diagnostics").discussion_sign_name -local namespace = require("gitlab.indicators.diagnostics").diagnostics_namespace local M = {} M.clear_signs = function() @@ -32,9 +31,8 @@ M.set_signs = function(diagnostics, bufnr) for _, diagnostic in ipairs(diagnostics) do ---@type SignTable[] local existing_signs = - vim.fn.sign_getplaced(vim.api.nvim_get_current_buf(), { group = "gitlab_discussion" })[1].signs + vim.fn.sign_getplaced(vim.api.nvim_get_current_buf(), { group = discussion_sign_name })[1].signs - local sign_id = string.format("%s__%d", namespace, diagnostic.lnum) if diagnostic.end_lnum then local linenr = diagnostic.lnum + 1 while linenr <= diagnostic.end_lnum do @@ -44,7 +42,7 @@ M.set_signs = function(diagnostics, bufnr) end) if conflicting_comment_sign == nil then vim.fn.sign_place( - sign_id, + linenr, discussion_sign_name, "DiagnosticSign" .. M.severity .. gitlab_range, bufnr, @@ -55,7 +53,7 @@ M.set_signs = function(diagnostics, bufnr) end vim.fn.sign_place( - sign_id, + diagnostic.lnum + 1, discussion_sign_name, "DiagnosticSign" .. M.severity .. gitlab_comment, bufnr, diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 4bf2abee..8bc537c5 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -149,6 +149,7 @@ M.settings = { skip_resolved_discussion = false, severity = vim.diagnostic.severity.INFO, virtual_text = false, + use_diagnostic_signs = true, icons = { comment = "→|", range = " |", From 25ce496589a6810b01d992fc0bc0893aab3dce92 Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Tue, 11 Jun 2024 09:36:11 -0400 Subject: [PATCH 61/97] remove print! --- lua/gitlab/actions/create_mr.lua | 2 -- 1 file changed, 2 deletions(-) diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua index efe94a75..9f49f974 100644 --- a/lua/gitlab/actions/create_mr.lua +++ b/lua/gitlab/actions/create_mr.lua @@ -316,8 +316,6 @@ M.create_mr = function() forked_project_id = forked_project_id, } - vim.print(body) - job.run_job("/create_mr", "POST", body, function(data) u.notify(data.message, vim.log.levels.INFO) M.reset_state() From 5d174931648ab270c013797dad21a3bfc37e7a9f Mon Sep 17 00:00:00 2001 From: Enric Calabuig Date: Thu, 20 Jun 2024 00:16:46 +0200 Subject: [PATCH 62/97] Fix docs: choose_merge_request's open_reviewer default value is true (#316) Fix docs to specify that reviewer is opened by default --- doc/gitlab.nvim.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index e99e2234..c7af27f4 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -635,7 +635,7 @@ This command will automatically check out that branch locally, and optionally open the reviewer pane. This is the default behavior. >lua require("gitlab").choose_merge_request() - require("gitlab").choose_merge_request({ open_reviewer = false }) + require("gitlab").choose_merge_request({ open_reviewer = true }) < Parameters: ~ • {opts}: (table|nil) Keyword arguments to configure the checkout. From ac1edb991be480b6bd6fbbf52e9c5b603f9eb8b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Thu, 20 Jun 2024 12:43:30 +0200 Subject: [PATCH 63/97] Fix: Only set autocommands for select popups (#315) --- lua/gitlab/actions/summary.lua | 18 +++++++++++++----- lua/gitlab/state.lua | 28 ++++++++++++++++++---------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 18761090..613b4df7 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -63,10 +63,20 @@ M.summary = function() description_popup, M.edit_summary, miscellaneous.attach_file, - { cb = exit, action_before_close = true, save_to_temp_register = true } + { cb = exit, action_before_close = true, action_before_exit = true, save_to_temp_register = true } + ) + state.set_popup_keymaps( + title_popup, + M.edit_summary, + nil, + { cb = exit, action_before_close = true, action_before_exit = true } + ) + state.set_popup_keymaps( + info_popup, + M.edit_summary, + nil, + { cb = exit, action_before_close = true, action_before_exit = true } ) - state.set_popup_keymaps(title_popup, M.edit_summary, nil, { cb = exit, action_before_close = true }) - state.set_popup_keymaps(info_popup, M.edit_summary, nil, { cb = exit, action_before_close = true }) miscellaneous.set_cycle_popups_keymaps(popups) vim.api.nvim_set_current_buf(description_popup.bufnr) @@ -151,8 +161,6 @@ M.edit_summary = function() u.notify(data.message, vim.log.levels.INFO) state.INFO.description = data.mr.description state.INFO.title = data.mr.title - M.layout:unmount() - M.layout_visible = false end) end diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 2de9e8b7..63601b8c 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -295,9 +295,9 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts) local text = u.get_buffer_text(popup.bufnr) if opts.action_before_close then action(text, popup.bufnr) - vim.api.nvim_buf_delete(popup.bufnr, {}) + exit(popup, opts) else - vim.api.nvim_buf_delete(popup.bufnr, {}) + exit(popup, opts) action(text, popup.bufnr) end end, { buffer = popup.bufnr, desc = "Perform action" }) @@ -312,18 +312,26 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts) end, { buffer = popup.bufnr, desc = "Perform linewise action" }) end - vim.api.nvim_create_autocmd("BufWinLeave", { - buffer = popup.bufnr, - callback = function() - if opts.save_to_temp_register then + if opts.save_to_temp_register then + vim.api.nvim_create_autocmd("BufWinLeave", { + buffer = popup.bufnr, + callback = function() local text = u.get_buffer_text(popup.bufnr) for _, register in ipairs(M.settings.popup.temp_registers) do vim.fn.setreg(register, text) end - end - exit(popup, opts) - end, - }) + end, + }) + end + + if opts.action_before_exit then + vim.api.nvim_create_autocmd("BufWinLeave", { + buffer = popup.bufnr, + callback = function() + exit(popup, opts) + end, + }) + end end -- Dependencies From a32b610053f8e31ccbdebade9abecb7863969223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Mon, 24 Jun 2024 06:14:19 +0200 Subject: [PATCH 64/97] Docs: Small iprovements to README and docs - Improve description of choose_merge_request() - Add note about hard-coded "origin" - Unify formatting --- README.md | 3 ++ doc/gitlab.nvim.txt | 72 ++++++++++++++++++++++++--------------------- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index abd907bf..5b04bd2a 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ To view these help docs and to get more detailed help information, please run `: This will checkout the branch locally, and open the plugin's reviewer pane. +NOTE: At the moment, the plugin assumes that the remote where you want to merge your feature branch +is called "origin". + For more detailed information about the Lua APIs please run `:h gitlab.nvim.api` ## Installation diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index c7af27f4..dda4f7b4 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -55,6 +55,9 @@ QUICK START *gitlab.nvim.quick-start* This will checkout the branch locally, and up the plugin's reviewer pane. +NOTE: At the moment, the plugin assumes that the remote where you want to +merge your feature branch is called "origin". + INSTALLATION *gitlab.nvim.installation* @@ -78,25 +81,25 @@ With Lazy: < And with Packer: >lua - use { - "harrisoncramer/gitlab.nvim", - requires = { - "MunifTanjim/nui.nvim", - "nvim-lua/plenary.nvim", - "sindrets/diffview.nvim" - "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. - "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. - }, - build = function() - require("gitlab.server").build() - end, - branch = "develop", - config = function() - require("diffview") -- We require some global state from diffview - local gitlab = require("gitlab") - gitlab.setup() - end, - } + use { + "harrisoncramer/gitlab.nvim", + requires = { + "MunifTanjim/nui.nvim", + "nvim-lua/plenary.nvim", + "sindrets/diffview.nvim", + "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. + "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. + }, + build = function() + require("gitlab.server").build() + end, + branch = "develop", + config = function() + require("diffview") -- We require some global state from diffview + local gitlab = require("gitlab") + gitlab.setup() + end, + } < CONNECTING TO GITLAB *gitlab.nvim.connecting-to-gitlab* @@ -418,18 +421,18 @@ SIGNS AND DIAGNOSTICS *gitlab.nvim.signs-and-diagnostics* By default when reviewing files, you will see diagnostics for comments that have been added to a review. These are the default settings: >lua - discussion_signs = { - enabled = true, -- Show diagnostics for gitlab comments in the reviewer - skip_resolved_discussion = false, -- Show diagnostics for resolved discussions - severity = vim.diagnostic.severity.INFO, -- ERROR, WARN, INFO, or HINT - virtual_text = false, -- Whether to show the comment text inline as floating virtual text - use_diagnostic_signs = true, -- Show diagnostic sign (depending on the `severity` setting, e.g., I for INFO) along with the comment icon - priority = 100, -- Higher will override LSP warnings, etc - icons = { - comment = "→|", - range = " |", + discussion_signs = { + enabled = true, -- Show diagnostics for gitlab comments in the reviewer + skip_resolved_discussion = false, -- Show diagnostics for resolved discussions + severity = vim.diagnostic.severity.INFO, -- ERROR, WARN, INFO, or HINT + virtual_text = false, -- Whether to show the comment text inline as floating virtual text + use_diagnostic_signs = true, -- Show diagnostic sign (depending on the `severity` setting, e.g., I for INFO) along with the comment icon + priority = 100, -- Higher will override LSP warnings, etc + icons = { + comment = "→|", + range = " |", + }, }, - }, When the cursor is on a diagnostic line you can view the discussion thread by using `vim.diagnostic.show()`. @@ -630,12 +633,13 @@ default arguments outlined under "Configuring the Plugin". *gitlab.nvim.choose_merge_request* gitlab.choose_merge_request({opts}) ~ -Choose a merge request from a list of those open in your current project to review. -This command will automatically check out that branch locally, and optionally -open the reviewer pane. This is the default behavior. +Choose a merge request from a list of those open in your current project to +review. This command will automatically check out the feature branch locally, +and by default also open the reviewer pane (this can be overridden with the +`open_reviewer` parameter). >lua require("gitlab").choose_merge_request() - require("gitlab").choose_merge_request({ open_reviewer = true }) + require("gitlab").choose_merge_request({ open_reviewer = false }) < Parameters: ~ • {opts}: (table|nil) Keyword arguments to configure the checkout. From f1ec78af1654ee08ec08bbe41f90128203d19543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 26 Jun 2024 16:27:49 +0200 Subject: [PATCH 65/97] Feat: Add branch info to choose_merge_request menu (#318) --- lua/gitlab/actions/merge_requests.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/actions/merge_requests.lua b/lua/gitlab/actions/merge_requests.lua index 127d380c..81f4c2d7 100644 --- a/lua/gitlab/actions/merge_requests.lua +++ b/lua/gitlab/actions/merge_requests.lua @@ -25,7 +25,7 @@ M.choose_merge_request = function(opts) vim.ui.select(state.MERGE_REQUESTS, { prompt = "Choose Merge Request", format_item = function(mr) - return string.format("%s (%s)", mr.title, mr.author.name) + return string.format("%s [%s -> %s] (%s)", mr.title, mr.source_branch, mr.target_branch, mr.author.name) end, }, function(choice) if not choice then From 3a7332931d024e6761bc1da8c97ad4e67e747f5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Tue, 2 Jul 2024 18:16:38 +0200 Subject: [PATCH 66/97] Chore: Add heart emoji (#323) --- config/emojis.json | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/config/emojis.json b/config/emojis.json index 36088efe..5df086aa 100644 --- a/config/emojis.json +++ b/config/emojis.json @@ -3432,6 +3432,54 @@ ], "moji": "🤕" }, + "heart": { + "unicode": "1F495", + "unicode_alternates": [], + "name": "heart", + "shortname": ":heart:", + "category": "people", + "aliases": [], + "aliases_ascii": [], + "keywords": [ + "affection", + "crush", + "face", + "infatuation", + "like", + "love", + "valentines", + "heart", + "lovestruck", + "heart-shaped", + "emotion", + "beautiful" + ], + "moji": "❤️ " + }, + "hearts": { + "unicode": "1F495", + "unicode_alternates": [], + "name": "hearts", + "shortname": ":hearts:", + "category": "people", + "aliases": [], + "aliases_ascii": [], + "keywords": [ + "affection", + "crush", + "face", + "infatuation", + "like", + "love", + "valentines", + "heart", + "lovestruck", + "heart-shaped", + "emotion", + "beautiful" + ], + "moji": "❤️ " + }, "heart_eyes": { "unicode": "1F60D", "unicode_alternates": [], From 8c648be6beb4cde078d92e780320d60301bc5f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Fri, 5 Jul 2024 03:44:36 +0200 Subject: [PATCH 67/97] Feat: Add highlight for mentions (#324) Feat: Adds hightlight for when Gitlab users are mentioned in comments or notes. --- after/syntax/gitlab.vim | 4 +++- doc/gitlab.nvim.txt | 6 +++++- lua/gitlab/colors.lua | 1 + lua/gitlab/state.lua | 15 +++++++++------ 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/after/syntax/gitlab.vim b/after/syntax/gitlab.vim index 4647290a..ef241470 100644 --- a/after/syntax/gitlab.vim +++ b/after/syntax/gitlab.vim @@ -2,7 +2,8 @@ if filereadable($VIMRUNTIME . '/syntax/markdown.vim') source $VIMRUNTIME/syntax/markdown.vim endif -syntax match Username "@\S*" +syntax match Username "\%([]\)\@<= @\S*" +syntax match Mention "\%([] \)\@ Date: Thu, 4 Jul 2024 18:50:00 -0700 Subject: [PATCH 68/97] Fix: Do Not Error w/Out Buffer Content (#325) Fix: Only attempt to return buffer content when creating MR if popups are actually rendered. --- lua/gitlab/utils/init.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 8c0e6e81..f2371227 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -354,6 +354,9 @@ M.split_path = function(path) end M.get_buffer_text = function(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return "" + end local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local text = table.concat(lines, "\n") return text @@ -374,7 +377,6 @@ M.string_to_bool = function(str) elseif str == "false" or str == "False" or str == "FALSE" then return false end - M.notify("Not a valid boolean value `" .. str .. "`. Defaulting to `false`", vim.log.levels.WARN) return false end From 1eed7a80e2c020f2e8adbac70342ab5b1742236f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sat, 6 Jul 2024 18:58:08 +0200 Subject: [PATCH 69/97] Fix diagnostic position when sha changes (#299) Fix: Update diagnostic positions when SHA changes --- README.md | 6 +- lua/gitlab/actions/common.lua | 59 ++++++++++++++----- .../actions/discussions/annotations.lua | 14 +++++ lua/gitlab/hunks.lua | 8 +-- lua/gitlab/indicators/common.lua | 5 +- lua/gitlab/indicators/diagnostics.lua | 22 ++++--- lua/gitlab/utils/init.lua | 1 + 7 files changed, 82 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 5b04bd2a..8ff73267 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ For more settings, please see `:h gitlab.nvim.connecting-to-gitlab` The plugin expects you to call `setup()` and pass in a table of options. All of these values are optional, and if you call this function with no values the defaults will be used. -For a list of all these settings please run `:h gitlab.nvim` which is stored in `doc/gitlab.nvim.txt` +For a list of all these settings please run `:h gitlab.nvim.configuring-the-plugin` which will show you the help stored in [doc/gitlab.nvim.txt](doc/gitlab.nvim.txt). ## Keybindings @@ -155,3 +155,7 @@ vim.keymap.set("n", "glD", gitlab.toggle_draft_mode) ``` For more information about each of these commands, and about the APIs in general, run `:h gitlab.nvim.api` + +## Contributing + +Contributions to the plugin are welcome. Please read [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) before you start working on a pull request. diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua index 8d8c6409..d340dda8 100644 --- a/lua/gitlab/actions/common.lua +++ b/lua/gitlab/actions/common.lua @@ -5,7 +5,6 @@ local List = require("gitlab.utils.list") local u = require("gitlab.utils") local reviewer = require("gitlab.reviewer") local indicators_common = require("gitlab.indicators.common") -local common_indicators = require("gitlab.indicators.common") local state = require("gitlab.state") local M = {} @@ -183,8 +182,8 @@ local function get_new_line(node) return node.new_line end - local _, start_new_line = common_indicators.parse_line_code(range.start.line_code) - return start_new_line + local _, new_start_line = indicators_common.parse_line_code(range.start.line_code) + return new_start_line end ---Takes a node and returns the line where the note is positioned in the old SHA. If @@ -198,12 +197,13 @@ local function get_old_line(node) return node.old_line end - local start_old_line, _ = common_indicators.parse_line_code(range.start.line_code) - return start_old_line + local old_start_line, _ = indicators_common.parse_line_code(range.start.line_code) + return old_start_line end ---@param id string|integer ----@return integer|nil +---@return integer|nil line_number +---@return boolean is_new_sha True if line number refers to NEW SHA M.get_line_number = function(id) ---@type Discussion|DraftNote|nil local d_or_n @@ -214,19 +214,50 @@ M.get_line_number = function(id) end) if d_or_n == nil then - return + return nil, true end local first_note = indicators_common.get_first_note(d_or_n) - return (indicators_common.is_new_sha(d_or_n) and first_note.position.new_line or first_note.position.old_line) or 1 + local is_new_sha = indicators_common.is_new_sha(d_or_n) + return ((is_new_sha and first_note.position.new_line or first_note.position.old_line) or 1), is_new_sha +end + +---Return the start and end line numbers for the note range. The range is calculated from the line +---codes but the position itself is based on either the `new_line` or `old_line`. +---@param old_line integer|nil The line number in the OLD version +---@param new_line integer|nil The line number in the NEW version +---@param start_line_code string The line code for the start of the range +---@param end_line_code string The line code for the end of the range +---@return integer start_line +---@return integer end_line +---@return boolean is_new_sha True if line range refers to NEW SHA +M.get_line_numbers_for_range = function(old_line, new_line, start_line_code, end_line_code) + local old_start_line, new_start_line = indicators_common.parse_line_code(start_line_code) + local old_end_line, new_end_line = indicators_common.parse_line_code(end_line_code) + if old_line ~= nil and old_start_line ~= 0 then + local range = old_end_line - old_start_line + return (old_line - range), old_line, false + elseif new_line ~= nil then + local range = new_end_line - new_start_line + return (new_line - range), new_line, true + else + u.notify("Error getting new or old line for range", vim.log.levels.ERROR) + return 1, 1, false + end end ----@param root_node NuiTree.Node ----@return integer|nil +---@param root_node RootNode +---@return integer|nil line_number +---@return boolean is_new_sha True if line number refers to NEW SHA M.get_line_number_from_node = function(root_node) if root_node.range then - local start_old_line, start_new_line = common_indicators.parse_line_code(root_node.range.start.line_code) - return root_node.old_line and start_old_line or start_new_line + local line_number, _, is_new_sha = M.get_line_numbers_for_range( + root_node.old_line, + root_node.new_line, + root_node.range.start.line_code, + root_node.range["end"].line_code + ) + return line_number, is_new_sha else return M.get_line_number(root_node.id) end @@ -240,12 +271,12 @@ M.jump_to_reviewer = function(tree, callback) u.notify("Could not get discussion node", vim.log.levels.ERROR) return end - local line_number = M.get_line_number_from_node(root_node) + local line_number, is_new_sha = M.get_line_number_from_node(root_node) if line_number == nil then u.notify("Could not get line number", vim.log.levels.ERROR) return end - reviewer.jump(root_node.file_name, line_number, root_node.old_line == nil) + reviewer.jump(root_node.file_name, line_number, is_new_sha) callback() end diff --git a/lua/gitlab/actions/discussions/annotations.lua b/lua/gitlab/actions/discussions/annotations.lua index 408cffe9..1866293d 100644 --- a/lua/gitlab/actions/discussions/annotations.lua +++ b/lua/gitlab/actions/discussions/annotations.lua @@ -133,3 +133,17 @@ ---@field commit_id string -- This will always be "" ---@field line_code string ---@field position NotePosition + +---@class RootNode: NuiTree.Node +---@field range table +---@field old_line integer|nil +---@field new_line integer|nil +---@field id string +---@field text string +---@field type "note" +---@field is_root boolean +---@field root_note_id string +---@field file_name string +---@field resolvable boolean +---@field resolved boolean +---@field url string diff --git a/lua/gitlab/hunks.lua b/lua/gitlab/hunks.lua index 15e7e8b5..cd6e5896 100644 --- a/lua/gitlab/hunks.lua +++ b/lua/gitlab/hunks.lua @@ -191,7 +191,7 @@ local function get_modification_type_from_new_sha(new_line, hunks, all_diff_outp return nil end return List.new(hunks):find(function(hunk) - local new_line_end = hunk.new_line + hunk.new_range + local new_line_end = hunk.new_line + hunk.new_range - (hunk.new_range > 0 and 1 or 0) local in_new_range = new_line >= hunk.new_line and new_line <= new_line_end local is_range_zero = hunk.new_range == 0 and hunk.old_range == 0 return in_new_range and (is_range_zero or line_was_added(new_line, hunk, all_diff_output)) @@ -209,10 +209,10 @@ local function get_modification_type_from_old_sha(old_line, new_line, hunks, all end return List.new(hunks):find(function(hunk) - local old_line_end = hunk.old_line + hunk.old_range - local new_line_end = hunk.new_line + hunk.new_range + local old_line_end = hunk.old_line + hunk.old_range - (hunk.old_range > 0 and 1 or 0) + local new_line_end = hunk.new_line + hunk.new_range - (hunk.new_range > 0 and 1 or 0) local in_old_range = old_line >= hunk.old_line and old_line <= old_line_end - local in_new_range = old_line >= hunk.new_line and new_line <= new_line_end + local in_new_range = new_line >= hunk.new_line and new_line <= new_line_end return (in_old_range or in_new_range) and line_was_removed(old_line, hunk, all_diff_output) end) and "deleted" or "unmodified" end diff --git a/lua/gitlab/indicators/common.lua b/lua/gitlab/indicators/common.lua index e68ee48d..6a72be47 100644 --- a/lua/gitlab/indicators/common.lua +++ b/lua/gitlab/indicators/common.lua @@ -67,8 +67,9 @@ end ---@param d_or_n Discussion|DraftNote ---@return boolean M.is_old_sha = function(d_or_n) - local first_note = M.get_first_note(d_or_n) - return first_note.position.old_line ~= nil + local position = M.get_first_note(d_or_n).position + local old_start_line = position.line_range ~= nil and M.parse_line_code(position.line_range.start.line_code) or nil + return position.old_line ~= nil and old_start_line ~= 0 end ---@param discussion Discussion|DraftNote diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index 42454ac6..ad3c5d67 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -72,19 +72,17 @@ local create_multiline_diagnostic = function(d_or_n) error("Parsing multi-line comment but note does not contain line range") end - local start_old_line, start_new_line = indicators_common.parse_line_code(line_range.start.line_code) + local start_line, end_line, _ = actions_common.get_line_numbers_for_range( + first_note.position.old_line, + first_note.position.new_line, + line_range.start.line_code, + line_range["end"].line_code + ) - if indicators_common.is_new_sha(d_or_n) then - return create_diagnostic({ - lnum = start_new_line - 1, - end_lnum = first_note.position.new_line - 1, - }, d_or_n) - else - return create_diagnostic({ - lnum = start_old_line - 1, - end_lnum = first_note.position.old_line - 1, - }, d_or_n) - end + return create_diagnostic({ + lnum = start_line - 1, + end_lnum = end_line - 1, + }, d_or_n) end ---Set diagnostics in currently new SHA. diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index f2371227..58f57b91 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -524,6 +524,7 @@ M.create_box_popup_state = function(title, enter) top = title, }, }, + opacity = settings.opacity, } end From 85dc29c80984838a967b59328bef3dd046068fa3 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sat, 6 Jul 2024 13:05:32 -0400 Subject: [PATCH 70/97] Fix: Remove API calls on Discussion Close (#328) Fix: Remove API calls on discussion close, only on open --- lua/gitlab/actions/discussions/init.lua | 7 +------ lua/gitlab/init.lua | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 4a418048..2fc59da6 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -111,12 +111,7 @@ end ---Opens the discussion tree, sets the keybindings. It also ---creates the tree for notes (which are not linked to specific lines of code) ---@param callback function? -M.toggle = function(callback) - if M.split_visible then - M.close() - return - end - +M.open = function(callback) state.DISCUSSION_DATA.discussions = u.ensure_table(state.DISCUSSION_DATA.discussions) state.DISCUSSION_DATA.unlinked_discussions = u.ensure_table(state.DISCUSSION_DATA.unlinked_discussions) state.DRAFT_NOTES = u.ensure_table(state.DRAFT_NOTES) diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 8e4c4d0c..8bba13ae 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -68,12 +68,18 @@ return { pipeline = async.sequence({ latest_pipeline }, pipeline.open), merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge), -- Discussion Tree Actions 🌴 - toggle_discussions = async.sequence({ - info, - user, - u.merge(draft_notes_dep, { refresh = true }), - u.merge(discussion_data, { refresh = true }), - }, discussions.toggle), + toggle_discussions = function() + if discussions.split_visible then + discussions.close() + else + async.sequence({ + info, + user, + u.merge(draft_notes_dep, { refresh = true }), + u.merge(discussion_data, { refresh = true }), + }, discussions.open)() + end + end, toggle_draft_mode = discussions.toggle_draft_mode, publish_all_drafts = draft_notes.publish_all_drafts, refresh_data = function() From 29ca058ea45869be761707de1b2cc2b6001d9b0d Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sat, 6 Jul 2024 13:08:39 -0400 Subject: [PATCH 71/97] Remove root node type (#329) --- lua/gitlab/actions/common.lua | 2 +- lua/gitlab/actions/discussions/annotations.lua | 14 -------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua index d340dda8..8ad4f6b8 100644 --- a/lua/gitlab/actions/common.lua +++ b/lua/gitlab/actions/common.lua @@ -246,7 +246,7 @@ M.get_line_numbers_for_range = function(old_line, new_line, start_line_code, end end end ----@param root_node RootNode +---@param root_node NuiTree.Node ---@return integer|nil line_number ---@return boolean is_new_sha True if line number refers to NEW SHA M.get_line_number_from_node = function(root_node) diff --git a/lua/gitlab/actions/discussions/annotations.lua b/lua/gitlab/actions/discussions/annotations.lua index 1866293d..408cffe9 100644 --- a/lua/gitlab/actions/discussions/annotations.lua +++ b/lua/gitlab/actions/discussions/annotations.lua @@ -133,17 +133,3 @@ ---@field commit_id string -- This will always be "" ---@field line_code string ---@field position NotePosition - ----@class RootNode: NuiTree.Node ----@field range table ----@field old_line integer|nil ----@field new_line integer|nil ----@field id string ----@field text string ----@field type "note" ----@field is_root boolean ----@field root_note_id string ----@field file_name string ----@field resolvable boolean ----@field resolved boolean ----@field url string From abd442d5fbd6458c645de8ad4029ea3afea7d155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sun, 28 Jul 2024 21:18:18 +0200 Subject: [PATCH 72/97] Fix: Cycle create_mr popups when create_mr.fork is disabled (#335) Fixes issue with MR creation when fork is enabled --- lua/gitlab/actions/create_mr.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lua/gitlab/actions/create_mr.lua b/lua/gitlab/actions/create_mr.lua index 9f49f974..401162e2 100644 --- a/lua/gitlab/actions/create_mr.lua +++ b/lua/gitlab/actions/create_mr.lua @@ -220,12 +220,15 @@ M.open_confirmation_popup = function(mr) local popups = { title_popup, description_popup, - forked_project_id_popup, delete_branch_popup, squash_popup, target_popup, } + if state.settings.create_mr.fork.enabled then + table.insert(popups, 3, forked_project_id_popup) + end + M.layout = layout M.layout_buf = layout.bufnr M.layout_visible = true From e269d23595c4c8d3988b965791bbbbbb3f7aef24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Thu, 8 Aug 2024 14:43:05 +0200 Subject: [PATCH 73/97] Feat: Add default keymaps (#331) feat: Implements default keybindings for all actions. This is a breaking change. --- README.md | 32 +-- doc/gitlab.nvim.txt | 261 ++++++++++++------- lua/gitlab/actions/comment.lua | 2 +- lua/gitlab/actions/common.lua | 4 +- lua/gitlab/actions/discussions/init.lua | 290 ++++++++++++++------- lua/gitlab/actions/discussions/tree.lua | 8 +- lua/gitlab/actions/discussions/winbar.lua | 4 +- lua/gitlab/actions/miscellaneous.lua | 21 +- lua/gitlab/init.lua | 3 +- lua/gitlab/reviewer/init.lua | 140 +++++++++++ lua/gitlab/state.lua | 292 +++++++++++++++++++--- lua/gitlab/utils/init.lua | 13 + 12 files changed, 813 insertions(+), 257 deletions(-) diff --git a/README.md b/README.md index 8ff73267..320c979a 100644 --- a/README.md +++ b/README.md @@ -122,37 +122,7 @@ For a list of all these settings please run `:h gitlab.nvim.configuring-the-plug ## Keybindings -The plugin does not set up any keybindings outside of the special buffers it creates, -you need to set them up yourself. Here's what I'm using: - -```lua -local gitlab = require("gitlab") -local gitlab_server = require("gitlab.server") -vim.keymap.set("n", "glb", gitlab.choose_merge_request) -vim.keymap.set("n", "glr", gitlab.review) -vim.keymap.set("n", "gls", gitlab.summary) -vim.keymap.set("n", "glA", gitlab.approve) -vim.keymap.set("n", "glR", gitlab.revoke) -vim.keymap.set("n", "glc", gitlab.create_comment) -vim.keymap.set("v", "glc", gitlab.create_multiline_comment) -vim.keymap.set("v", "glC", gitlab.create_comment_suggestion) -vim.keymap.set("n", "glO", gitlab.create_mr) -vim.keymap.set("n", "glm", gitlab.move_to_discussion_tree_from_diagnostic) -vim.keymap.set("n", "gln", gitlab.create_note) -vim.keymap.set("n", "gld", gitlab.toggle_discussions) -vim.keymap.set("n", "glaa", gitlab.add_assignee) -vim.keymap.set("n", "glad", gitlab.delete_assignee) -vim.keymap.set("n", "glla", gitlab.add_label) -vim.keymap.set("n", "glld", gitlab.delete_label) -vim.keymap.set("n", "glra", gitlab.add_reviewer) -vim.keymap.set("n", "glrd", gitlab.delete_reviewer) -vim.keymap.set("n", "glp", gitlab.pipeline) -vim.keymap.set("n", "glo", gitlab.open_in_browser) -vim.keymap.set("n", "glM", gitlab.merge) -vim.keymap.set("n", "glu", gitlab.copy_mr_url) -vim.keymap.set("n", "glP", gitlab.publish_all_drafts) -vim.keymap.set("n", "glD", gitlab.toggle_draft_mode) -``` +The plugin sets up a number of useful keybindings in the special buffers it creates, and some global keybindings as well. Refer to the relevant section of the manual `:h gitlab.nvim.keybindings` for more details. For more information about each of these commands, and about the APIs in general, run `:h gitlab.nvim.api` diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 1a5cde20..6afc3679 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -166,14 +166,71 @@ you call this function with no values the defaults will be used: connection_settings = { insecure = false, -- Like curl's --insecure option, ignore bad x509 certificates on connection }, - help = "g?", -- Opens a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) - popup = { -- The popup for comment creation, editing, and replying - keymaps = { + keymaps = { + disable_all = false, -- Disable all mappings created by the plugin + help = "g?", -- Open a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) + global = { + disable_all = false, -- Disable all global mappings created by the plugin + add_assignee = "glaa", + delete_assignee = "glad", + add_label = "glla", + delete_label = "glld", + add_reviewer = "glra", + delete_reviewer = "glrd", + approve = "glA", -- Approve MR + revoke = "glR", -- Revoke MR approval + merge = "glM", -- Merge the feature branch to the target branch and close MR + create_mr = "glC", -- Create a new MR for currently checked-out feature branch + choose_merge_request = "glc", -- Chose MR for review (if necessary check out the feature branch) + start_review = "glS", -- Start review for the currently checked-out branch + summary = "gls", -- Show the editable summary of the MR + copy_mr_url = "glu", -- Copy the URL of the MR to the system clipboard + open_in_browser = "glo", -- Openthe URL of the MR in the default Internet browser + create_note = "gln", -- Create a note (comment not linked to a specific line) + pipeline = "glp", -- Show the pipeline status + toggle_discussions = "gld", -- Toggle the discussions window + toggle_draft_mode = "glD", -- Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately) + publish_all_drafts = "glP", -- Publish all draft comments/notes + }, + popup = { + disable_all = false, -- Disable all default mappings for the popup windows (comments, summary, MR creation, etc.) next_field = "", -- Cycle to the next field. Accepts |count|. prev_field = "", -- Cycle to the previous field. Accepts |count|. + perform_action = "ZZ", -- Once in normal mode, does action (like saving comment or applying description edit, etc) + perform_linewise_action = "ZA", -- Once in normal mode, does the linewise action (see logs for this job, etc) + discard_changes = "ZQ", -- Quit the popup discarding changes, the popup content is not saved to the `temp_registers` (see `:h gitlab.nvim.temp-registers`) + }, + discussion_tree = { + disable_all = false, -- Disable all default mappings for the discussion tree window + add_emoji = "Ea", -- Add an emoji to the note/comment + delete_emoji = "Ed", -- Remove an emoji from a note/comment + delete_comment = "dd", -- Delete comment + edit_comment = "e", -- Edit comment + reply = "r", -- Reply to comment + toggle_resolved = "-", -- Toggle the resolved status of the whole discussion + jump_to_file = "o", -- Jump to comment location in file + jump_to_reviewer = "a", -- Jump to the comment location in the reviewer window + open_in_browser = "b", -- Jump to the URL of the current note/discussion + copy_node_url = "u", -- Copy the URL of the current node to clipboard + switch_view = "c", -- Toggle between the notes and discussions views + toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name" + publish_draft = "P", -- Publish the currently focused note/comment + toggle_draft_mode = "D", -- Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately) + toggle_node = "t", -- Open or close the discussion + toggle_all_discussions = "T", -- Open or close separately both resolved and unresolved discussions + toggle_resolved_discussions = "R", -- Open or close all resolved discussions + toggle_unresolved_discussions = "U", -- Open or close all unresolved discussions + refresh_data = "", -- Refresh the data in the view by hitting Gitlab's APIs again + print_node = "p", -- Print the current node (for debugging) + }, + reviewer = { + disable_all = false, -- Disable all default mappings for the reviewer windows + create_comment = "c", -- Create a comment for the lines that the following {motion} moves over. Repeat the key(s) for creating comment for the current line + create_suggestion = "s", -- Create a suggestion for the lines that the following {motion} moves over. Repeat the key(s) for creating comment for the current line + move_to_discussion_tree = "a", -- Jump to the comment in the discussion tree }, - perform_action = "s", -- Once in normal mode, does action (like saving comment or editing description, etc) - perform_linewise_action = "l", -- Once in normal mode, does the linewise action (see logs for this job, etc) + }, + popup = { -- The popup for comment creation, editing, and replying width = "40%", height = "60%", border = "rounded", -- One of "rounded", "single", "double", "solid" @@ -188,35 +245,16 @@ you call this function with no values the defaults will be used: }, discussion_tree = { -- The discussion tree that holds all comments auto_open = true, -- Automatically open when the reviewer is opened - switch_view = "S", -- Toggles between the notes and discussions views default_view = "discussions" -- Show "discussions" or "notes" by default 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 comment - delete_comment = "dd", -- Delete comment - refresh_data = "a", -- Refreshes the data in the view by hitting Gitlab's APIs again - reply = "r", -- Reply to comment - toggle_node = "t", -- Opens or closes the discussion - add_emoji = "Ea" -- Add an emoji to the note/comment - delete_emoji = "Ed" -- Remove an emoji from a note/comment - toggle_all_discussions = "T", -- Open or close separately both resolved and unresolved discussions - toggle_resolved_discussions = "R", -- Open or close all resolved discussions - toggle_unresolved_discussions = "U", -- Open or close all unresolved discussions keep_current_open = false, -- If true, current discussion stays open even if it should otherwise be closed when toggling - publish_draft = "P", -- Publishes the currently focused note/comment - toggle_resolved = "p" -- Toggles the resolved status of the whole discussion position = "left", -- "top", "right", "bottom" or "left" - open_in_browser = "b" -- Jump to the URL of the current note/discussion - copy_node_url = "u", -- Copy the URL of the current node to clipboard size = "20%", -- Size of split relative = "editor", -- Position of tree split relative to "editor" or "window" resolved = '✓', -- Symbol to show next to resolved discussions unresolved = '-', -- Symbol to show next to unresolved discussions tree_type = "simple", -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file - toggle_tree_type = "i", -- Toggle type of discussion tree - "simple", or "by_file_name" draft_mode = false, -- Whether comments are posted as drafts as part of a review - toggle_draft_mode = "D" -- Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately) winbar = nil -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) -- If using lualine, please add "gitlab" to disabled file types, otherwise you will not see the winbar. }, @@ -314,7 +352,7 @@ The `summary` action will open the MR title and description: require("gitlab").summary() < After editing the description or title, you may save your changes via the -`settings.popup.perform_action` keybinding. +`keymaps.popup.perform_action` keybinding. By default this plugin will also show additional metadata about the MR in a separate pane underneath the description. This can be disabled, and these @@ -338,20 +376,20 @@ For suggesting changes you can use `create_comment_suggestion` in visual mode which works similar to `create_multiline_comment` but prefills the comment window with Gitlab’s suggest changes -code block with prefilled code from the visual selection. -Just like the summary, all the different kinds of comments are saved via the -`settings.popup.perform_action` keybinding. +code block with prefilled code from the visual selection. Just like the +summary, all the different kinds of comments are saved via the +`keymaps.popup.perform_action` keybinding. DRAFT NOTES *gitlab.nvim.draft-comments* When you publish a "draft" of any of the above resources, the comment will be added to a review. You can configure the default commenting mode (draft vs live) via the `state.settings.discussion_tree.draft_mode` setting, and you can -toggle the setting with the `state.settings.discussion_tree.toggle_draft_mode` +toggle the setting with the `keymaps.discussion_tree.toggle_draft_mode` keybinding, or by calling the `gitlab.toggle_draft_mode()` function. You may publish all draft comments via the `gitlab.publish_all_drafts()` function, and you can publish an individual comment or note by pressing the -`state.settings.discussion_tree.publish_draft` keybinding. +`keymaps.discussion_tree.publish_draft` keybinding. Draft notes do not support replying or emojis. @@ -372,9 +410,13 @@ else. Using the clipboard register lets you easily use the text outside of nvim. NOTE: The `temp_registers` are also filled with the contents of the popup when -pressing the `settings.popup.perform_action` keybinding, even if the action +pressing the `keymaps.popup.perform_action` keybinding, even if the action that was supposed to be performed fails. +If you don't want the popup contents to be saved to the `temp_registers`, quit +the popup using the `keymaps.pupup.discard_changes` keybinding, which is `ZQ` +by default (compare the builtin |ZQ| command). + DISCUSSIONS AND NOTES *gitlab.nvim.discussions-and-notes* Gitlab groups threads of comments together into "discussions." @@ -386,11 +428,11 @@ action, which will show the discussions in a split window: < You can jump to the comment’s location in the reviewer window by using the -`state.settings.discussion_tree.jump_to_reviewer` key, or to the actual file -with the `state.settings.discussion_tree.jump_to_file` key. +`keymaps.discussion_tree.jump_to_reviewer` keybinding, or to the actual file +with the `keymaps.discussion_tree.jump_to_file` keybinding. Within the discussion tree, you can delete/edit/reply to comments with the -`state.settings.discussion_tree.SOME_ACTION` keybindings. +`keymaps.discussion_tree.SOME_ACTION` keybindings. If you’d like to create a note in an MR (like a comment, but not linked to a specific line) use the `create_note` action. The same keybindings for @@ -466,14 +508,14 @@ emojis that you have responded with. UPLOADING FILES *gitlab.nvim.uploading-files* To attach a file to an MR description, reply, comment, and so forth use the -`settings.popup.perform_linewise_action` keybinding when the popup is open. +`keymaps.popup.perform_linewise_action` keybinding when the popup is open. This will open a picker that will look for files in the directory you specify in the `settings.attachment_dir` folder (this must be an absolute path). When you have picked the file, it will be added to the current buffer at the current line. -Use the `settings.popup.perform_action` to send the changes to Gitlab. +Use the `keymaps.popup.perform_action` to send the changes to Gitlab. MR APPROVALS *gitlab.nvim.mr-approvals* @@ -515,8 +557,8 @@ action. require("gitlab").pipeline() < To re-trigger failed jobs in the pipeline manually, use the -`settings.popup.perform_action` keybinding. To open the log trace of a job in a -new Neovim buffer, use your `settings.popup.perform_linewise_action` +`keymaps.popup.perform_action` keybinding. To open the log trace of a job in a +new Neovim buffer, use your `keymaps.popup.perform_linewise_action` keybinding. @@ -565,34 +607,75 @@ reviewer when checking out a new branch: KEYBINDINGS *gitlab.nvim.keybindings* -The plugin does not set up any keybindings outside of the special buffers it -creates, you need to set them up yourself. Here’s what I’m using (note that -the `` prefix is not necessary, as `gl` does not have a special meaning -in normal mode): +The `gitlab.nvim` plugin sets up a number of default keybindings for the +discussion tree, the popup windows and the reviewer windows, and also some +global keybindings that are available in any buffer. You can find the defaults +in the `keymaps` section of the configuration table (see +|gitlab.nvim.configuring-the-plugin|) and you can see the current buffer-local +mappings by pressing the `keymaps.help` keybinding (`g?` by default). The +`help` mapping is not set in the reviewer windows which are managed by the +Diffview plugin, see |diffview-config-keymaps|. + +You can set any of the mappings to whatever you want. You can also prevent the +plugin from setting up any keybindings whatsoever, or any keybindings for +individual buffers by setting `keymaps.disable_all`, +`keymaps.global.disable_all`, `keymaps.popup.disable_all`, etc. to `true`. +Alternatively, you can disable individual keybindings by setting the value of +the field to `false`, e.g., `keymaps = {global {merge = false}}`, which will +leave all the other keybindings in place. + +The global keymaps all use the `gl` prefix as it does not have a special +meaning in normal mode. You can add your own global keybindings by calling +something like this after the `gitlab.setup()` call: >lua local gitlab = require("gitlab") - local gitlab_server = require("gitlab.server") - vim.keymap.set("n", "glr", gitlab.review) - vim.keymap.set("n", "gls", gitlab.summary) - vim.keymap.set("n", "glA", gitlab.approve) - vim.keymap.set("n", "glR", gitlab.revoke) - vim.keymap.set("n", "glc", gitlab.create_comment) - vim.keymap.set("v", "glc", gitlab.create_multiline_comment) - vim.keymap.set("v", "glC", gitlab.create_comment_suggestion) - vim.keymap.set("n", "glO", gitlab.create_mr) - vim.keymap.set("n", "glm", gitlab.move_to_discussion_tree_from_diagnostic) - vim.keymap.set("n", "gln", gitlab.create_note) - vim.keymap.set("n", "gld", gitlab.toggle_discussions) - vim.keymap.set("n", "glaa", gitlab.add_assignee) - vim.keymap.set("n", "glad", gitlab.delete_assignee) - vim.keymap.set("n", "glra", gitlab.add_reviewer) - vim.keymap.set("n", "glrd", gitlab.delete_reviewer) - vim.keymap.set("n", "glp", gitlab.pipeline) - vim.keymap.set("n", "glo", gitlab.open_in_browser) - vim.keymap.set("n", "glM", gitlab.merge) - vim.keymap.set("n", "glu", gitlab.copy_mr_url) - vim.keymap.set("n", "glP", gitlab.publish_all_drafts) - vim.keymap.set("n", "glD", gitlab.toggle_draft_mode) + vim.keymap.set("n", "gl", gitlab.print_settings, { desc = "Print gitlab.nvim settings"}) +< +See |gitlab.nvim.api| for an overview of available API functions that you can +use in your mappings. + +If you want to set up your own additional keybindings for the discussion tree, +you can put them in the `$XDG_CONFIG_HOME/nvim/after/ftplugin/gitlab.lua` +file. E.g., if you wanted to use the `j` and `k` keys to move to the next +discussion node instead of the next line, you could add to the |ftplugin| file +the following lines: +>lua + vim.keymap.set("n", "j", [[call search('[] @')]], { buffer = 0, desc = "Go to next node" }) + vim.keymap.set("n", "k", [[call search('[] @', 'b')]], { buffer = 0, desc = "Go to previous node" }) +< + +Reviewer keybindings ~ + +Most of the keybindings `gitlab.nvim` sets are normal mode mappings, with the +exception of `keymaps.reviewer.create_comment` and +`keymaps.reviewer.create_suggestion` which work in both normal and visual +mode. In normal mode, these keybindings are |operator|s that accept a |motion| +(with an optional |count|). E.g., `c2j` will create a comment for the current +and the next 2 lines. Similarly, `sip` will create a suggestion for the "inner +paragraph". The operator forces |linewise| visual selection, so it works +correctly even if the motion itself works |characterwise| (e.g., |i(| for +selecting the inner parentheses block). + +The keybindings also work in visual mode, e.g., if you first want to make +sure you are commenting on the right text segment/object, you can do `v4j` to +visually select the current and the next 4 lines, followed by either `c` for a +normal comment or `s` for a suggestion. + +To create a comment or suggestion for the current line, just duplicate the +keybinding: `cc` and `ss`. Alternatively, you can use any motion that moves on +that line only, e.g., `c$`. The same logic applies also when you change these +keybindings, e.g., to something like `c`. + +Delay in keybindings ~ + +You may experience a lag in some of the keybindings, because you have existing +keybinding starting with they same key(s). You can force the `gitlab.nvim` +mapping to use |nowait| and fire immediately (and lose your other mapping for +this buffer) by adding a special field called `nowait` to your keymap. For example, +if the name of the keybinding is `reviewer.create_comment`, then you add the +following to your config: +>lua + keymaps = { reviewer = { create_comment_nowait = true }} < TROUBLESHOOTING *gitlab.nvim.troubleshooting* @@ -668,7 +751,7 @@ in the setup call. require("gitlab").summary() The summary can be edited. Once you have made changes, send them to Gitlab via -the `settings.popup.perform_action` keybinding. +the `keymaps.popup.perform_action` keybinding. *gitlab.nvim.approve* gitlab.approve() ~ @@ -694,8 +777,8 @@ reviewer pane (see the gitlab.nvim.review command), otherwise it will error. >lua require("gitlab").create_comment() -After the comment is typed, submit it to Gitlab via the `settings.popup.perform_action` -keybinding, by default `l`. +After the comment is typed, submit it to Gitlab via the +`keymaps.popup.perform_action` keybinding, by default `ZZ`. *gitlab.nvim.create_multiline_comment* gitlab.create_multiline_comment() ~ @@ -705,8 +788,8 @@ mode, and will use the currently selected lines. >lua require("gitlab").create_multiline_comment() -After the comment is typed, submit it to Gitlab via the |settings.popup.perform_linewise_action| -keybinding, by default `l`. +After the comment is typed, submit it to Gitlab via the +`keymaps.popup.perform_linewise_action` keybinding, by default `ZA`. *gitlab.nvim.create_comment_suggestion* gitlab.create_comment_suggestion() ~ @@ -716,8 +799,8 @@ change suggestion to the currently selected lines). >lua require("gitlab").create_multiline_comment() -After the comment is typed, submit it to Gitlab via the |settings.popup.perform_linewise_action| -keybinding, by default |l| +After the comment is typed, submit it to Gitlab via the +`keymaps.popup.perform_linewise_action` keybinding, by default `ZA`. *gitlab.nvim.create_mr* gitlab.create_mr({opts}) ~ @@ -751,12 +834,12 @@ Starts the process of creating an MR for the currently checked out branch. After selecting all necessary details, you'll be presented with a confirmation window. You can cycle through the individual fields with the keymaps defined -in `settings.popup.keymaps.next_field` and `settings.popup.keymaps.prev_field`. -Both keymaps accept a count, i.g., 2 goes to the 2nd next field. -In the "Delete source branch", "Squash commits", and "Target branch" fields, -you can use the `settings.popup.perform_linewise_action` keymap to either -toggle the Boolean value or to select a new target branch, respectively. -Use the `settings.popup.perform_action` keymap to POST the MR to Gitlab. +in `keymaps.popup.next_field` and `keymaps.popup.prev_field`. Both keymaps +accept a count, i.g., 2 goes to the 2nd next field. In the "Delete source +branch", "Squash commits", and "Target branch" fields, you can use the +`keymaps.popup.perform_linewise_action` keymap to either toggle the Boolean +value or to select a new target branch, respectively. Use the +`keymaps.popup.perform_action` keymap to POST the MR to Gitlab. *gitlab.nvim.move_to_discussion_tree_from_diagnostic* gitlab.move_to_discussion_tree_from_diagnostic() ~ @@ -776,8 +859,8 @@ tied to specific changes in an MR. >lua require("gitlab").create_note() -After the comment is typed, submit it to Gitlab via the `settings.popup.perform_action` -keybinding, by default |s|. +After the comment is typed, submit it to Gitlab via the +`keymaps.popup.perform_action` keybinding, by default `ZZ`. *gitlab.nvim.toggle_discussions* gitlab.toggle_discussions() ~ @@ -786,15 +869,16 @@ Toggles visibility of the discussion tree. >lua require("gitlab").toggle_discussions() -Once the discussion tree is open, a number of different keybindings are available -for interacting with different discussions. Please see the `settings.discussion_tree` -section of the setup call for more information about different keybindings. +Once the discussion tree is open, a number of different keybindings are +available for interacting with different discussions. Please see the +`keymaps.discussion_tree` section of the setup call for more information about +different keybindings. *gitlab.nvim.publish_all_drafts* gitlab.publish_all_drafts() ~ -Publishes all unpublished draft notes. Used to finish a review and make all notes and -comments visible. +Publishes all unpublished draft notes. Used to finish a review and make all +notes and comments visible. >lua require("gitlab").publish_all_drafts() < @@ -853,8 +937,9 @@ Opens up a popup with information about the pipeline for the current merge reque >lua require("gitlab").pipeline() < -To re-trigger failed jobs in the pipeline manually, use the `settings.popup.perform_action` keybinding. -To open the log trace of a job in a new Neovim buffer, use your `settings.popup.perform_linewise_action` +To re-trigger failed jobs in the pipeline manually, use the +`keymaps.popup.perform_action` keybinding. To open the log trace of a job in a +new Neovim buffer, use your `keymaps.popup.perform_linewise_action` keybinding. *gitlab.nvim.open_in_browser* @@ -891,8 +976,8 @@ Gitlab online. You can see the current settings in the Summary view, see • {squash}: (bool) If true, the commits will be squashed. If you enable {squash} you will be prompted for a squash message. To use the default message, leave the popup empty. - Use the `settings.popup.perform_action` to merge the MR with - your message. + Use the `keymaps.popup.perform_action` to merge the MR + with your message. *gitlab.nvim.data* gitlab.data({resources}, {cb}) ~ diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index b4c9aa18..4723a364 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -22,7 +22,7 @@ local M = { } ---Fires the API that sends the comment data to the Go server, called when you "confirm" creation ----via the M.settings.popup.perform_action keybinding +---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 diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua index 8ad4f6b8..e409454c 100644 --- a/lua/gitlab/actions/common.lua +++ b/lua/gitlab/actions/common.lua @@ -263,7 +263,7 @@ M.get_line_number_from_node = function(root_node) 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 +-- This function (settings.keymaps.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(tree, callback) local node = tree:get_node() local root_node = M.get_root_node(tree, node) @@ -280,7 +280,7 @@ M.jump_to_reviewer = function(tree, callback) callback() end --- This function (settings.discussion_tree.jump_to_file) will jump to the file changed in a new tab +-- This function (settings.keymaps.discussion_tree.jump_to_file) will jump to the file changed in a new tab M.jump_to_file = function(tree) local node = tree:get_node() local root_node = M.get_root_node(tree, node) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 2fc59da6..cc5421b6 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -74,6 +74,7 @@ M.initialize_discussions = function() reviewer.set_callback_for_file_changed(function() M.refresh_diagnostics_and_winbar() M.modifiable(false) + reviewer.set_reviewer_keymaps() end) reviewer.set_callback_for_reviewer_enter(function() M.modifiable(false) @@ -82,6 +83,7 @@ M.initialize_discussions = function() signs.clear_signs() diagnostics.clear_diagnostics() M.modifiable(true) + reviewer.del_reviewer_keymaps() end) end @@ -219,7 +221,7 @@ M.move_to_discussion_tree = function() 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. +-- The reply popup will mount in a window when you trigger it (settings.keymaps.discussion_tree.reply) when hovering over a node in the discussion tree. M.reply = function(tree) if M.is_draft_note(tree) then u.notify("Gitlab does not support replying to draft notes", vim.log.levels.WARN) @@ -241,7 +243,7 @@ M.reply = function(tree) layout:mount() end --- This function (settings.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment +-- This function (settings.keymaps.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment M.delete_comment = function(tree, unlinked) vim.ui.select({ "Confirm", "Cancel" }, { prompt = "Delete comment?", @@ -267,7 +269,7 @@ M.delete_comment = function(tree, unlinked) end) end --- This function (settings.discussion_tree.edit_comment) will open the edit popup for the current comment in the discussion tree +-- This function (settings.keymaps.discussion_tree.edit_comment) will open the edit popup for the current comment in the discussion tree M.edit_comment = function(tree, unlinked) local edit_popup = Popup(u.create_popup_state("Edit Comment", state.settings.popup.edit)) local current_node = tree:get_node() @@ -312,7 +314,7 @@ M.edit_comment = function(tree, unlinked) end end --- This function (settings.discussion_tree.toggle_discussion_resolved) will toggle the resolved status of the current discussion and send the change to the Go server +-- This function (settings.keymaps.discussion_tree.toggle_discussion_resolved) will toggle the resolved status of the current discussion and send the change to the Go server M.toggle_discussion_resolved = function(tree) local note = tree:get_node() if note == nil then @@ -527,101 +529,215 @@ M.is_current_node_note = function(tree) end M.set_tree_keymaps = function(tree, bufnr, unlinked) + -- Require keymaps only after user settings have been merged with defaults + local keymaps = require("gitlab.state").settings.keymaps + if keymaps.disable_all or keymaps.discussion_tree.disable_all then + return + end + ---Keybindings only relevant for linked (comment) view if not unlinked then - vim.keymap.set("n", state.settings.discussion_tree.jump_to_file, function() + if keymaps.discussion_tree.jump_to_file then + vim.keymap.set("n", keymaps.discussion_tree.jump_to_file, function() + if M.is_current_node_note(tree) then + common.jump_to_file(tree) + end + end, { buffer = bufnr, desc = "Jump to file", nowait = keymaps.discussion_tree.jump_to_file_nowait }) + end + + if keymaps.discussion_tree.jump_to_reviewer then + vim.keymap.set("n", keymaps.discussion_tree.jump_to_reviewer, function() + if M.is_current_node_note(tree) then + common.jump_to_reviewer(tree, M.refresh_diagnostics_and_winbar) + end + end, { buffer = bufnr, desc = "Jump to reviewer", nowait = keymaps.discussion_tree.jump_to_reviewer_nowait }) + end + + if keymaps.discussion_tree.toggle_tree_type then + vim.keymap.set("n", keymaps.discussion_tree.toggle_tree_type, function() + M.toggle_tree_type() + end, { + buffer = bufnr, + desc = "Change tree type between `simple` and `by_file_name`", + nowait = keymaps.discussion_tree.toggle_tree_type_nowait, + }) + end + end + + if keymaps.discussion_tree.refresh_data then + vim.keymap.set("n", keymaps.discussion_tree.refresh_data, function() + u.notify("Refreshing data...", vim.log.levels.INFO) + draft_notes.rebuild_view(unlinked, false) + end, { + buffer = bufnr, + desc = "Refresh the view with Gitlab's APIs", + nowait = keymaps.discussion_tree.refresh_data_nowait, + }) + end + + if keymaps.discussion_tree.edit_comment then + vim.keymap.set("n", keymaps.discussion_tree.edit_comment, function() if M.is_current_node_note(tree) then - common.jump_to_file(tree) + M.edit_comment(tree, unlinked) end - end, { buffer = bufnr, desc = "Jump to file" }) - vim.keymap.set("n", state.settings.discussion_tree.jump_to_reviewer, function() + end, { buffer = bufnr, desc = "Edit comment", nowait = keymaps.discussion_tree.edit_comment_nowait }) + end + + if keymaps.discussion_tree.publish_draft then + vim.keymap.set("n", keymaps.discussion_tree.publish_draft, function() + if M.is_draft_note(tree) then + draft_notes.publish_draft(tree) + end + end, { buffer = bufnr, desc = "Publish draft", nowait = keymaps.discussion_tree.publish_draft_nowait }) + end + + if keymaps.discussion_tree.delete_comment then + vim.keymap.set("n", keymaps.discussion_tree.delete_comment, function() if M.is_current_node_note(tree) then - common.jump_to_reviewer(tree, M.refresh_diagnostics_and_winbar) + M.delete_comment(tree, unlinked) end - end, { buffer = bufnr, desc = "Jump to reviewer" }) - vim.keymap.set("n", state.settings.discussion_tree.toggle_tree_type, function() - M.toggle_tree_type() - end, { buffer = bufnr, desc = "Toggle tree type between `simple` and `by_file_name`" }) + end, { buffer = bufnr, desc = "Delete comment", nowait = keymaps.discussion_tree.delete_comment_nowait }) end - vim.keymap.set("n", state.settings.discussion_tree.refresh_data, function() - u.notify("Refreshing data...", vim.log.levels.INFO) - draft_notes.rebuild_view(unlinked, false) - end, { buffer = bufnr, desc = "Refreshes the view with Gitlab's APIs" }) + if keymaps.discussion_tree.toggle_draft_mode then + vim.keymap.set("n", keymaps.discussion_tree.toggle_draft_mode, function() + M.toggle_draft_mode() + end, { + buffer = bufnr, + desc = "Toggle between draft mode and live mode", + nowait = keymaps.discussion_tree.toggle_draft_mode_nowait, + }) + end - vim.keymap.set("n", state.settings.discussion_tree.edit_comment, function() - if M.is_current_node_note(tree) then - M.edit_comment(tree, unlinked) - end - end, { buffer = bufnr, desc = "Edit comment" }) - vim.keymap.set("n", state.settings.discussion_tree.publish_draft, function() - if M.is_draft_note(tree) then - draft_notes.publish_draft(tree) - end - end, { buffer = bufnr, desc = "Publish draft" }) - vim.keymap.set("n", state.settings.discussion_tree.delete_comment, function() - if M.is_current_node_note(tree) then - M.delete_comment(tree, unlinked) - end - end, { buffer = bufnr, desc = "Delete comment" }) - vim.keymap.set("n", state.settings.discussion_tree.toggle_draft_mode, function() - M.toggle_draft_mode() - end, { buffer = bufnr, desc = "Toggle between draft mode and live mode" }) - vim.keymap.set("n", state.settings.discussion_tree.toggle_resolved, function() - if M.is_current_node_note(tree) and not M.is_draft_note(tree) then - M.toggle_discussion_resolved(tree) - end - end, { buffer = bufnr, desc = "Toggle resolved" }) - vim.keymap.set("n", state.settings.discussion_tree.toggle_node, function() - tree_utils.toggle_node(tree) - end, { buffer = bufnr, desc = "Toggle node" }) - vim.keymap.set("n", state.settings.discussion_tree.toggle_all_discussions, function() - tree_utils.toggle_nodes(M.split.winid, tree, unlinked, { - toggle_resolved = true, - toggle_unresolved = true, - keep_current_open = state.settings.discussion_tree.keep_current_open, + if keymaps.discussion_tree.toggle_resolved then + vim.keymap.set("n", keymaps.discussion_tree.toggle_resolved, function() + if M.is_current_node_note(tree) and not M.is_draft_note(tree) then + M.toggle_discussion_resolved(tree) + end + end, { buffer = bufnr, desc = "Toggle resolved", nowait = keymaps.discussion_tree.toggle_resolved_nowait }) + end + + if keymaps.discussion_tree.toggle_node then + vim.keymap.set("n", keymaps.discussion_tree.toggle_node, function() + tree_utils.toggle_node(tree) + end, { buffer = bufnr, desc = "Toggle node", nowait = keymaps.discussion_tree.toggle_node_nowait }) + end + + if keymaps.discussion_tree.toggle_all_discussions then + vim.keymap.set("n", keymaps.discussion_tree.toggle_all_discussions, function() + tree_utils.toggle_nodes(M.split.winid, tree, unlinked, { + toggle_resolved = true, + toggle_unresolved = true, + keep_current_open = state.settings.discussion_tree.keep_current_open, + }) + end, { + buffer = bufnr, + desc = "Toggle all nodes", + nowait = keymaps.discussion_tree.toggle_all_discussions_nowait, }) - end, { buffer = bufnr, desc = "Toggle all nodes" }) - vim.keymap.set("n", state.settings.discussion_tree.toggle_resolved_discussions, function() - tree_utils.toggle_nodes(M.split.winid, tree, unlinked, { - toggle_resolved = true, - toggle_unresolved = false, - keep_current_open = state.settings.discussion_tree.keep_current_open, + end + + if keymaps.discussion_tree.toggle_resolved_discussions then + vim.keymap.set("n", keymaps.discussion_tree.toggle_resolved_discussions, function() + tree_utils.toggle_nodes(M.split.winid, tree, unlinked, { + toggle_resolved = true, + toggle_unresolved = false, + keep_current_open = state.settings.discussion_tree.keep_current_open, + }) + end, { + buffer = bufnr, + desc = "Toggle resolved nodes", + nowait = keymaps.discussion_tree.toggle_resolved_discussions_nowait, }) - end, { buffer = bufnr, desc = "Toggle resolved nodes" }) - vim.keymap.set("n", state.settings.discussion_tree.toggle_unresolved_discussions, function() - tree_utils.toggle_nodes(M.split.winid, tree, unlinked, { - toggle_resolved = false, - toggle_unresolved = true, - keep_current_open = state.settings.discussion_tree.keep_current_open, + end + + if keymaps.discussion_tree.toggle_unresolved_discussions then + vim.keymap.set("n", keymaps.discussion_tree.toggle_unresolved_discussions, function() + tree_utils.toggle_nodes(M.split.winid, tree, unlinked, { + toggle_resolved = false, + toggle_unresolved = true, + keep_current_open = state.settings.discussion_tree.keep_current_open, + }) + end, { + buffer = bufnr, + desc = "Toggle unresolved nodes", + nowait = keymaps.discussion_tree.toggle_unresolved_discussions_nowait, }) - end, { buffer = bufnr, desc = "Toggle unresolved nodes" }) - vim.keymap.set("n", state.settings.discussion_tree.reply, function() - if M.is_current_node_note(tree) then - M.reply(tree) - end - end, { buffer = bufnr, desc = "Reply" }) - vim.keymap.set("n", state.settings.discussion_tree.switch_view, function() - winbar.switch_view_type() - end, { buffer = bufnr, desc = "Switch view type" }) - vim.keymap.set("n", state.settings.help, function() - help.open() - end, { buffer = bufnr, desc = "Open help popup" }) - vim.keymap.set("n", state.settings.discussion_tree.open_in_browser, function() - common.open_in_browser(tree) - end, { buffer = bufnr, desc = "Open the note in your browser" }) - vim.keymap.set("n", state.settings.discussion_tree.copy_node_url, function() - common.copy_node_url(tree) - end, { buffer = bufnr, desc = "Copy the URL of the current node to clipboard" }) - vim.keymap.set("n", "p", function() - common.print_node(tree) - end, { buffer = bufnr, desc = "Print current node (for debugging)" }) - vim.keymap.set("n", state.settings.discussion_tree.add_emoji, function() - M.add_emoji_to_note(tree, unlinked) - end, { buffer = bufnr, desc = "Add an emoji reaction to the note/comment" }) - vim.keymap.set("n", state.settings.discussion_tree.delete_emoji, function() - M.delete_emoji_from_note(tree, unlinked) - end, { buffer = bufnr, desc = "Remove an emoji reaction from the note/comment" }) + end + + if keymaps.discussion_tree.reply then + vim.keymap.set("n", keymaps.discussion_tree.reply, function() + if M.is_current_node_note(tree) then + M.reply(tree) + end + end, { buffer = bufnr, desc = "Reply", nowait = keymaps.discussion_tree.reply_nowait }) + end + + if keymaps.discussion_tree.switch_view then + vim.keymap.set("n", keymaps.discussion_tree.switch_view, function() + winbar.switch_view_type() + end, { + buffer = bufnr, + desc = "Change view type between discussions and notes", + nowait = keymaps.discussion_tree.switch_view_nowait, + }) + end + + if keymaps.help then + vim.keymap.set("n", keymaps.help, function() + help.open() + end, { buffer = bufnr, desc = "Open help popup", nowait = keymaps.help_nowait }) + end + + if keymaps.discussion_tree.open_in_browser then + vim.keymap.set("n", keymaps.discussion_tree.open_in_browser, function() + common.open_in_browser(tree) + end, { + buffer = bufnr, + desc = "Open the note in your browser", + nowait = keymaps.discussion_tree.open_in_browser_nowait, + }) + end + + if keymaps.discussion_tree.copy_node_url then + vim.keymap.set("n", keymaps.discussion_tree.copy_node_url, function() + common.copy_node_url(tree) + end, { + buffer = bufnr, + desc = "Copy the URL of the current node to clipboard", + nowait = keymaps.discussion_tree.copy_node_url_nowait, + }) + end + + if keymaps.discussion_tree.print_node then + vim.keymap.set("n", keymaps.discussion_tree.print_node, function() + common.print_node(tree) + end, { + buffer = bufnr, + desc = "Print current node (for debugging)", + nowait = keymaps.discussion_tree.print_node_nowait, + }) + end + + if keymaps.discussion_tree.add_emoji then + vim.keymap.set("n", keymaps.discussion_tree.add_emoji, function() + M.add_emoji_to_note(tree, unlinked) + end, { + buffer = bufnr, + desc = "Add an emoji reaction to the note/comment", + nowait = keymaps.discussion_tree.add_emoji_nowait, + }) + end + + if keymaps.discussion_tree.delete_emoji then + vim.keymap.set("n", keymaps.discussion_tree.delete_emoji, function() + M.delete_emoji_from_note(tree, unlinked) + end, { + buffer = bufnr, + desc = "Remove an emoji reaction from the note/comment", + nowait = keymaps.discussion_tree.delete_emoji_nowait, + }) + end emoji.init_popup(tree, bufnr) end diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index a8954e9a..11526c89 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -368,7 +368,7 @@ end ---@field toggle_unresolved boolean Whether to toggle unresolved discussions. ---@field keep_current_open boolean Whether to keep the current discussion open even if it should otherwise be closed. ----This function (settings.discussion_tree.toggle_nodes) expands/collapses all nodes and their children according to the opts. +---This function expands/collapses all nodes and their children according to the opts. ---@param tree NuiTree ---@param winid integer ---@param unlinked boolean @@ -433,7 +433,7 @@ M.restore_cursor_position = function(winid, tree, original_node, root_node) end end ----This function (settings.discussion_tree.expand_recursively) expands a node and its children. +---This function expands a node and its children. ---@param tree NuiTree ---@param node NuiTree.Node ---@param is_resolved boolean If true, expand resolved discussions. If false, expand unresolved discussions. @@ -450,7 +450,7 @@ M.expand_recursively = function(tree, node, is_resolved) end end ----This function (settings.discussion_tree.collapse_recursively) collapses a node and its children. +---This function collapses a node and its children. ---@param tree NuiTree ---@param node NuiTree.Node ---@param current_root_node NuiTree.Node The root node of the current node. @@ -483,7 +483,7 @@ M.open_node_by_id = function(tree, id) end end --- This function (settings.discussion_tree.toggle_node) expands/collapses the current node and its children +-- This function (settings.keymaps.discussion_tree.toggle_node) expands/collapses the current node and its children M.toggle_node = function(tree) local node = tree:get_node() if node == nil then diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index ee0abd56..09fb9934 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -61,7 +61,7 @@ local function content() unlinked_draft_notes = #unlinked_draft_notes, resolvable_notes = resolvable_notes, resolved_notes = resolved_notes, - help_keymap = state.settings.help, + help_keymap = state.settings.keymaps.help, } return M.make_winbar(t) @@ -130,7 +130,7 @@ M.make_winbar = function(t) -- Join everything together and return it local separator = "%#Comment#|" local end_section = "%=" - local help = "%#Comment#Help: " .. t.help_keymap:gsub(" ", "") .. " " + local help = "%#Comment#Help: " .. (t.help_keymap and t.help_keymap:gsub(" ", "") .. " " or "unmapped") return string.format( " %s %s %s %s %s %s %s", discussion_title, diff --git a/lua/gitlab/actions/miscellaneous.lua b/lua/gitlab/actions/miscellaneous.lua index 5cf42738..df080665 100644 --- a/lua/gitlab/actions/miscellaneous.lua +++ b/lua/gitlab/actions/miscellaneous.lua @@ -78,14 +78,23 @@ end ---Setup keymaps for cycling popups. The keymap accepts count. ---@param popups table Table of Popups M.set_cycle_popups_keymaps = function(popups) + local keymaps = require("gitlab.state").settings.keymaps + if keymaps.disable_all or keymaps.popup.disable_all then + return + end + local number_of_popups = #popups for i, popup in ipairs(popups) do - popup:map("n", state.settings.popup.keymaps.next_field, function() - vim.api.nvim_set_current_win(popups[next_index(i, number_of_popups, vim.v.count)].winid) - end, { desc = "Go to next field (accepts count)" }) - popup:map("n", state.settings.popup.keymaps.prev_field, function() - vim.api.nvim_set_current_win(popups[prev_index(i, number_of_popups, vim.v.count)].winid) - end, { desc = "Go to previous field (accepts count)" }) + if keymaps.popup.next_field then + popup:map("n", keymaps.popup.next_field, function() + vim.api.nvim_set_current_win(popups[next_index(i, number_of_popups, vim.v.count)].winid) + end, { desc = "Go to next field (accepts count)", nowait = keymaps.popup.next_field_nowait }) + end + if keymaps.popup.prev_field then + popup:map("n", keymaps.popup.prev_field, function() + vim.api.nvim_set_current_win(popups[prev_index(i, number_of_popups, vim.v.count)].winid) + end, { desc = "Go to previous field (accepts count)", nowait = keymaps.popup.prev_field_nowait }) + end end end diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 8bba13ae..81ff454a 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -34,7 +34,8 @@ return { args = {} end server.build() -- Builds the Go binary if it doesn't exist - state.merge_settings(args) -- Sets keymaps and other settings from setup function + state.merge_settings(args) -- Merges user settings with default settings + state.set_global_keymaps() -- Sets keymaps that are not bound to a specific buffer require("gitlab.colors") -- Sets colors reviewer.init() discussions.initialize_discussions() -- place signs / diagnostics for discussions in reviewer diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index a3ba61f4..1b32e9a0 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -276,4 +276,144 @@ M.set_callback_for_reviewer_enter = function(callback) }) end +---Create the line-wise visual selection in the range of the motion and execute the gitlab.nvim API +---function. After that, restore the cursor position and the original operatorfunc. +---@param callback string Name of the gitlab.nvim API function to call +M.execute_callback = function(callback) + return function() + vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { "'[V']" } }, {}) + vim.api.nvim_cmd( + { cmd = "lua", args = { ("require'gitlab'.%s()"):format(callback) }, mods = { lockmarks = true } }, + {} + ) + vim.api.nvim_win_set_cursor(M.old_winnr, M.old_cursor_position) + vim.opt.operatorfunc = M.old_opfunc + end +end + +---Set the operatorfunc that will work on the lines defined by the motion that follows after the +---operator mapping, and enter the operator-pending mode. +---@param cb string Name of the gitlab.nvim API function to call, e.g., "create_multiline_comment". +local function execute_operatorfunc(cb) + M.old_opfunc = vim.opt.operatorfunc + M.old_winnr = vim.api.nvim_get_current_win() + M.old_cursor_position = vim.api.nvim_win_get_cursor(M.old_winnr) + vim.opt.operatorfunc = ("v:lua.require'gitlab.reviewer'.execute_callback'%s'"):format(cb) + vim.api.nvim_feedkeys("g@", "n", false) +end + +---Set keymaps for creating comments, suggestions and for jumping to discussion tree. +---@param bufnr integer Number of the buffer for which the keybindings will be created. +---@param keymaps table The settings keymaps table. +local set_keymaps = function(bufnr, keymaps) + -- Set mappings for creating comments + if keymaps.reviewer.create_comment ~= false then + vim.keymap.set( + "o", + keymaps.reviewer.create_comment, + "$", + { buffer = bufnr, desc = "Create comment for current line", nowait = keymaps.reviewer.create_comment_nowait } + ) + vim.keymap.set( + "n", + keymaps.reviewer.create_comment, + function() + execute_operatorfunc("create_multiline_comment") + end, + { buffer = bufnr, desc = "Create comment for range of motion", nowait = keymaps.reviewer.create_comment_nowait } + ) + vim.keymap.set("v", keymaps.reviewer.create_comment, function() + require("gitlab").create_multiline_comment() + end, { + buffer = bufnr, + desc = "Create comment for selected text", + nowait = keymaps.reviewer.create_comment_nowait, + }) + end + + -- Set mappings for creating suggestions + if keymaps.reviewer.create_suggestion ~= false then + vim.keymap.set("o", keymaps.reviewer.create_suggestion, "$", { + buffer = bufnr, + desc = "Create suggestion for current line", + nowait = keymaps.reviewer.create_suggestion_nowait, + }) + vim.keymap.set("n", keymaps.reviewer.create_suggestion, function() + execute_operatorfunc("create_comment_suggestion") + end, { + buffer = bufnr, + desc = "Create suggestion for range of motion", + nowait = keymaps.reviewer.create_suggestion_nowait, + }) + vim.keymap.set("v", keymaps.reviewer.create_suggestion, function() + require("gitlab").create_comment_suggestion() + end, { + buffer = bufnr, + desc = "Create suggestion for selected text", + nowait = keymaps.reviewer.create_suggestion_nowait, + }) + end + + -- Set mapping for moving to discussion tree + if keymaps.reviewer.move_to_discussion_tree ~= false then + vim.keymap.set("n", keymaps.reviewer.move_to_discussion_tree, function() + require("gitlab").move_to_discussion_tree_from_diagnostic() + end, { buffer = bufnr, desc = "Move to discussion", nowait = keymaps.reviewer.move_to_discussion_tree_nowait }) + end +end + +--- Sets up keymaps for both buffers in the reviewer. +M.set_reviewer_keymaps = function() + -- Require keymaps only after user settings have been merged with defaults + local keymaps = require("gitlab.state").settings.keymaps + if keymaps.disable_all or keymaps.reviewer.disable_all then + return + end + + local view = diffview_lib.get_current_view() + local a = view.cur_layout.a.file.bufnr + local b = view.cur_layout.b.file.bufnr + if a ~= nil and vim.api.nvim_buf_is_loaded(a) then + set_keymaps(a, keymaps) + end + if b ~= nil and vim.api.nvim_buf_is_loaded(b) then + set_keymaps(b, keymaps) + end +end + +---Delete keymaps from reviewer buffers. +---@param bufnr integer Number of the buffer from which the keybindings will be removed. +---@param keymaps table The settings keymaps table. +local del_keymaps = function(bufnr, keymaps) + for _, func in ipairs({ "create_comment", "create_suggestion" }) do + if keymaps.reviewer[func] ~= false then + for _, mode in ipairs({ "n", "o", "v" }) do + pcall(vim.api.nvim_buf_del_keymap, bufnr, mode, keymaps.reviewer[func]) + end + end + end + if keymaps.reviewer.move_to_discussion_tree ~= false then + pcall(vim.api.nvim_buf_del_keymap, bufnr, "n", keymaps.reviewer.move_to_discussion_tree) + end +end + +--- Deletes keymaps from both buffers in the reviewer. +M.del_reviewer_keymaps = function() + -- Require keymaps only after user settings have been merged with defaults + local keymaps = require("gitlab.state").settings.keymaps + if keymaps.disable_all or keymaps.reviewer.disable_all then + return + end + + local view = diffview_lib.get_current_view() + local a = view.cur_layout.a.file.bufnr + local b = view.cur_layout.b.file.bufnr + if a ~= nil and vim.api.nvim_buf_is_loaded(a) then + del_keymaps(a, keymaps) + end + if b ~= nil and vim.api.nvim_buf_is_loaded(b) then + del_keymaps(b, keymaps) + end +end + return M diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 964aaa04..c50d1918 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -60,14 +60,71 @@ M.settings = { insecure = true, }, attachment_dir = "", - help = "g?", - popup = { - keymaps = { + keymaps = { + disable_all = false, + help = "g?", + global = { + disable_all = false, + add_assignee = "glaa", + delete_assignee = "glad", + add_label = "glla", + delete_label = "glld", + add_reviewer = "glra", + delete_reviewer = "glrd", + approve = "glA", + revoke = "glR", + merge = "glM", + create_mr = "glC", + choose_merge_request = "glc", + start_review = "glS", + summary = "gls", + copy_mr_url = "glu", + open_in_browser = "glo", + create_note = "gln", + pipeline = "glp", + toggle_discussions = "gld", + toggle_draft_mode = "glD", + publish_all_drafts = "glP", + }, + popup = { + disable_all = false, next_field = "", prev_field = "", + perform_action = "ZZ", + perform_linewise_action = "ZA", + discard_changes = "ZQ", + }, + discussion_tree = { + disable_all = false, + add_emoji = "Ea", + delete_emoji = "Ed", + delete_comment = "dd", + edit_comment = "e", + reply = "r", + toggle_resolved = "-", + jump_to_file = "o", + jump_to_reviewer = "a", + open_in_browser = "b", + copy_node_url = "u", + switch_view = "c", + toggle_tree_type = "i", + publish_draft = "P", + toggle_draft_mode = "D", + toggle_node = "t", + toggle_all_discussions = "T", + toggle_resolved_discussions = "R", + toggle_unresolved_discussions = "U", + refresh_data = "", + print_node = "p", }, - perform_action = "s", - perform_linewise_action = "l", + reviewer = { + disable_all = false, + create_comment = "c", + create_suggestion = "s", + move_to_discussion_tree = "a", + }, + }, + popup = { width = "40%", height = "60%", border = "rounded", @@ -83,35 +140,16 @@ M.settings = { }, discussion_tree = { auto_open = true, - switch_view = "S", default_view = "discussions", blacklist = {}, - jump_to_file = "o", - jump_to_reviewer = "m", - edit_comment = "e", - delete_comment = "dd", - refresh_data = "a", - reply = "r", - toggle_node = "t", - add_emoji = "Ea", - delete_emoji = "Ed", - toggle_all_discussions = "T", - toggle_resolved_discussions = "R", - toggle_unresolved_discussions = "U", keep_current_open = false, - publish_draft = "P", - toggle_resolved = "p", position = "left", - open_in_browser = "b", - copy_node_url = "u", size = "20%", relative = "editor", resolved = "✓", unresolved = "-", tree_type = "simple", - toggle_tree_type = "i", draft_mode = false, - toggle_draft_mode = "D", }, create_mr = { target = nil, @@ -202,6 +240,135 @@ M.unlinked_discussion_tree = { unresolved_expanded = false, } +-- These keymaps are set globally when the plugin is initialized +M.set_global_keymaps = function() + local keymaps = M.settings.keymaps + + if keymaps.disable_all or keymaps.global.disable_all then + return + end + + if keymaps.global.start_review then + vim.keymap.set("n", keymaps.global.start_review, function() + require("gitlab").review() + end, { desc = "Start Gitlab review", nowait = keymaps.global.start_review_nowait }) + end + + if keymaps.global.choose_merge_request then + vim.keymap.set("n", keymaps.global.choose_merge_request, function() + require("gitlab").choose_merge_request() + end, { desc = "Choose MR for review", nowait = keymaps.global.choose_merge_request_nowait }) + end + + if keymaps.global.summary then + vim.keymap.set("n", keymaps.global.summary, function() + require("gitlab").summary() + end, { desc = "Show MR summary", nowait = keymaps.global.summary_nowait }) + end + + if keymaps.global.approve then + vim.keymap.set("n", keymaps.global.approve, function() + require("gitlab").approve() + end, { desc = "Approve MR", nowait = keymaps.global.approve_nowait }) + end + + if keymaps.global.revoke then + vim.keymap.set("n", keymaps.global.revoke, function() + require("gitlab").revoke() + end, { desc = "Revoke approval", nowait = keymaps.global.revoke_nowait }) + end + + if keymaps.global.create_mr then + vim.keymap.set("n", keymaps.global.create_mr, function() + require("gitlab").create_mr() + end, { desc = "Create MR", nowait = keymaps.global.create_mr_nowait }) + end + + if keymaps.global.create_note then + vim.keymap.set("n", keymaps.global.create_note, function() + require("gitlab").create_note() + end, { desc = "Create MR note", nowait = keymaps.global.create_note_nowait }) + end + + if keymaps.global.toggle_discussions then + vim.keymap.set("n", keymaps.global.toggle_discussions, function() + require("gitlab").toggle_discussions() + end, { desc = "Toggle MR discussions", nowait = keymaps.global.toggle_discussions_nowait }) + end + + if keymaps.global.add_assignee then + vim.keymap.set("n", keymaps.global.add_assignee, function() + require("gitlab").add_assignee() + end, { desc = "Add MR assignee", nowait = keymaps.global.add_assignee_nowait }) + end + + if keymaps.global.delete_assignee then + vim.keymap.set("n", keymaps.global.delete_assignee, function() + require("gitlab").delete_assignee() + end, { desc = "Delete MR assignee", nowait = keymaps.global.delete_assignee_nowait }) + end + + if keymaps.global.add_label then + vim.keymap.set("n", keymaps.global.add_label, function() + require("gitlab").add_label() + end, { desc = "Add MR label", nowait = keymaps.global.add_label_nowait }) + end + + if keymaps.global.delete_label then + vim.keymap.set("n", keymaps.global.delete_label, function() + require("gitlab").delete_label() + end, { desc = "Delete MR label", nowait = keymaps.global.delete_label_nowait }) + end + + if keymaps.global.add_reviewer then + vim.keymap.set("n", keymaps.global.add_reviewer, function() + require("gitlab").add_reviewer() + end, { desc = "Add MR reviewer", nowait = keymaps.global.add_reviewer_nowait }) + end + + if keymaps.global.delete_reviewer then + vim.keymap.set("n", keymaps.global.delete_reviewer, function() + require("gitlab").delete_reviewer() + end, { desc = "Delete MR reviewer", nowait = keymaps.global.delete_reviewer_nowait }) + end + + if keymaps.global.pipeline then + vim.keymap.set("n", keymaps.global.pipeline, function() + require("gitlab").pipeline() + end, { desc = "Show MR pipeline status", nowait = keymaps.global.pipeline_nowait }) + end + + if keymaps.global.open_in_browser then + vim.keymap.set("n", keymaps.global.open_in_browser, function() + require("gitlab").open_in_browser() + end, { desc = "Open MR in browser", nowait = keymaps.global.open_in_browser_nowait }) + end + + if keymaps.global.merge then + vim.keymap.set("n", keymaps.global.merge, function() + require("gitlab").merge() + end, { desc = "Merge MR", nowait = keymaps.global.merge_nowait }) + end + + if keymaps.global.copy_mr_url then + vim.keymap.set("n", keymaps.global.copy_mr_url, function() + require("gitlab").copy_mr_url() + end, { desc = "Copy MR url", nowait = keymaps.global.copy_mr_url_nowait }) + end + + if keymaps.global.publish_all_drafts then + vim.keymap.set("n", keymaps.global.publish_all_drafts, function() + require("gitlab").publish_all_drafts() + end, { desc = "Publish all MR comment drafts", nowait = keymaps.global.publish_all_drafts_nowait }) + end + + if keymaps.global.toggle_draft_mode then + vim.keymap.set("n", keymaps.global.toggle_draft_mode, function() + require("gitlab").toggle_draft_mode() + end, { desc = "Toggle MR comment draft mode", nowait = keymaps.global.toggle_draft_mode_nowait }) + end +end + -- Merges user settings into the default settings, overriding them M.merge_settings = function(args) M.settings = u.merge(M.settings, args) @@ -225,9 +392,43 @@ M.merge_settings = function(args) return false end - if M.settings.review_pane ~= nil then + local removed_fields_in_user_config = {} + local removed_settings_fields = { + "discussion_tree.add_emoji", + "discussion_tree.copy_node_url", + "discussion_tree.delete_comment", + "discussion_tree.delete_emoji", + "discussion_tree.edit_comment", + "discussion_tree.jump_to_file", + "discussion_tree.jump_to_reviewer", + "discussion_tree.open_in_browser", + "discussion_tree.publish_draft", + "discussion_tree.refresh_data", + "discussion_tree.reply", + "discussion_tree.switch_view", + "discussion_tree.toggle_all_discussions", + "discussion_tree.toggle_draft_mode", + "discussion_tree.toggle_node", + "discussion_tree.toggle_resolved", + "discussion_tree.toggle_resolved_discussions", + "discussion_tree.toggle_tree_type", + "discussion_tree.toggle_unresolved_discussions", + "help", + "popup.keymaps.next_field", + "popup.keymaps.prev_field", + "popup.perform_action", + "popup.perform_linewise_action", + "review_pane", -- Only relevant for the Delta reviewer + } + for _, field in ipairs(removed_settings_fields) do + if u.get_nested_field(M.settings, field) ~= nil then + table.insert(removed_fields_in_user_config, field) + end + end + + if #removed_fields_in_user_config ~= 0 then u.notify( - "The review_pane field is only relevant for Delta, which has been deprecated, please remove it from your setup function", + "The following settings fields have been removed:\n" .. table.concat(removed_fields_in_user_config, "\n"), vim.log.levels.WARN ) end @@ -284,17 +485,21 @@ end -- These keymaps are buffer specific and are set dynamically when popups mount M.set_popup_keymaps = function(popup, action, linewise_action, opts) + if M.settings.keymaps.disable_all or M.settings.keymaps.popup.disable_all then + return + end + if opts == nil then opts = {} end - if action ~= "Help" then -- Don't show help on the help popup - vim.keymap.set("n", M.settings.help, function() + if action ~= "Help" and M.settings.keymaps.help then -- Don't show help on the help popup + vim.keymap.set("n", M.settings.keymaps.help, function() local help = require("gitlab.actions.help") help.open() - end, { buffer = popup.bufnr, desc = "Open help" }) + end, { buffer = popup.bufnr, desc = "Open help", nowait = M.settings.keymaps.help_nowait }) end - if action ~= nil then - vim.keymap.set("n", M.settings.popup.perform_action, function() + if action ~= nil and M.settings.keymaps.popup.perform_action then + vim.keymap.set("n", M.settings.keymaps.popup.perform_action, function() local text = u.get_buffer_text(popup.bufnr) if opts.action_before_close then action(text, popup.bufnr) @@ -303,16 +508,33 @@ M.set_popup_keymaps = function(popup, action, linewise_action, opts) exit(popup, opts) action(text, popup.bufnr) end - end, { buffer = popup.bufnr, desc = "Perform action" }) + end, { buffer = popup.bufnr, desc = "Perform action", nowait = M.settings.keymaps.popup.perform_action_nowait }) end - if linewise_action ~= nil then - vim.keymap.set("n", M.settings.popup.perform_linewise_action, function() + if linewise_action ~= nil and M.settings.keymaps.popup.perform_action then + vim.keymap.set("n", M.settings.keymaps.popup.perform_linewise_action, function() local bufnr = vim.api.nvim_get_current_buf() local linnr = vim.api.nvim_win_get_cursor(0)[1] local text = u.get_line_content(bufnr, linnr) linewise_action(text) - end, { buffer = popup.bufnr, desc = "Perform linewise action" }) + end, { + buffer = popup.bufnr, + desc = "Perform linewise action", + nowait = M.settings.keymaps.popup.perform_linewise_action_nowait, + }) + end + + if M.settings.keymaps.popup.discard_changes then + vim.keymap.set("n", M.settings.keymaps.popup.discard_changes, function() + local temp_registers = M.settings.popup.temp_registers + M.settings.popup.temp_registers = {} + vim.cmd("quit!") + M.settings.popup.temp_registers = temp_registers + end, { + buffer = popup.bufnr, + desc = "Quit discarding changes", + nowait = M.settings.keymaps.popup.discard_changes_nowait, + }) end if opts.save_to_temp_register then diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 58f57b91..3018fd44 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -739,4 +739,17 @@ M.ensure_table = function(data) return data end +M.get_nested_field = function(table, field) + local subfield = string.match(field, "[^.]+") + local subtable = table[subfield] + if subtable ~= nil then + local new_field = string.gsub(field, "^" .. subfield .. ".?", "") + if new_field ~= "" then + return M.get_nested_field(subtable, new_field) + else + return subtable + end + end +end + return M From 076647ace5fa5944e81a9f026f3572bc770be77f Mon Sep 17 00:00:00 2001 From: c0r73x Date: Thu, 8 Aug 2024 15:37:49 +0000 Subject: [PATCH 74/97] Feat: Customize discussion tree chevrons (#339) feat: Adds the ability to customize the chevron icons in the discussion tree --- after/syntax/gitlab.vim | 31 ++++++++++++++----------- doc/gitlab.nvim.txt | 7 +++++- lua/gitlab/actions/discussions/tree.lua | 7 +++--- lua/gitlab/colors.lua | 12 ++++++++-- lua/gitlab/state.lua | 7 +++++- 5 files changed, 43 insertions(+), 21 deletions(-) diff --git a/after/syntax/gitlab.vim b/after/syntax/gitlab.vim index ef241470..d7ffb450 100644 --- a/after/syntax/gitlab.vim +++ b/after/syntax/gitlab.vim @@ -2,22 +2,25 @@ if filereadable($VIMRUNTIME . '/syntax/markdown.vim') source $VIMRUNTIME/syntax/markdown.vim endif -syntax match Username "\%([]\)\@<= @\S*" -syntax match Mention "\%([] \)\@ Date: Fri, 9 Aug 2024 22:40:55 +0200 Subject: [PATCH 75/97] Fix: Discussion tree highlighting (#342) fix: Fixes highlighting regressions from #339 --- after/syntax/gitlab.vim | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/after/syntax/gitlab.vim b/after/syntax/gitlab.vim index d7ffb450..3acf898a 100644 --- a/after/syntax/gitlab.vim +++ b/after/syntax/gitlab.vim @@ -11,16 +11,20 @@ highlight link Unresolved GitlabUnresolved execute 'syntax match Resolved /\s' . g:gitlab_discussion_tree_resolved . '\s\?/' highlight link Resolved GitlabResolved -execute 'syntax match GitlabDiscussionOpen /^' . g:gitlab_discussion_tree_expander_open . '/' +execute 'syntax match GitlabDiscussionOpen /^\s*' . g:gitlab_discussion_tree_expander_open . '/' highlight link GitlabDiscussionOpen GitlabExpander -execute 'syntax match GitlabDiscussionClosed /^' . g:gitlab_discussion_tree_expander_closed . '/' +execute 'syntax match GitlabDiscussionClosed /^\s*' . g:gitlab_discussion_tree_expander_closed . '/' highlight link GitlabDiscussionClosed GitlabExpander execute 'syntax match Draft /' . g:gitlab_discussion_tree_draft . '/' highlight link Draft GitlabDraft -execute 'syntax match Username "@\w\+"' +execute 'syntax match Username "@[a-zA-Z0-9.]\+"' highlight link Username GitlabUsername -let b:current_syntax = "gitlab" +execute 'syntax match Mention "\%(' . g:gitlab_discussion_tree_expander_open . '\|' + \ . g:gitlab_discussion_tree_expander_closed . '\)\@ Date: Sat, 17 Aug 2024 04:29:28 +0800 Subject: [PATCH 76/97] feat: add filtering when choosing merge requests (#346) feat: support the ability to filter merge requests by label and notlabel when choosing an MR --- cmd/merge_requests.go | 34 +++++++++++++++++++++------ cmd/merge_requests_test.go | 6 ++--- doc/gitlab.nvim.txt | 13 +++++++--- lua/gitlab/actions/merge_requests.lua | 8 ++++--- lua/gitlab/async.lua | 2 +- lua/gitlab/state.lua | 13 +++++++++- 6 files changed, 58 insertions(+), 18 deletions(-) diff --git a/cmd/merge_requests.go b/cmd/merge_requests.go index dff03de2..48cd2f22 100644 --- a/cmd/merge_requests.go +++ b/cmd/merge_requests.go @@ -4,27 +4,49 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "github.com/xanzy/go-gitlab" ) +type ListMergeRequestRequest struct { + Label []string `json:"label"` + NotLabel []string `json:"notlabel"` +} + type ListMergeRequestResponse struct { SuccessResponse MergeRequests []*gitlab.MergeRequest `json:"merge_requests"` } func (a *api) mergeRequestsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) + handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) + return + } - if r.Method != http.MethodGet { - w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) - handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed) + body, err := io.ReadAll(r.Body) + if err != nil { + handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + defer r.Body.Close() + var listMergeRequestRequest ListMergeRequestRequest + err = json.Unmarshal(body, &listMergeRequestRequest) + if err != nil { + handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) return } options := gitlab.ListProjectMergeRequestsOptions{ - Scope: gitlab.Ptr("all"), - State: gitlab.Ptr("opened"), + Scope: gitlab.Ptr("all"), + State: gitlab.Ptr("opened"), + Labels: (*gitlab.LabelOptions)(&listMergeRequestRequest.Label), + NotLabels: (*gitlab.LabelOptions)(&listMergeRequestRequest.NotLabel), } mergeRequests, _, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, &options) @@ -32,7 +54,6 @@ func (a *api) mergeRequestsHandler(w http.ResponseWriter, r *http.Request) { handleError(w, fmt.Errorf("Failed to list merge requests: %w", err), "Failed to list merge requests", http.StatusInternalServerError) return } - if len(mergeRequests) == 0 { handleError(w, errors.New("No merge requests found"), "No merge requests found", http.StatusNotFound) return @@ -51,5 +72,4 @@ func (a *api) mergeRequestsHandler(w http.ResponseWriter, r *http.Request) { if err != nil { handleError(w, err, "Could not encode response", http.StatusInternalServerError) } - } diff --git a/cmd/merge_requests_test.go b/cmd/merge_requests_test.go index 3a642b22..7d55d89d 100644 --- a/cmd/merge_requests_test.go +++ b/cmd/merge_requests_test.go @@ -22,21 +22,21 @@ func listProjectMergeRequestsErr(pid interface{}, opt *gitlab.ListProjectMergeRe func TestMergeRequestHandler(t *testing.T) { t.Run("Should fetch merge requests", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/merge_requests", nil) + request := makeRequest(t, http.MethodPost, "/merge_requests", ListMergeRequestRequest{}) server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequests200}) data := serveRequest(t, server, request, ListMergeRequestResponse{}) assert(t, data.Message, "Merge requests fetched successfully") assert(t, data.Status, http.StatusOK) }) t.Run("Should handle an error", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/merge_requests", nil) + request := makeRequest(t, http.MethodPost, "/merge_requests", ListMergeRequestRequest{}) server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequestsErr}) data := serveRequest(t, server, request, ErrorResponse{}) assert(t, data.Message, "Failed to list merge requests") assert(t, data.Status, http.StatusInternalServerError) }) t.Run("Should handle not having any merge requests with 404", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/merge_requests", nil) + request := makeRequest(t, http.MethodPost, "/merge_requests", ListMergeRequestRequest{}) server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequestsEmpty}) data := serveRequest(t, server, request, ErrorResponse{}) assert(t, data.Message, "No merge requests found") diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 4ba02ae9..0ed76f87 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -726,17 +726,24 @@ default arguments outlined under "Configuring the Plugin". gitlab.choose_merge_request({opts}) ~ Choose a merge request from a list of those open in your current project to -review. This command will automatically check out the feature branch locally, -and by default also open the reviewer pane (this can be overridden with the -`open_reviewer` parameter). +review. This command will automatically check out the feature branch locally +and open the reviewer pane (this can be overridden with the `open_reviewer` +parameter. +You can also filter merge requests by specifying `label` and `notlabel` +parameters. >lua require("gitlab").choose_merge_request() require("gitlab").choose_merge_request({ open_reviewer = false }) + require("gitlab").choose_merge_request({ label = {"include_mrs_with_label"} }) + require("gitlab").choose_merge_request({ notlabel = {"exclude_mrs_with_label"} }) < Parameters: ~ • {opts}: (table|nil) Keyword arguments to configure the checkout. • {open_reviewer}: (boolean) Whether to open the reviewer after switching branches. True by default. + • {label}: (table) Return merge requests with *including* matching labels + • {notlabel}: (table) Return merge requests *excluding* + matching label < *gitlab.nvim.review* gitlab.review() ~ diff --git a/lua/gitlab/actions/merge_requests.lua b/lua/gitlab/actions/merge_requests.lua index 81f4c2d7..85ea15fa 100644 --- a/lua/gitlab/actions/merge_requests.lua +++ b/lua/gitlab/actions/merge_requests.lua @@ -4,11 +4,13 @@ local git = require("gitlab.git") local u = require("gitlab.utils") local M = {} ----@class SwitchOpts ----@field open_reviewer boolean +---@class ChooseMergeRequestOptions +---@field open_reviewer? boolean +---@field label? string[] +---@field notlabel? string[] ---Opens up a select menu that lets you choose a different merge request. ----@param opts SwitchOpts|nil +---@param opts ChooseMergeRequestOptions|nil M.choose_merge_request = function(opts) local has_clean_tree, clean_tree_err = git.has_clean_tree() if clean_tree_err ~= nil then diff --git a/lua/gitlab/async.lua b/lua/gitlab/async.lua index ebb1f7a1..f3acbe26 100644 --- a/lua/gitlab/async.lua +++ b/lua/gitlab/async.lua @@ -36,7 +36,7 @@ function async:fetch(dependencies, i, argTable) end -- Call the API, set the data, and then call the next API - local body = dependency.body and dependency.body() or nil + local body = dependency.body and dependency.body(argTable) or nil job.run_job(dependency.endpoint, dependency.method or "GET", body, function(data) state[dependency.state] = dependency.key and data[dependency.key] or data self:fetch(dependencies, i + 1, argTable) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index ef2089b4..93aeb242 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -616,7 +616,18 @@ M.dependencies = { endpoint = "/merge_requests", key = "merge_requests", state = "MERGE_REQUESTS", - refresh = false, + refresh = true, + method = "POST", + body = function(opts) + local listArgs = { + label = opts and opts.label or {}, + notlabel = opts and opts.notlabel or {}, + } + for k, v in pairs(listArgs) do + listArgs[k] = v + end + return listArgs + end, }, discussion_data = { -- key is missing here... From 15d66f7784462b5ca65162b1b0690330ab909f7f Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Fri, 16 Aug 2024 21:17:44 -0400 Subject: [PATCH 77/97] feat: Add healthcheck (#345) feat: Adds a health check to the plugin feat: Adds types to the setup function, to provide type safety --- README.md | 2 +- doc/gitlab.nvim.txt | 7 +- .../actions/discussions/annotations.lua | 135 ------- lua/gitlab/annotations.lua | 340 ++++++++++++++++++ lua/gitlab/health.lua | 146 ++++++++ lua/gitlab/init.lua | 35 +- lua/gitlab/state.lua | 68 +--- 7 files changed, 518 insertions(+), 215 deletions(-) delete mode 100644 lua/gitlab/actions/discussions/annotations.lua create mode 100644 lua/gitlab/annotations.lua create mode 100644 lua/gitlab/health.lua diff --git a/README.md b/README.md index 320c979a..54f2d835 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ To view these help docs and to get more detailed help information, please run `: 1. Install Go 2. Add configuration (see Installation section) -5. Run `:lua require("gitlab").choose_merge_request()` +3. Run `:lua require("gitlab").choose_merge_request()` or `:lua require("gitlab").review()` if already in review branch/worktree. This will checkout the branch locally, and open the plugin's reviewer pane. diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 0ed76f87..a9ce172e 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -685,8 +685,11 @@ following to your config: TROUBLESHOOTING *gitlab.nvim.troubleshooting* -To check that the current settings of the plugin are configured correctly,~ -please run:~ +To check the health of the plugin and related dependencies, please run: +>vim + :checkhealth gitlab +< +To print your current settings: >lua require("gitlab").print_settings() < diff --git a/lua/gitlab/actions/discussions/annotations.lua b/lua/gitlab/actions/discussions/annotations.lua deleted file mode 100644 index 408cffe9..00000000 --- a/lua/gitlab/actions/discussions/annotations.lua +++ /dev/null @@ -1,135 +0,0 @@ ----@meta diagnostics - ----@class Author ----@field id integer ----@field username string ----@field email string ----@field name string ----@field state string ----@field avatar_url string ----@field web_url string - ----@class LinePosition ----@field line_code string ----@field type string - ----@class GitlabLineRange ----@field start LinePosition ----@field end LinePosition - ----@class NotePosition ----@field base_sha string ----@field start_sha string ----@field head_sha string ----@field position_type string ----@field new_path string? ----@field new_line integer? ----@field old_path string? ----@field old_line integer? ----@field line_range GitlabLineRange? - ----@class Note ----@field id integer ----@field type string ----@field body string ----@field attachment string ----@field title string ----@field file_name string ----@field author Author ----@field system boolean ----@field expires_at string? ----@field updated_at string? ----@field created_at string? ----@field noteable_id integer ----@field noteable_type string ----@field commit_id string ----@field position NotePosition ----@field resolvable boolean ----@field resolved boolean ----@field resolved_by Author ----@field resolved_at string? ----@field noteable_iid integer ----@field url string? - ----@class UnlinkedNote: Note ----@field position nil - ----@class Discussion ----@field id string ----@field individual_note boolean ----@field notes Note[] - ----@class UnlinkedDiscussion: Discussion ----@field notes UnlinkedNote[] - ----@class DiscussionData ----@field discussions Discussion[] ----@field unlinked_discussions UnlinkedDiscussion[] - ----@class EmojiMap: table ----@class Emoji ----@field unicode string ----@field unicodeAlternates string[] ----@field name string ----@field shortname string ----@field category string ----@field aliases string[] ----@field aliasesASCII string[] ----@field keywords string[] ----@field moji string - ----@class WinbarTable ----@field view_type string ----@field resolvable_discussions number ----@field resolved_discussions number ----@field inline_draft_notes number ----@field unlinked_draft_notes number ----@field resolvable_notes number ----@field resolved_notes number ----@field help_keymap string ---- ----@class SignTable ----@field name string ----@field group string ----@field priority number ----@field id number ----@field lnum number ----@field buffer number? ---- ----@class DiagnosticTable ----@field message string ----@field col number ----@field severity number ----@field user_data table ----@field source string ----@field code string? - ----@class LineRange ----@field start_line integer ----@field end_line integer - ----@class DiffviewInfo ----@field modification_type string ----@field file_name string ----@field current_bufnr integer ----@field new_sha_win_id integer ----@field old_sha_win_id integer ----@field opposite_bufnr integer ----@field new_line_from_buf integer ----@field old_line_from_buf integer - ----@class LocationData ----@field old_line integer | nil ----@field new_line integer | nil ----@field line_range ReviewerRangeInfo|nil - ----@class DraftNote ----@field note string ----@field id integer ----@field author_id integer ----@field merge_request_id integer ----@field resolve_discussion boolean ----@field discussion_id string -- This will always be "" ----@field commit_id string -- This will always be "" ----@field line_code string ----@field position NotePosition diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua new file mode 100644 index 00000000..5360cc36 --- /dev/null +++ b/lua/gitlab/annotations.lua @@ -0,0 +1,340 @@ +---@meta diagnostics + +---@alias BorderEnum "rounded" | "single" | "double" | "solid" +---@alias SeverityEnum "ERROR" | "WARN" | "INFO" | "HINT" + +---@class Author +---@field id integer +---@field username string +---@field email string +---@field name string +---@field state string +---@field avatar_url string +---@field web_url string + +---@class LinePosition +---@field line_code string +---@field type string + +---@class GitlabLineRange +---@field start LinePosition +---@field end LinePosition + +---@class NotePosition +---@field base_sha string +---@field start_sha string +---@field head_sha string +---@field position_type string +---@field new_path string? +---@field new_line integer? +---@field old_path string? +---@field old_line integer? +---@field line_range GitlabLineRange? + +---@class Note +---@field id integer +---@field type string +---@field body string +---@field attachment string +---@field title string +---@field file_name string +---@field author Author +---@field system boolean +---@field expires_at string? +---@field updated_at string? +---@field created_at string? +---@field noteable_id integer +---@field noteable_type string +---@field commit_id string +---@field position NotePosition +---@field resolvable boolean +---@field resolved boolean +---@field resolved_by Author +---@field resolved_at string? +---@field noteable_iid integer +---@field url string? + +---@class UnlinkedNote: Note +---@field position nil + +---@class Discussion +---@field id string +---@field individual_note boolean +---@field notes Note[] + +---@class UnlinkedDiscussion: Discussion +---@field notes UnlinkedNote[] + +---@class DiscussionData +---@field discussions Discussion[] +---@field unlinked_discussions UnlinkedDiscussion[] + +---@class EmojiMap: table +---@class Emoji +---@field unicode string +---@field unicodeAlternates string[] +---@field name string +---@field shortname string +---@field category string +---@field aliases string[] +---@field aliasesASCII string[] +---@field keywords string[] +---@field moji string + +---@class WinbarTable +---@field view_type string +---@field resolvable_discussions number +---@field resolved_discussions number +---@field inline_draft_notes number +---@field unlinked_draft_notes number +---@field resolvable_notes number +---@field resolved_notes number +---@field help_keymap string +--- +---@class SignTable +---@field name string +---@field group string +---@field priority number +---@field id number +---@field lnum number +---@field buffer number? +--- +---@class DiagnosticTable +---@field message string +---@field col number +---@field severity number +---@field user_data table +---@field source string +---@field code string? + +---@class LineRange +---@field start_line integer +---@field end_line integer + +---@class DiffviewInfo +---@field modification_type string +---@field file_name string +---@field current_bufnr integer +---@field new_sha_win_id integer +---@field old_sha_win_id integer +---@field opposite_bufnr integer +---@field new_line_from_buf integer +---@field old_line_from_buf integer + +---@class LocationData +---@field old_line integer | nil +---@field new_line integer | nil +---@field line_range ReviewerRangeInfo|nil + +---@class DraftNote +---@field note string +---@field id integer +---@field author_id integer +---@field merge_request_id integer +---@field resolve_discussion boolean +---@field discussion_id string -- This will always be "" +---@field commit_id string -- This will always be "" +---@field line_code string +---@field position NotePosition +--- +--- +--- Plugin Settings +--- +---@class Settings +---@field port? number -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically +---@field log_path? string -- Log path for the Go server +---@field string? any -- Custom path for `.gitlab.nvim` file, please read the "Connecting to Gitlab" section +---@field debug? DebugSettings -- Which values to log +---@field attachment_dir? string, -- The local directory for files (see the "summary" section) +---@field reviewer_settings? ReviewerSettings -- Settings for the reviewer view +---@field connection_settings? ConnectionSettings -- Settings for the connection to Gitlab +---@field keymaps? Keymaps -- Keymaps for the plugin +---@field popup? PopupSettings -- Settings for the popup windows +---@field discussion_tree? DiscussionSettings -- Settings for the popup windows +---@field choose_merge_request? ChooseMergeRequestSettings -- Default settings when choosing a merge request +---@field info? InfoSettings -- Settings for the "info" or "summary" view +---@field discussion_signs? DiscussionSigns -- The settings for discussion signs/diagnostics +---@field pipeline? PipelineSettings -- The settings for the pipeline popup +---@field create_mr? CreateMrSettings -- The settings when creating an MR +---@field colors? ColorSettings --- Colors settings for the plugin + +---@class DiscussionSigns: table +---@field enabled? boolean -- Show diagnostics for gitlab comments in the reviewer +---@field skip_resolved_discussion? boolean -- Show diagnostics for resolved discussions +---@field severity? SeverityEnum +---@field virtual_text? boolean -- Whether to show the comment text inline as floating virtual text +---@field use_diagnostic_signs? boolean -- Show diagnostic sign (depending on the `severity` setting) along with the comment icon +---@field priority? number -- Higher will override LSP warnings, etc +---@field icons? IconsOpts -- Customize the icons shown with comments or notes + +---@class ColorSettings: table +---@field discussion_tree? DiscussionTreeColors -- Colors for elements in the discussion tree + +---@class DiscussionTreeColors +--- @field username? string +--- @field mention? string +--- @field date? string +--- @field expander? string +--- @field directory? string +--- @field directory_icon? string +--- @field file_name? string +--- @field resolved? string +--- @field unresolved? string +--- @field draft? string + +---@class CreateMrSettings: table +---@field target? string -- Default branch to target when creating an MR +---@field template_file? string -- Default MR template in .gitlab/merge_request_templates +---@field delete_branch? boolean -- Whether the source branch will be marked for deletion +---@field squash? boolean -- Whether the commits will be marked for squashing +---@field title_input? TitleInputSettings +---@field fork? ForkSettings + +---@class ForkSettings: table +---@field enabled? boolean -- If making an MR from a fork +---@field forked_project_id? number -- The Gitlab ID of the project you are merging into. If nil, will be prompted. + +---@class TitleInputSettings: table +---@field width? number +---@field border? BorderEnum + +---@class PipelineSettings: table +---@field created? string -- What to show for this pipeline status, by default "", +---@field pending? string -- What to show for this pipeline status, by default "", +---@field preparing? string -- What to show for this pipeline status, by default "", +---@field scheduled? string -- What to show for this pipeline status, by default "", +---@field running? string -- What to show for this pipeline status, by default "", +---@field canceled? string -- What to show for this pipeline status, by default "↪", +---@field skipped? string -- What to show for this pipeline status, by default "↪", +---@field success? string -- What to show for this pipeline status, by default "✓", +---@field failed? string -- What to show for this pipeline status, by default "", + +---@class IconsOpts: table +---@field comment? string -- The icon for comments, by default "→|", +---@field range? string -- The icon for lines in ranged comments, by default " |" + +---@class ReviewerSettings: table +---@field diffview? SettingsDiffview -- Settings for diffview (the dependency) + +---@class SettingsDiffview: table +---@field imply_local? boolean -- If true, will attempt to use --imply_local option when calling |:DiffviewOpen| + +---@class ConnectionSettings: table +---@field insecure? boolean -- Like curl's --insecure option, ignore bad x509 certificates on connection + +---@class DebugSettings: table +---@field go_request? boolean -- Log the requests to Gitlab sent by the Go server +---@field go_response? boolean -- Log the responses received from Gitlab to the Go server + +---@class PopupSettings: table +---@field width? string -- The width of the popup, by default "40%" +---@field height? string The width of the popup, by default "60%" +---@field border? BorderEnum +---@field opacity? number -- From 0.0 (fully transparent) to 1.0 (fully opaque) +---@field comment? table -- Individual popup overrides, e.g. { width = "60%", height = "80%", border = "single", opacity = 0.85 }, +---@field edit? table -- Individual popup overrides, e.g. { width = "60%", height = "80%", border = "single", opacity = 0.85 } +---@field note? table -- Individual popup overrides, e.g. { width = "60%", height = "80%", border = "single", opacity = 0.85 } +---@field pipeline? table -- Individual popup overrides, e.g. { width = "60%", height = "80%", border = "single", opacity = 0.85 } +---@field reply? table -- Individual popup overrides, e.g. { width = "60%", height = "80%", border = "single", opacity = 0.85 } +---@field squash_message? string The default message when squashing a commit +---@field temp_registers? string[] -- List of registers for backing up popup content (see `:h gitlab.nvim.temp-registers`) + +---@class ChooseMergeRequestSettings +---@field open_reviewer? boolean -- Open the reviewer window automatically after switching merge requests + +---@class InfoSettings +---@field horizontal? boolean -- Display metadata to the left of the summary rather than underneath +---@field fields? ("author" | "created_at" | "updated_at" | "merge_status" | "draft" | "conflicts" | "assignees" | "reviewers" | "pipeline" | "branch" | "target_branch" | "delete_branch" | "squash" | "labels")[] + +---@class DiscussionSettings: table +---@field expanders? ExpanderOpts -- Customize the expander icons in the discussion tree +---@field auto_open? boolean -- Automatically open when the reviewer is opened +---@field default_view? string - Show "discussions" or "notes" by default +---@field blacklist? table -- List of usernames to remove from tree (bots, CI, etc) +---@field keep_current_open? boolean -- If true, current discussion stays open even if it should otherwise be closed when toggling +---@field position? "top" | "right" | "bottom" | "left" +---@field size? string -- Size of split, default to "20%" +---@field relative? "editor" | "window" -- Relative position of tree split +---@field resolved? string -- Symbol to show next to resolved discussions +---@field unresolved? '-', -- Symbol to show next to unresolved discussions +---@field tree_type? string -- Type of discussion tree - "simple" means just list of discussions, "by_file_name" means file tree with discussions under file +---@field draft_mode? boolean -- Whether comments are posted as drafts as part of a review +---@field winbar? function -- Custom function to return winbar title, should return a string. Provided with WinbarTable (defined in annotations.lua) + +---@class ExpanderOpts: table +---@field expanded? string -- Icon for expanded discussion thread +---@field collapsed? string -- Icon for collapsed discussion thread +---@field indentation? string -- Indentation Icon + +---@class Keymaps +---@field help? string -- Open a help popup for local keymaps when a relevant view is focused (popup, discussion panel, etc) +---@field global? KeymapsGlobal -- Global keybindings which will apply everywhere in Neovim +---@field popup? KeymapsPopup -- Keymaps for the popups (creating a comment, reading the summary, etc) +---@field discussion_tree? KeymapsDiscussionTree -- Keymaps for the discussion tree pane +---@field reviewer? KeymapsReviewer -- Keymaps for the reviewer view + +---@class KeymapTable: table> +---@field disable_all? boolean -- Disable all built-in keymaps + +---@class KeymapsPopup: KeymapTable +---@field next_field? string -- Cycle to the next field. Accepts |count|. +---@field prev_field? string -- Cycle to the previous field. Accepts |count|. +---@field perform_action? string -- Once in normal mode, does action (like saving comment or applying description edit, etc) +---@field perform_linewise_action? string -- Once in normal mode, does the linewise action (see logs for this job, etc) +---@field discard_changes? string -- Quit the popup discarding changes, the popup content isnot? saved to the `temp_registers` (see `:h gitlab.nvim.temp-registers`) +--- +---@class KeymapsDiscussionTree: KeymapTable +---@field add_emoji? string -- Add an emoji to the note/comment +---@field delete_emoji? string -- Remove an emoji from a note/comment +---@field delete_comment? string -- Delete comment +---@field edit_comment? string -- Edit comment +---@field reply? string -- Reply to comment +---@field toggle_resolved? string -- Toggle the resolved? status of the whole discussion +---@field jump_to_file? string -- Jump to comment location in file +---@field jump_to_reviewer? string -- Jump to the comment location in the reviewer window +---@field open_in_browser? string -- Jump to the URL of the current note/discussion +---@field copy_node_url? string -- Copy the URL of the current node to clipboard +---@field switch_view? string -- Toggle between the notes and discussions views +---@field toggle_tree_type? string or "by_file_name" +---@field publish_draft? string -- Publish the currently focused note/comment +---@field toggle_draft_mode? string -- Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately) +---@field toggle_node? string -- Open or close the discussion +---@field toggle_all_discussions? string -- Open or close? separately both resolved and unresolved discussions +---@field toggle_resolved_discussions? string -- Open or close all resolved discussions +---@field toggle_unresolved_discussions? string -- Open or close all unresolved discussions +---@field refresh_data? string -- Refresh the data in the view by hitting Gitlab's APIs again +---@field print_node? string -- Print the current node (for debugging) +--- +---@class KeymapsReviewer: KeymapTable +---@field create_comment? string -- Create a comment for the lines that the following {motion} moves over. Repeat the key(s) for creating comment for the current line +---@field create_suggestion? string -- Creates suggestion for the lines that the following {motion} moves over. Repeat the key(s) for creating comment for the current line +---@field move_to_discussion_tree? string -- Jump to the comment in the discussion tree +--- +---@class KeymapsGlobal: KeymapTable +---@field add_assignee? string -- Add an assignee to the merge request +---@field delete_assignee? string -- Delete an assignee from the merge request +---@field add_label? string -- Add a label from the merge request +---@field delete_label? string -- Remove a label from the merge request +---@field add_reviewer? string -- Add a reviewer to the merge request +---@field delete_reviewer? string -- Delete a reviewer from the merge request +---@field approve? string -- Approve MR +---@field revoke? string -- Revoke MR approval +---@field merge? string -- Merge the feature branch to the target branch and close MR +---@field create_mr? string -- Create a new MR for currently checked-out feature branch +---@field choose_merge_request? string -- Chose MR for review (if necessary check out the feature branch) +---@field start_review? string -- Start review for the currently checked-out branch +---@field summary? string -- Show the editable summary of the MR +---@field copy_mr_url? string -- Copy the URL of the MR to the system clipboard +---@field open_in_browser? string -- Openthe URL of the MR in the default Internet browser +---@field create_note? string -- Create a note (comment not linked toa specific line) +---@field pipeline? string -- Show the pipeline status +---@field toggle_discussions? string -- Toggle the discussions window +---@field toggle_draft_mode? string -- Toggle between draft mode (comments posted as drafts) and live mode (comments are posted immediately) +---@field publish_all_drafts? string -- Publish all draft comments/notes + +---@class Settings: KeymapTable +---@field next_field? string -- Cycle to the next field. Accepts |count|. +---@field prev_field? string -- Cycle to the previous field. Accepts |count|. +---@field perform_action? string -- Once in normal mode, does action (like saving comment or applying description edit, etc) +---@field perform_linewise_action? string -- Once in normal mode, does the linewise action (see logs for this job, etc) +---@field discard_changes? string -- Quit the popup discarding changes, the popup content is not? saved to the `temp_registers` (see `:h gitlab.nvim.temp-registers`) diff --git a/lua/gitlab/health.lua b/lua/gitlab/health.lua new file mode 100644 index 00000000..a81d7672 --- /dev/null +++ b/lua/gitlab/health.lua @@ -0,0 +1,146 @@ +local state = require("gitlab.state") +local List = require("gitlab.utils.list") +local u = require("gitlab.utils") +local M = {} + +local function check_go_version() + local go_version = io.popen("go version"):read("*a") + if go_version then + local major, minor, _ = go_version:match("(%d+)%.(%d+)%.(%d+)") + if major and tonumber(major) >= 1 and tonumber(minor) >= 19 then + return + else + return "Go is installed, but version is older than 1.19." + end + else + return "Go is not installed." + end +end + +---Checks the health of the plugin +---@param return_results boolean +M.check = function(return_results) + local warnings = List.new({}) + local errors = List.new({}) + + if not return_results then + vim.health.start("gitlab.nvim") + end + + if state.settings.reviewer == "delta" then + table.insert( + warnings, + "Delta is no longer a supported reviewer, please use diffview and update your setup function" + ) + end + + local required_deps = { + { + name = "MunifTanjim/nui.nvim", + package = "nui.popup", + }, + { + name = "nvim-lua/plenary.nvim", + package = "plenary", + }, + { + name = "sindrets/diffview.nvim", + package = "diffview", + }, + } + + local recommended_deps = { + { + name = "stevearc/dressing.nvim", + package = "dressing", + }, + { + name = "nvim-tree/nvim-web-devicons", + package = "nvim-web-devicons", + }, + } + + local go_version_problem = check_go_version() + if go_version_problem ~= nil then + table.insert(warnings, go_version_problem) + end + + for _, dep in ipairs(required_deps) do + local ok, _ = pcall(require, dep.package) + if not ok then + table.insert(errors, string.format("%s is a required dependency, but cannot be found", dep.name)) + end + end + + for _, dep in ipairs(recommended_deps) do + local ok, _ = pcall(require, dep.package) + if not ok then + table.insert(warnings, string.format("%s is a recommended dependency", dep.name)) + end + end + + local removed_fields_in_user_config = {} + local removed_settings_fields = { + "dialogue", + "discussion_tree.add_emoji", + "discussion_tree.copy_node_url", + "discussion_tree.delete_comment", + "discussion_tree.delete_emoji", + "discussion_tree.edit_comment", + "discussion_tree.jump_to_file", + "discussion_tree.jump_to_reviewer", + "discussion_tree.open_in_browser", + "discussion_tree.publish_draft", + "discussion_tree.refresh_data", + "discussion_tree.reply", + "discussion_tree.switch_view", + "discussion_tree.toggle_all_discussions", + "discussion_tree.toggle_draft_mode", + "discussion_tree.toggle_node", + "discussion_tree.toggle_resolved", + "discussion_tree.toggle_resolved_discussions", + "discussion_tree.toggle_tree_type", + "discussion_tree.toggle_unresolved_discussions", + "help", + "popup.keymaps.next_field", + "popup.keymaps.prev_field", + "popup.perform_action", + "popup.perform_linewise_action", + "review_pane", -- Only relevant for the Delta reviewer + } + + for _, field in ipairs(removed_settings_fields) do + if u.get_nested_field(state.settings, field) ~= nil then + vim.health.warn(warnings, field) + end + end + + if #removed_fields_in_user_config ~= 0 then + table.insert( + warnings, + "The following settings fields have been removed:\n" .. table.concat(removed_fields_in_user_config, "\n") + ) + end + + if #errors > 0 then + for _, err in ipairs(errors) do + vim.health.error(err) + end + end + + if #warnings > 0 then + for _, err in ipairs(warnings) do + vim.health.warn(err) + end + end + + if #warnings + #errors == 0 then + vim.health.ok("Gitlab plugin is okay!") + end + + if return_results then + return #warnings + #errors == 0 + end +end + +return M diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 81ff454a..278bb4b6 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -17,6 +17,7 @@ local create_mr = require("gitlab.actions.create_mr") local approvals = require("gitlab.actions.approvals") local draft_notes = require("gitlab.actions.draft_notes") local labels = require("gitlab.actions.labels") +local health = require("gitlab.health") local user = state.dependencies.user local info = state.dependencies.info @@ -28,20 +29,28 @@ local merge_requests_dep = state.dependencies.merge_requests local draft_notes_dep = state.dependencies.draft_notes local discussion_data = state.dependencies.discussion_data +---@param args Settings | {} | nil +---@return nil +local function setup(args) + if args == nil then + args = {} + end + server.build() -- Builds the Go binary if it doesn't exist + state.merge_settings(args) -- Merges user settings with default settings + state.set_global_keymaps() -- Sets keymaps that are not bound to a specific buffer + require("gitlab.colors") -- Sets colors + reviewer.init() + discussions.initialize_discussions() -- place signs / diagnostics for discussions in reviewer + emoji.init() -- Read in emojis for lookup purposes + + local is_healthy = health.check(true) + if not is_healthy then + u.notify("Plugin unhealthy, please run ':checkhealth gitlab' for details", vim.log.levels.WARN) + end +end + return { - setup = function(args) - if args == nil then - args = {} - end - server.build() -- Builds the Go binary if it doesn't exist - state.merge_settings(args) -- Merges user settings with default settings - state.set_global_keymaps() -- Sets keymaps that are not bound to a specific buffer - require("gitlab.colors") -- Sets colors - reviewer.init() - discussions.initialize_discussions() -- place signs / diagnostics for discussions in reviewer - emoji.init() -- Read in emojis for lookup purposes - end, - -- Global Actions 🌎 + setup = setup, summary = async.sequence({ u.merge(info, { refresh = true }), labels_dep, diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 93aeb242..c6a84f9e 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -40,7 +40,7 @@ M.default_auth_provider = function() return auth_token, gitlab_url, err end --- These are the default settings for the plugin +--- These are the default settings for the plugin M.settings = { auth_provider = M.default_auth_provider, port = nil, -- choose random port @@ -375,72 +375,12 @@ M.set_global_keymaps = function() end -- Merges user settings into the default settings, overriding them +---@param args Settings +---@return Settings M.merge_settings = function(args) M.settings = u.merge(M.settings, args) - - -- Check deprecated settings and alert users! - if M.settings.dialogue ~= nil then - u.notify("The dialogue field has been deprecated, please remove it from your setup function", vim.log.levels.WARN) - end - - if M.settings.reviewer == "delta" then - u.notify( - "Delta is no longer a supported reviewer, please use diffview and update your setup function", - vim.log.levels.ERROR - ) - return false - end - - local diffview_ok, _ = pcall(require, "diffview") - if not diffview_ok then - u.notify("Please install diffview, it is required") - return false - end - - local removed_fields_in_user_config = {} - local removed_settings_fields = { - "discussion_tree.add_emoji", - "discussion_tree.copy_node_url", - "discussion_tree.delete_comment", - "discussion_tree.delete_emoji", - "discussion_tree.edit_comment", - "discussion_tree.jump_to_file", - "discussion_tree.jump_to_reviewer", - "discussion_tree.open_in_browser", - "discussion_tree.publish_draft", - "discussion_tree.refresh_data", - "discussion_tree.reply", - "discussion_tree.switch_view", - "discussion_tree.toggle_all_discussions", - "discussion_tree.toggle_draft_mode", - "discussion_tree.toggle_node", - "discussion_tree.toggle_resolved", - "discussion_tree.toggle_resolved_discussions", - "discussion_tree.toggle_tree_type", - "discussion_tree.toggle_unresolved_discussions", - "help", - "popup.keymaps.next_field", - "popup.keymaps.prev_field", - "popup.perform_action", - "popup.perform_linewise_action", - "review_pane", -- Only relevant for the Delta reviewer - } - for _, field in ipairs(removed_settings_fields) do - if u.get_nested_field(M.settings, field) ~= nil then - table.insert(removed_fields_in_user_config, field) - end - end - - if #removed_fields_in_user_config ~= 0 then - u.notify( - "The following settings fields have been removed:\n" .. table.concat(removed_fields_in_user_config, "\n"), - vim.log.levels.WARN - ) - end - M.settings.file_separator = (u.is_windows() and "\\" or "/") - - return true + return M.settings end M.print_settings = function() From 1e3af0c0223c1f266ba616e26fc9d585008752ff Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sat, 17 Aug 2024 23:39:43 -0400 Subject: [PATCH 78/97] refactor: Move to gomock (#349) refactor: Moves off of homegrown mocking system in favor of https://github.com/uber-go/mock --- .github/CONTRIBUTING.md | 7 + .github/workflows/lua.yaml | 2 +- cmd/approve.go | 2 +- cmd/approve_test.go | 41 ++- cmd/assignee.go | 2 +- cmd/assignee_test.go | 49 ++- cmd/attachment.go | 2 +- cmd/attachment_test.go | 63 ++-- cmd/client.go | 5 +- cmd/comment.go | 8 +- cmd/comment_test.go | 171 +++++---- cmd/create_mr.go | 2 +- cmd/create_mr_test.go | 108 +++--- cmd/draft_notes.go | 12 +- cmd/draft_notes_test.go | 208 +++++++---- cmd/emoji.go | 10 +- cmd/git.go | 6 +- cmd/info.go | 2 +- cmd/info_test.go | 42 ++- cmd/job.go | 2 +- cmd/job_test.go | 41 ++- cmd/label.go | 6 +- cmd/list_discussions.go | 4 +- cmd/list_discussions_test.go | 118 +++--- cmd/members.go | 2 +- cmd/members_test.go | 38 +- cmd/merge.go | 2 +- cmd/merge_requests.go | 11 +- cmd/merge_requests_test.go | 66 +++- cmd/merge_test.go | 53 ++- cmd/mocks/fake_client.go | 686 +++++++++++++++++++++++++++++++++++ cmd/mocks/helpers.go | 60 +++ cmd/pipeline.go | 18 +- cmd/pipeline_test.go | 123 +++---- cmd/reply.go | 2 +- cmd/reply_test.go | 67 +++- cmd/resolve_discussion.go | 2 +- cmd/reviewer.go | 2 +- cmd/revisions.go | 2 +- cmd/revoke.go | 2 +- cmd/server.go | 28 +- cmd/shutdown.go | 2 +- cmd/summary.go | 2 +- cmd/test.go | 175 +-------- cmd/user.go | 2 +- example.lua | 36 -- go.mod | 4 + go.sum | 8 + 48 files changed, 1534 insertions(+), 772 deletions(-) create mode 100644 cmd/mocks/fake_client.go create mode 100644 cmd/mocks/helpers.go delete mode 100644 example.lua diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 0a7679fd..934a859d 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -38,6 +38,13 @@ $ go fmt ./... $ golangci-lint run ``` +If you are writing tests and have added something to the Go client, you can re-generate the mocked client like so: + +```bash +$ go install go.uber.org/mock/mockgen@latest # Install the mockgen CLI on your machine +$ mockgen -source cmd/types.go > cmd/mocks/fake_client.go +``` + For changes to the Lua codebase: We use stylua for formatting and luacheck for linting. Run these commands in the root of the repository: ```bash diff --git a/.github/workflows/lua.yaml b/.github/workflows/lua.yaml index 71598d94..75042618 100644 --- a/.github/workflows/lua.yaml +++ b/.github/workflows/lua.yaml @@ -46,7 +46,7 @@ jobs: - name: Install luajit uses: leafo/gh-actions-lua@v10 with: - luaVersion: "luajit-2.1.0-beta3" + luaVersion: "luajit-openresty" - name: Install luarocks uses: leafo/gh-actions-luarocks@v4 - name: Run tests diff --git a/cmd/approve.go b/cmd/approve.go index 4fcbdd58..9dc8b477 100644 --- a/cmd/approve.go +++ b/cmd/approve.go @@ -6,7 +6,7 @@ import ( ) /* approveHandler approves a merge request. */ -func (a *api) approveHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) approveHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) diff --git a/cmd/approve_test.go b/cmd/approve_test.go index d614e870..5ab77182 100644 --- a/cmd/approve_test.go +++ b/cmd/approve_test.go @@ -1,52 +1,59 @@ package main import ( - "errors" "net/http" "testing" "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" ) -func approveMergeRequest(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) { - return &gitlab.MergeRequestApprovals{}, makeResponse(http.StatusOK), nil -} - -func approveMergeRequestNon200(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) { - return &gitlab.MergeRequestApprovals{}, makeResponse(http.StatusSeeOther), nil -} - -func approveMergeRequestErr(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) { - return &gitlab.MergeRequestApprovals{}, nil, errors.New("Some error from Gitlab") -} - func TestApproveHandler(t *testing.T) { t.Run("Approves merge request", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ApproveMergeRequest("", mock_main.MergeId, nil, nil).Return(&gitlab.MergeRequestApprovals{}, makeResponse(http.StatusOK), nil) + request := makeRequest(t, http.MethodPost, "/mr/approve", nil) - server, _ := createRouterAndApi(fakeClient{approveMergeRequest: approveMergeRequest}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, SuccessResponse{}) + assert(t, data.Message, "Approved MR") assert(t, data.Status, http.StatusOK) }) t.Run("Disallows non-POST method", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ApproveMergeRequest("", mock_main.MergeId, nil, nil).Return(&gitlab.MergeRequestApprovals{}, makeResponse(http.StatusOK), nil) + request := makeRequest(t, http.MethodPut, "/mr/approve", nil) - server, _ := createRouterAndApi(fakeClient{approveMergeRequest: approveMergeRequest}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) checkBadMethod(t, *data, http.MethodPost) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ApproveMergeRequest("", mock_main.MergeId, nil, nil).Return(nil, nil, errorFromGitlab) + request := makeRequest(t, http.MethodPost, "/mr/approve", nil) - server, _ := createRouterAndApi(fakeClient{approveMergeRequest: approveMergeRequestErr}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not approve merge request") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ApproveMergeRequest("", mock_main.MergeId, nil, nil).Return(nil, makeResponse(http.StatusSeeOther), nil) + request := makeRequest(t, http.MethodPost, "/mr/approve", nil) - server, _ := createRouterAndApi(fakeClient{approveMergeRequest: approveMergeRequestNon200}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not approve merge request", "/mr/approve") }) } diff --git a/cmd/assignee.go b/cmd/assignee.go index d74bb9b2..0d4f6c6d 100644 --- a/cmd/assignee.go +++ b/cmd/assignee.go @@ -23,7 +23,7 @@ type AssigneesRequestResponse struct { } /* assigneesHandler adds or removes assignees from a merge request. */ -func (a *api) assigneesHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) assigneesHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPut { w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) diff --git a/cmd/assignee_test.go b/cmd/assignee_test.go index 61def41b..9ffb9ee2 100644 --- a/cmd/assignee_test.go +++ b/cmd/assignee_test.go @@ -1,56 +1,67 @@ package main import ( - "errors" "net/http" "testing" "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" + "go.uber.org/mock/gomock" ) -func updateAssignees(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return &gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil -} - -func updateAssigneesNon200(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return nil, makeResponse(http.StatusSeeOther), nil -} - -func updateAssigneesErr(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return nil, nil, errors.New("Some error from Gitlab") -} +var updatePayload = AssigneeUpdateRequest{Ids: []int{1, 2}} func TestAssigneeHandler(t *testing.T) { t.Run("Updates assignees", func(t *testing.T) { - request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) - server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssignees}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().UpdateMergeRequest("", mock_main.MergeId, gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, AssigneeUpdateResponse{}) + assert(t, data.SuccessResponse.Message, "Assignees updated") assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Disallows non-PUT method", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().UpdateMergeRequest("", mock_main.MergeId, gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) + request := makeRequest(t, http.MethodPost, "/mr/assignee", nil) - server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssignees}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Status, http.StatusMethodNotAllowed) assert(t, data.Details, "Invalid request type") assert(t, data.Message, "Expected PUT") }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) - server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssigneesErr}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().UpdateMergeRequest("", mock_main.MergeId, gomock.Any()).Return(nil, nil, errorFromGitlab) + + request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Status, http.StatusInternalServerError) assert(t, data.Message, "Could not modify merge request assignees") assert(t, data.Details, "Some error from Gitlab") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPut, "/mr/assignee", AssigneeUpdateRequest{Ids: []int{1, 2}}) - server, _ := createRouterAndApi(fakeClient{updateMergeRequest: updateAssigneesNon200}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().UpdateMergeRequest("", mock_main.MergeId, gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) + + request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Status, http.StatusSeeOther) assert(t, data.Message, "Could not modify merge request assignees") assert(t, data.Details, "An error occurred on the /mr/assignee endpoint") diff --git a/cmd/attachment.go b/cmd/attachment.go index 3b8dbab5..fcbd64d8 100644 --- a/cmd/attachment.go +++ b/cmd/attachment.go @@ -46,7 +46,7 @@ func (ar attachmentReader) ReadFile(path string) (io.Reader, error) { } /* attachmentHandler uploads an attachment (file, image, etc) to Gitlab and returns metadata about the upload. */ -func (a *api) attachmentHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) attachmentHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) diff --git a/cmd/attachment_test.go b/cmd/attachment_test.go index 8f002a82..d1390da1 100644 --- a/cmd/attachment_test.go +++ b/cmd/attachment_test.go @@ -2,64 +2,67 @@ package main import ( "bytes" - "errors" - "io" "net/http" "testing" "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" ) -type MockAttachmentReader struct{} - -func (mf MockAttachmentReader) ReadFile(path string) (io.Reader, error) { - return bytes.NewReader([]byte{}), nil -} - -func uploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { - return &gitlab.ProjectFile{}, makeResponse(http.StatusOK), nil -} - -func uploadFileNon200(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { - return &gitlab.ProjectFile{}, makeResponse(http.StatusSeeOther), nil -} - -func uploadFileErr(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { - return nil, nil, errors.New("Some error from Gitlab") -} - -func withMockFileReader(a *api) error { - reader := MockAttachmentReader{} +func withMockFileReader(a *Api) error { + reader := mock_main.MockAttachmentReader{} a.fileReader = reader return nil } +var reader = bytes.NewReader([]byte{}) +var attachmentTestRequestData = AttachmentRequest{ + FileName: "some_file_name", + FilePath: "some_file_path", +} + func TestAttachmentHandler(t *testing.T) { t.Run("Returns 200-status response after upload", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"}) - router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFile}, withMockFileReader) + client := mock_main.NewMockClient(t) + client.EXPECT().UploadFile("", reader, attachmentTestRequestData.FileName).Return(&gitlab.ProjectFile{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) + router, _ := CreateRouterAndApi(client, withMockFileReader) data := serveRequest(t, router, request, AttachmentResponse{}) + assert(t, data.SuccessResponse.Status, http.StatusOK) assert(t, data.SuccessResponse.Message, "File uploaded successfully") }) t.Run("Disallows non-POST method", func(t *testing.T) { - request := makeRequest(t, http.MethodPut, "/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"}) - router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFile}, withMockFileReader) + client := mock_main.NewMockClient(t) + client.EXPECT().UploadFile("", reader, attachmentTestRequestData.FileName).Return(&gitlab.ProjectFile{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPut, "/attachment", attachmentTestRequestData) + router, _ := CreateRouterAndApi(client, withMockFileReader) data := serveRequest(t, router, request, ErrorResponse{}) + checkBadMethod(t, *data, http.MethodPost) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"}) - router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFileErr}, withMockFileReader) + client := mock_main.NewMockClient(t) + client.EXPECT().UploadFile("", reader, attachmentTestRequestData.FileName).Return(nil, nil, errorFromGitlab) + + request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) + router, _ := CreateRouterAndApi(client, withMockFileReader) + data := serveRequest(t, router, request, ErrorResponse{}) checkErrorFromGitlab(t, *data, "Could not upload some_file_name to Gitlab") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/attachment", AttachmentRequest{FilePath: "some_file_path", FileName: "some_file_name"}) - router, _ := createRouterAndApi(fakeClient{uploadFile: uploadFileNon200}, withMockFileReader) + client := mock_main.NewMockClient(t) + client.EXPECT().UploadFile("", reader, attachmentTestRequestData.FileName).Return(nil, makeResponse(http.StatusSeeOther), nil) + + request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) + router, _ := CreateRouterAndApi(client, withMockFileReader) + data := serveRequest(t, router, request, ErrorResponse{}) checkNon200(t, *data, "Could not upload some_file_name to Gitlab", "/attachment") }) diff --git a/cmd/client.go b/cmd/client.go index 59de32ca..9cd43914 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -130,12 +130,9 @@ func initProjectSettings(c *Client, gitInfo GitProjectInfo) (error, *ProjectInfo if err != nil { return fmt.Errorf(fmt.Sprintf("Error getting project at %s", gitInfo.RemoteUrl), err), nil } - if project == nil { - return fmt.Errorf(fmt.Sprintf("Could not find project at %s", gitInfo.RemoteUrl), err), nil - } if project == nil { - return fmt.Errorf("No projects you are a member of contained remote URL %s", gitInfo.RemoteUrl), nil + return fmt.Errorf(fmt.Sprintf("Could not find project at %s", gitInfo.RemoteUrl), err), nil } projectId := fmt.Sprint(project.ID) diff --git a/cmd/comment.go b/cmd/comment.go index bd13e25d..7cbd36d0 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -42,7 +42,7 @@ func (comment CommentWithPosition) GetPositionData() PositionData { } /* commentHandler creates, edits, and deletes discussions (comments, multi-line comments) */ -func (a *api) commentHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) commentHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodPost: @@ -58,7 +58,7 @@ func (a *api) commentHandler(w http.ResponseWriter, r *http.Request) { } /* deleteComment deletes a note, multiline comment, or comment, which are all considered discussion notes. */ -func (a *api) deleteComment(w http.ResponseWriter, r *http.Request) { +func (a *Api) deleteComment(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { handleError(w, err, "Could not read request body", http.StatusBadRequest) @@ -99,7 +99,7 @@ func (a *api) deleteComment(w http.ResponseWriter, r *http.Request) { } /* postComment creates a note, multiline comment, or comment. */ -func (a *api) postComment(w http.ResponseWriter, r *http.Request) { +func (a *Api) postComment(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { handleError(w, err, "Could not read request body", http.StatusBadRequest) @@ -156,7 +156,7 @@ func (a *api) postComment(w http.ResponseWriter, r *http.Request) { } /* editComment changes the text of a comment or changes it's resolved status. */ -func (a *api) editComment(w http.ResponseWriter, r *http.Request) { +func (a *Api) editComment(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { diff --git a/cmd/comment_test.go b/cmd/comment_test.go index 7b7939ad..97d486ba 100644 --- a/cmd/comment_test.go +++ b/cmd/comment_test.go @@ -6,139 +6,176 @@ import ( "testing" "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" + "go.uber.org/mock/gomock" ) -func createMergeRequestDiscussion(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { - return &gitlab.Discussion{Notes: []*gitlab.Note{{}}}, makeResponse(http.StatusOK), nil +var testCommentCreationData = PostCommentRequest{ + Comment: "Some comment", } -func createMergeRequestDiscussionNon200(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { - return nil, makeResponse(http.StatusSeeOther), nil +var testCommentDeletionData = DeleteCommentRequest{ + NoteId: 3, + DiscussionId: "abc123", } -func createMergeRequestDiscussionErr(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { - return nil, nil, errors.New("Some error from Gitlab") +var testEditCommentData = EditCommentRequest{ + Comment: "Some comment", + NoteId: 3, + DiscussionId: "abc123", } func TestPostComment(t *testing.T) { t.Run("Creates a new note (unlinked comment)", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{}) - server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().CreateMergeRequestDiscussion( + "", + mock_main.MergeId, + gomock.Any(), + ).Return(&gitlab.Discussion{Notes: []*gitlab.Note{{}}}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, CommentResponse{}) + assert(t, data.SuccessResponse.Message, "Comment created successfully") assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Creates a new comment", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{ + // Re-create comment creation data to avoid mutating this variable in other tests + testCommentCreationData := PostCommentRequest{ + Comment: "Some comment", PositionData: PositionData{ - FileName: "some_file.txt", + FileName: "file.txt", }, - }) - server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) - data := serveRequest(t, server, request, CommentResponse{}) - assert(t, data.SuccessResponse.Message, "Comment created successfully") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) + } - t.Run("Creates a new multiline comment", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{ - PositionData: PositionData{ - FileName: "some_file.txt", - LineRange: &LineRange{ - StartRange: &LinePosition{}, /* These would have real data */ - EndRange: &LinePosition{}, - }, - }, - }) - server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussion}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().CreateMergeRequestDiscussion( + "", + mock_main.MergeId, + gomock.Any(), + ).Return(&gitlab.Discussion{Notes: []*gitlab.Note{{}}}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) + + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, CommentResponse{}) assert(t, data.SuccessResponse.Message, "Comment created successfully") assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{}) - server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussionErr}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().CreateMergeRequestDiscussion( + "", + mock_main.MergeId, + gomock.Any(), + ).Return(nil, nil, errors.New("Some error from Gitlab")) + + request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not create discussion") }) t.Run("Handles non-200s from Gitlab", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/comment", PostCommentRequest{}) - server, _ := createRouterAndApi(fakeClient{createMergeRequestDiscussion: createMergeRequestDiscussionNon200}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().CreateMergeRequestDiscussion( + "", + mock_main.MergeId, + gomock.Any(), + ).Return(nil, makeResponse(http.StatusSeeOther), nil) + + request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not create discussion", "/mr/comment") }) } -func deleteMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return makeResponse(http.StatusOK), nil -} - -func deleteMergeRequestDiscussionNoteErr(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return nil, errors.New("Some error from Gitlab") -} - -func deleteMergeRequestDiscussionNoteNon200(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return makeResponse(http.StatusSeeOther), nil -} - func TestDeleteComment(t *testing.T) { t.Run("Deletes a comment", func(t *testing.T) { - request := makeRequest(t, http.MethodDelete, "/mr/comment", DeleteCommentRequest{}) - server, _ := createRouterAndApi(fakeClient{deleteMergeRequestDiscussionNote: deleteMergeRequestDiscussionNote}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().DeleteMergeRequestDiscussionNote("", mock_main.MergeId, testCommentDeletionData.DiscussionId, testCommentDeletionData.NoteId).Return(makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, CommentResponse{}) + assert(t, data.SuccessResponse.Message, "Comment deleted successfully") assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodDelete, "/mr/comment", DeleteCommentRequest{}) - server, _ := createRouterAndApi(fakeClient{deleteMergeRequestDiscussionNote: deleteMergeRequestDiscussionNoteErr}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().DeleteMergeRequestDiscussionNote("", mock_main.MergeId, testCommentDeletionData.DiscussionId, testCommentDeletionData.NoteId).Return(nil, errorFromGitlab) + + request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not delete comment") }) t.Run("Handles non-200s from Gitlab", func(t *testing.T) { - request := makeRequest(t, http.MethodDelete, "/mr/comment", DeleteCommentRequest{}) - server, _ := createRouterAndApi(fakeClient{deleteMergeRequestDiscussionNote: deleteMergeRequestDiscussionNoteNon200}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().DeleteMergeRequestDiscussionNote("", mock_main.MergeId, testCommentDeletionData.DiscussionId, testCommentDeletionData.NoteId).Return(makeResponse(http.StatusSeeOther), nil) + + request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not delete comment", "/mr/comment") }) } -func updateMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { - return &gitlab.Note{}, makeResponse(http.StatusOK), nil -} - -func updateMergeRequestDiscussionNoteErr(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { - return nil, nil, errors.New("Some error from Gitlab") -} - -func updateMergeRequestDiscussionNoteNon200(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { - return nil, makeResponse(http.StatusSeeOther), nil -} - func TestEditComment(t *testing.T) { t.Run("Edits a comment", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/mr/comment", EditCommentRequest{}) - server, _ := createRouterAndApi(fakeClient{updateMergeRequestDiscussionNote: updateMergeRequestDiscussionNote}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + opts := gitlab.UpdateMergeRequestDiscussionNoteOptions{ + Body: gitlab.Ptr(testEditCommentData.Comment), + } + client.EXPECT().UpdateMergeRequestDiscussionNote("", mock_main.MergeId, testEditCommentData.DiscussionId, testEditCommentData.NoteId, &opts).Return(&gitlab.Note{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, CommentResponse{}) + assert(t, data.SuccessResponse.Message, "Comment updated successfully") assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/mr/comment", EditCommentRequest{}) - server, _ := createRouterAndApi(fakeClient{updateMergeRequestDiscussionNote: updateMergeRequestDiscussionNoteErr}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().UpdateMergeRequestDiscussionNote("", mock_main.MergeId, testEditCommentData.DiscussionId, testEditCommentData.NoteId, gomock.Any()).Return(nil, nil, errorFromGitlab) + + request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not update comment") }) t.Run("Handles non-200s from Gitlab", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/mr/comment", EditCommentRequest{}) - server, _ := createRouterAndApi(fakeClient{updateMergeRequestDiscussionNote: updateMergeRequestDiscussionNoteNon200}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().UpdateMergeRequestDiscussionNote("", mock_main.MergeId, testEditCommentData.DiscussionId, testEditCommentData.NoteId, gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) + + request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not update comment", "/mr/comment") }) diff --git a/cmd/create_mr.go b/cmd/create_mr.go index 05e88bd2..47dcda3b 100644 --- a/cmd/create_mr.go +++ b/cmd/create_mr.go @@ -20,7 +20,7 @@ type CreateMrRequest struct { } /* createMr creates a merge request */ -func (a *api) createMr(w http.ResponseWriter, r *http.Request) { +func (a *Api) createMr(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) if r.Method != http.MethodPost { diff --git a/cmd/create_mr_test.go b/cmd/create_mr_test.go index c097269b..49dd9078 100644 --- a/cmd/create_mr_test.go +++ b/cmd/create_mr_test.go @@ -1,105 +1,97 @@ package main import ( - "errors" "net/http" "testing" "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" + "go.uber.org/mock/gomock" ) -func createMrFn(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return &gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil -} - -func createMrFnErr(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return nil, nil, errors.New("Some error from Gitlab") -} - -func createMrFnNon200(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return nil, makeResponse(http.StatusSeeOther), nil +var testCreateMrRequestData = CreateMrRequest{ + Title: "Some title", + Description: "Some description", + TargetBranch: "main", + DeleteBranch: false, + Squash: false, } func TestCreateMr(t *testing.T) { t.Run("Creates an MR", func(t *testing.T) { + client := mock_main.NewMockClient(t) + client.EXPECT().CreateMergeRequest("", gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) - body := CreateMrRequest{ - Title: "Some title", - Description: "Some description", - TargetBranch: "main", - DeleteBranch: false, - Squash: false, - } + request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) + server, _ := CreateRouterAndApi(client) - request := makeRequest(t, http.MethodPost, "/create_mr", body) - server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFn}) data := serveRequest(t, server, request, SuccessResponse{}) assert(t, data.Message, "MR 'Some title' created") assert(t, data.Status, http.StatusOK) }) t.Run("Disallows non-POST methods", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/create_mr", CreateMrRequest{}) - server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFn}) + client := mock_main.NewMockClient(t) + client.EXPECT().CreateMergeRequest("", gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) + + request := makeRequest(t, http.MethodPatch, "/create_mr", testCreateMrRequestData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkBadMethod(t, *data, http.MethodPost) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { - body := CreateMrRequest{ - Title: "Some title", - Description: "Some description", - TargetBranch: "main", - DeleteBranch: false, - Squash: false, - } - request := makeRequest(t, http.MethodPost, "/create_mr", body) - server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFnErr}) + client := mock_main.NewMockClient(t) + client.EXPECT().CreateMergeRequest("", gomock.Any()).Return(nil, nil, errorFromGitlab) + + request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not create MR") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - body := CreateMrRequest{ - Title: "Some title", - Description: "Some description", - TargetBranch: "main", - DeleteBranch: false, - Squash: false, - } - request := makeRequest(t, http.MethodPost, "/create_mr", body) - server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFnNon200}) + client := mock_main.NewMockClient(t) + client.EXPECT().CreateMergeRequest("", gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) + + request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not create MR", "/create_mr") }) t.Run("Handles missing titles", func(t *testing.T) { - body := CreateMrRequest{ - Title: "", - Description: "Some description", - TargetBranch: "main", - DeleteBranch: false, - Squash: false, - } - request := makeRequest(t, http.MethodPost, "/create_mr", body) - server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFn}) + client := mock_main.NewMockClient(t) + + missingTitleRequest := testCreateMrRequestData + missingTitleRequest.Title = "" + + client.EXPECT().CreateMergeRequest("", gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPost, "/create_mr", missingTitleRequest) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Status, http.StatusBadRequest) assert(t, data.Message, "Could not create MR") assert(t, data.Details, "Title cannot be empty") }) t.Run("Handles missing target branch", func(t *testing.T) { - body := CreateMrRequest{ - Title: "Some title", - Description: "Some description", - TargetBranch: "", - DeleteBranch: false, - Squash: false, - } - request := makeRequest(t, http.MethodPost, "/create_mr", body) - server, _ := createRouterAndApi(fakeClient{createMrFn: createMrFn}) + client := mock_main.NewMockClient(t) + + missingTitleRequest := testCreateMrRequestData + missingTitleRequest.TargetBranch = "" + + client.EXPECT().CreateMergeRequest("", gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPost, "/create_mr", missingTitleRequest) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Status, http.StatusBadRequest) assert(t, data.Message, "Could not create MR") assert(t, data.Details, "Target branch cannot be empty") diff --git a/cmd/draft_notes.go b/cmd/draft_notes.go index 68c16954..b18262f5 100644 --- a/cmd/draft_notes.go +++ b/cmd/draft_notes.go @@ -52,7 +52,7 @@ func (draftNote DraftNoteWithPosition) GetPositionData() PositionData { } /* draftNoteHandler creates, edits, and deletes draft notes */ -func (a *api) draftNoteHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) draftNoteHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodGet: @@ -69,7 +69,7 @@ func (a *api) draftNoteHandler(w http.ResponseWriter, r *http.Request) { } } -func (a *api) draftNotePublisher(w http.ResponseWriter, r *http.Request) { +func (a *Api) draftNotePublisher(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) @@ -126,7 +126,7 @@ func (a *api) draftNotePublisher(w http.ResponseWriter, r *http.Request) { } /* postDraftNote creates a draft note */ -func (a *api) postDraftNote(w http.ResponseWriter, r *http.Request) { +func (a *Api) postDraftNote(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { handleError(w, err, "Could not read request body", http.StatusBadRequest) @@ -184,7 +184,7 @@ func (a *api) postDraftNote(w http.ResponseWriter, r *http.Request) { } /* deleteDraftNote deletes a draft note */ -func (a *api) deleteDraftNote(w http.ResponseWriter, r *http.Request) { +func (a *Api) deleteDraftNote(w http.ResponseWriter, r *http.Request) { suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/") id, err := strconv.Atoi(suffix) if err != nil { @@ -217,7 +217,7 @@ func (a *api) deleteDraftNote(w http.ResponseWriter, r *http.Request) { } /* updateDraftNote edits the text of a draft comment */ -func (a *api) updateDraftNote(w http.ResponseWriter, r *http.Request) { +func (a *Api) updateDraftNote(w http.ResponseWriter, r *http.Request) { suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/") id, err := strconv.Atoi(suffix) if err != nil { @@ -278,7 +278,7 @@ func (a *api) updateDraftNote(w http.ResponseWriter, r *http.Request) { } /* listDraftNotes lists all draft notes for the currently authenticated user */ -func (a *api) listDraftNotes(w http.ResponseWriter, r *http.Request) { +func (a *Api) listDraftNotes(w http.ResponseWriter, _ *http.Request) { opt := gitlab.ListDraftNotesOptions{} draftNotes, res, err := a.client.ListDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt) diff --git a/cmd/draft_notes_test.go b/cmd/draft_notes_test.go index d4506d47..a8dd4f54 100644 --- a/cmd/draft_notes_test.go +++ b/cmd/draft_notes_test.go @@ -1,25 +1,36 @@ package main import ( - "errors" + "fmt" "net/http" "testing" "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" + "go.uber.org/mock/gomock" ) -func listDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) { - return []*gitlab.DraftNote{}, makeResponse(http.StatusOK), nil +var testPostDraftNoteRequestData = PostDraftNoteRequest{ + Comment: "Some comment", } -func listDraftNotesErr(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) { - return nil, makeResponse(http.StatusInternalServerError), errors.New("Some error") +var testUpdateDraftNoteRequest = UpdateDraftNoteRequest{ + Note: "Some new note", +} + +var testDraftNotePublishRequest = DraftNotePublishRequest{ + Note: 3, + PublishAll: false, } func TestListDraftNotes(t *testing.T) { t.Run("Lists all draft notes", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ListDraftNotes("", mock_main.MergeId, gomock.Any()).Return([]*gitlab.DraftNote{}, makeResponse(http.StatusOK), nil) + request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) - server, _ := createRouterAndApi(fakeClient{listDraftNotes: listDraftNotes}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ListDraftNotesResponse{}) assert(t, data.SuccessResponse.Message, "Draft notes fetched successfully") @@ -27,29 +38,28 @@ func TestListDraftNotes(t *testing.T) { }) t.Run("Handles error", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ListDraftNotes("", mock_main.MergeId, gomock.Any()).Return(nil, nil, errorFromGitlab) + request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) - server, _ := createRouterAndApi(fakeClient{listDraftNotes: listDraftNotesErr}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) assert(t, data.Message, "Could not get draft notes") assert(t, data.Status, http.StatusInternalServerError) - assert(t, data.Details, "Some error") + assert(t, data.Details, errorFromGitlab.Error()) }) } -func createDraftNote(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { - return &gitlab.DraftNote{}, makeResponse(http.StatusOK), nil -} - -func createDraftNoteErr(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { - return nil, makeResponse(http.StatusInternalServerError), errors.New("Some error") -} - func TestPostDraftNote(t *testing.T) { t.Run("Posts new draft note", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", PostDraftNoteRequest{}) - server, _ := createRouterAndApi(fakeClient{createDraftNote: createDraftNote}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().CreateDraftNote("", mock_main.MergeId, gomock.Any()).Return(&gitlab.DraftNote{}, makeResponse(http.StatusOK), nil) + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, DraftNoteResponse{}) assert(t, data.SuccessResponse.Message, "Draft note created successfully") @@ -57,139 +67,179 @@ func TestPostDraftNote(t *testing.T) { }) t.Run("Handles errors on draft note creation", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", PostDraftNoteRequest{}) - server, _ := createRouterAndApi(fakeClient{createDraftNote: createDraftNoteErr}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().CreateDraftNote("", mock_main.MergeId, gomock.Any()).Return(nil, nil, errorFromGitlab) + + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not create draft note") assert(t, data.Status, http.StatusInternalServerError) - assert(t, data.Details, "Some error") + assert(t, data.Details, errorFromGitlab.Error()) }) } -func deleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return makeResponse(http.StatusOK), nil -} - -func deleteDraftNoteErr(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return makeResponse(http.StatusInternalServerError), errors.New("Something went wrong") -} - func TestDeleteDraftNote(t *testing.T) { t.Run("Deletes draft note", func(t *testing.T) { - request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil) - server, _ := createRouterAndApi(fakeClient{deleteDraftNote: deleteDraftNote}) - data := serveRequest(t, server, request, SuccessResponse{}) - assert(t, data.Message, "Draft note deleted") - assert(t, data.Status, http.StatusOK) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + urlId := 10 + client.EXPECT().DeleteDraftNote("", mock_main.MergeId, urlId).Return(makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodDelete, fmt.Sprintf("/mr/draft_notes/%d", urlId), nil) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, DraftNoteResponse{}) + + assert(t, data.SuccessResponse.Message, "Draft note deleted") + assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Handles error", func(t *testing.T) { - request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil) - server, _ := createRouterAndApi(fakeClient{deleteDraftNote: deleteDraftNoteErr}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + urlId := 10 + client.EXPECT().DeleteDraftNote("", mock_main.MergeId, urlId).Return(nil, errorFromGitlab) + + request := makeRequest(t, http.MethodDelete, fmt.Sprintf("/mr/draft_notes/%d", urlId), nil) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not delete draft note") assert(t, data.Status, http.StatusInternalServerError) + assert(t, data.Details, errorFromGitlab.Error()) }) t.Run("Handles bad ID", func(t *testing.T) { - request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/abc", nil) - server, _ := createRouterAndApi(fakeClient{deleteDraftNote: deleteDraftNote}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + urlId := "abc" + + request := makeRequest(t, http.MethodDelete, fmt.Sprintf("/mr/draft_notes/%s", urlId), nil) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not parse draft note ID") assert(t, data.Status, http.StatusBadRequest) }) } -func updateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { - return &gitlab.DraftNote{}, makeResponse(http.StatusOK), nil -} - func TestEditDraftNote(t *testing.T) { t.Run("Edits draft note", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", UpdateDraftNoteRequest{Note: "Some new note", Position: gitlab.PositionOptions{}}) - server, _ := createRouterAndApi(fakeClient{updateDraftNote: updateDraftNote}) - data := serveRequest(t, server, request, SuccessResponse{}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + urlId := 10 + client.EXPECT().UpdateDraftNote("", mock_main.MergeId, urlId, gomock.Any()).Return(&gitlab.DraftNote{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPatch, fmt.Sprintf("/mr/draft_notes/%d", urlId), testUpdateDraftNoteRequest) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, DraftNoteResponse{}) + assert(t, data.Message, "Draft note updated") assert(t, data.Status, http.StatusOK) }) t.Run("Handles bad ID", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/abc", nil) - server, _ := createRouterAndApi(fakeClient{updateDraftNote: updateDraftNote}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + urlId := "abc" + client.EXPECT().UpdateDraftNote("", mock_main.MergeId, urlId, gomock.Any()).Return(&gitlab.DraftNote{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPatch, fmt.Sprintf("/mr/draft_notes/%s", urlId), testUpdateDraftNoteRequest) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not parse draft note ID") assert(t, data.Status, http.StatusBadRequest) }) t.Run("Handles empty note", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", UpdateDraftNoteRequest{Note: ""}) - server, _ := createRouterAndApi(fakeClient{updateDraftNote: updateDraftNote}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + urlId := 10 + + testEmptyUpdateDraftNoteRequest := testUpdateDraftNoteRequest + testEmptyUpdateDraftNoteRequest.Note = "" + + client.EXPECT().UpdateDraftNote("", mock_main.MergeId, urlId, gomock.Any()).Return(&gitlab.DraftNote{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPatch, fmt.Sprintf("/mr/draft_notes/%d", urlId), testEmptyUpdateDraftNoteRequest) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Must provide draft note text") assert(t, data.Status, http.StatusBadRequest) }) } -func publishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return makeResponse(http.StatusOK), nil -} - -func publishDraftNoteErr(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return nil, errors.New("Some error") -} - func TestPublishDraftNote(t *testing.T) { t.Run("Should publish a draft note", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{Note: 3, PublishAll: false}) - server, _ := createRouterAndApi(fakeClient{ - publishDraftNote: publishDraftNote, - }) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().PublishDraftNote("", mock_main.MergeId, testDraftNotePublishRequest.Note).Return(makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, SuccessResponse{}) assert(t, data.Message, "Draft note(s) published") assert(t, data.Status, http.StatusOK) }) t.Run("Handles bad/missing ID", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: false}) - server, _ := createRouterAndApi(fakeClient{publishDraftNote: publishDraftNote}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + + // Missing Note ID + testDraftNotePublishRequest := DraftNotePublishRequest{ + PublishAll: false, + } + + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, ErrorResponse{}) assert(t, data.Message, "Must provide Note ID") assert(t, data.Status, http.StatusBadRequest) }) t.Run("Handles error", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: false, Note: 3}) - server, _ := createRouterAndApi(fakeClient{publishDraftNote: publishDraftNoteErr}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().PublishDraftNote("", mock_main.MergeId, testDraftNotePublishRequest.Note).Return(nil, errorFromGitlab) + + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Could not publish draft note(s)") assert(t, data.Status, http.StatusInternalServerError) }) } -func publishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return makeResponse(http.StatusOK), nil -} - -func publishAllDraftNotesErr(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return nil, errors.New("Some error") -} - func TestPublishAllDraftNotes(t *testing.T) { t.Run("Should publish all draft notes", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().PublishAllDraftNotes("", mock_main.MergeId).Return(makeResponse(http.StatusOK), nil) + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: true}) - server, _ := createRouterAndApi(fakeClient{ - publishAllDraftNotes: publishAllDraftNotes, - }) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, SuccessResponse{}) assert(t, data.Message, "Draft note(s) published") assert(t, data.Status, http.StatusOK) }) t.Run("Should handle an error", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().PublishAllDraftNotes("", mock_main.MergeId).Return(nil, errorFromGitlab) + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: true}) - server, _ := createRouterAndApi(fakeClient{ - publishAllDraftNotes: publishAllDraftNotesErr, - }) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, ErrorResponse{}) assert(t, data.Message, "Could not publish draft note(s)") assert(t, data.Status, http.StatusInternalServerError) diff --git a/cmd/emoji.go b/cmd/emoji.go index b9942994..45cd5b55 100644 --- a/cmd/emoji.go +++ b/cmd/emoji.go @@ -43,7 +43,7 @@ type CreateEmojiResponse struct { attachEmojisToApi reads the emojis from our external JSON file and attaches them to the API so that they can be looked up later */ -func attachEmojisToApi(a *api) error { +func attachEmojisToApi(a *Api) error { e, err := os.Executable() if err != nil { @@ -78,7 +78,7 @@ func attachEmojisToApi(a *api) error { Fetches emojis for a set of notes and comments in parallel and returns a map of note IDs to their emojis. Gitlab's API does not allow for fetching notes for an entire discussion thread so we have to do it per-note. */ -func (a *api) fetchEmojisForNotesAndComments(noteIDs []int) (map[int][]*gitlab.AwardEmoji, error) { +func (a *Api) fetchEmojisForNotesAndComments(noteIDs []int) (map[int][]*gitlab.AwardEmoji, error) { var wg sync.WaitGroup emojis := make(map[int][]*gitlab.AwardEmoji) @@ -131,7 +131,7 @@ func (a *api) fetchEmojisForNotesAndComments(noteIDs []int) (map[int][]*gitlab.A return emojis, nil } -func (a *api) emojiNoteHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) emojiNoteHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodPost: @@ -145,7 +145,7 @@ func (a *api) emojiNoteHandler(w http.ResponseWriter, r *http.Request) { } /* deleteEmojiFromNote deletes an emoji from a note based on the emoji (awardable) ID and the note's ID */ -func (a *api) deleteEmojiFromNote(w http.ResponseWriter, r *http.Request) { +func (a *Api) deleteEmojiFromNote(w http.ResponseWriter, r *http.Request) { suffix := strings.TrimPrefix(r.URL.Path, "/mr/awardable/note/") ids := strings.Split(suffix, "/") @@ -187,7 +187,7 @@ func (a *api) deleteEmojiFromNote(w http.ResponseWriter, r *http.Request) { } /* postEmojiOnNote adds an emojis to a note based on the note's ID */ -func (a *api) postEmojiOnNote(w http.ResponseWriter, r *http.Request) { +func (a *Api) postEmojiOnNote(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { handleError(w, err, "Could not read request body", http.StatusBadRequest) diff --git a/cmd/git.go b/cmd/git.go index 2fc89f6b..53231ab6 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -12,7 +12,7 @@ type GitProjectInfo struct { Namespace string ProjectName string BranchName string - GetLatestCommitOnRemote func(a *api) (string, error) + GetLatestCommitOnRemote func(a *Api) (string, error) } /* @@ -111,9 +111,9 @@ func RefreshProjectInfo() error { } /* -The GetLatestCommitOnRemote function is attached during the createRouterAndApi call, since it needs to be called every time to get the latest commit. +The GetLatestCommitOnRemote function is attached during the CreateRouterAndApi call, since it needs to be called every time to get the latest commit. */ -func GetLatestCommitOnRemote(a *api) (string, error) { +func GetLatestCommitOnRemote(a *Api) (string, error) { cmd := exec.Command("git", "log", "-1", "--format=%H", fmt.Sprintf("origin/%s", a.gitInfo.BranchName)) out, err := cmd.Output() diff --git a/cmd/info.go b/cmd/info.go index b99eb4c7..91b520c7 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -13,7 +13,7 @@ type InfoResponse struct { } /* infoHandler fetches infomation about the current git project. The data returned here is used in many other API calls */ -func (a *api) infoHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) infoHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodGet { w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) diff --git a/cmd/info_test.go b/cmd/info_test.go index d6de9b2a..030ea60b 100644 --- a/cmd/info_test.go +++ b/cmd/info_test.go @@ -1,52 +1,58 @@ package main import ( - "errors" "net/http" "testing" "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" ) -func getInfo(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return &gitlab.MergeRequest{Title: "Some Title"}, makeResponse(http.StatusOK), nil -} - -func getInfoNon200(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return nil, makeResponse(http.StatusSeeOther), nil -} - -func getInfoErr(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return nil, nil, errors.New("Some error from Gitlab") -} - func TestInfoHandler(t *testing.T) { t.Run("Returns normal information", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().GetMergeRequest("", mock_main.MergeId, &gitlab.GetMergeRequestsOptions{}).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) + + server, _ := CreateRouterAndApi(client) request := makeRequest(t, http.MethodGet, "/mr/info", nil) - server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfo}) data := serveRequest(t, server, request, InfoResponse{}) - assert(t, data.Info.Title, "Some Title") + assert(t, data.SuccessResponse.Message, "Merge requests retrieved") assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Disallows non-GET method", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + + server, _ := CreateRouterAndApi(client) request := makeRequest(t, http.MethodPost, "/mr/info", nil) - server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfo}) + data := serveRequest(t, server, request, ErrorResponse{}) checkBadMethod(t, *data, http.MethodGet) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().GetMergeRequest("", mock_main.MergeId, &gitlab.GetMergeRequestsOptions{}).Return(nil, nil, errorFromGitlab) + + server, _ := CreateRouterAndApi(client) request := makeRequest(t, http.MethodGet, "/mr/info", nil) - server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfoErr}) + data := serveRequest(t, server, request, ErrorResponse{}) checkErrorFromGitlab(t, *data, "Could not get project info") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().GetMergeRequest("", mock_main.MergeId, &gitlab.GetMergeRequestsOptions{}).Return(nil, makeResponse(http.StatusSeeOther), nil) + + server, _ := CreateRouterAndApi(client) request := makeRequest(t, http.MethodGet, "/mr/info", nil) - server, _ := createRouterAndApi(fakeClient{getMergeRequest: getInfoNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not get project info", "/mr/info") }) diff --git a/cmd/job.go b/cmd/job.go index 334141f9..993be73e 100644 --- a/cmd/job.go +++ b/cmd/job.go @@ -16,7 +16,7 @@ type JobTraceResponse struct { } /* jobHandler returns a string that shows the output of a specific job run in a Gitlab pipeline */ -func (a *api) jobHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) jobHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodGet { w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) diff --git a/cmd/job_test.go b/cmd/job_test.go index da4a0e89..1914867b 100644 --- a/cmd/job_test.go +++ b/cmd/job_test.go @@ -2,53 +2,58 @@ package main import ( "bytes" - "errors" "net/http" "testing" - "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" ) -func getTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { - return bytes.NewReader([]byte("Some data")), makeResponse(http.StatusOK), nil -} - -func getTraceFileErr(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { - return nil, nil, errors.New("Some error from Gitlab") -} - -func getTraceFileNon200(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { - return nil, makeResponse(http.StatusSeeOther), nil -} +var jobId = 0 func TestJobHandler(t *testing.T) { t.Run("Should read a job trace file", func(t *testing.T) { + client := mock_main.NewMockClient(t) + client.EXPECT().GetTraceFile("", jobId).Return(bytes.NewReader([]byte("Some data")), makeResponse(http.StatusOK), nil) + request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) - server, _ := createRouterAndApi(fakeClient{getTraceFile: getTraceFile}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, JobTraceResponse{}) + assert(t, data.SuccessResponse.Message, "Log file read") assert(t, data.SuccessResponse.Status, http.StatusOK) assert(t, data.File, "Some data") }) t.Run("Disallows non-GET methods", func(t *testing.T) { + client := mock_main.NewMockClient(t) + client.EXPECT().GetTraceFile("", jobId).Return(bytes.NewReader([]byte("Some data")), makeResponse(http.StatusOK), nil) + request := makeRequest(t, http.MethodPost, "/job", JobTraceRequest{}) - server, _ := createRouterAndApi(fakeClient{getTraceFile: getTraceFile}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkBadMethod(t, *data, http.MethodGet) }) t.Run("Should handle errors from Gitlab", func(t *testing.T) { + client := mock_main.NewMockClient(t) + client.EXPECT().GetTraceFile("", jobId).Return(nil, nil, errorFromGitlab) + request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) - server, _ := createRouterAndApi(fakeClient{getTraceFile: getTraceFileErr}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not get trace file for job") }) - t.Run("Should handle non-200s", func(t *testing.T) { + t.Run("Should handle non-2jobIdjobIds", func(t *testing.T) { + client := mock_main.NewMockClient(t) + client.EXPECT().GetTraceFile("", jobId).Return(nil, makeResponse(http.StatusSeeOther), nil) + request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) - server, _ := createRouterAndApi(fakeClient{getTraceFile: getTraceFileNon200}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not get trace file for job", "/job") }) } diff --git a/cmd/label.go b/cmd/label.go index ca7beb90..2157fc7e 100644 --- a/cmd/label.go +++ b/cmd/label.go @@ -29,7 +29,7 @@ type LabelsRequestResponse struct { } /* labelsHandler adds or removes labels from a merge request, and returns all labels for the current project */ -func (a *api) labelHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) labelHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: a.getLabels(w, r) @@ -42,7 +42,7 @@ func (a *api) labelHandler(w http.ResponseWriter, r *http.Request) { } } -func (a *api) getLabels(w http.ResponseWriter, r *http.Request) { +func (a *Api) getLabels(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") labels, res, err := a.client.ListLabels(a.projectInfo.ProjectId, &gitlab.ListLabelsOptions{}) @@ -82,7 +82,7 @@ func (a *api) getLabels(w http.ResponseWriter, r *http.Request) { } -func (a *api) updateLabels(w http.ResponseWriter, r *http.Request) { +func (a *Api) updateLabels(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") body, err := io.ReadAll(r.Body) if err != nil { diff --git a/cmd/list_discussions.go b/cmd/list_discussions.go index 2638f2a9..4cc464fa 100644 --- a/cmd/list_discussions.go +++ b/cmd/list_discussions.go @@ -41,7 +41,7 @@ func (n SortableDiscussions) Swap(i, j int) { listDiscussionsHandler lists all discusions for a given merge request, both those linked and unlinked to particular points in the code. The responses are sorted by date created, and blacklisted users are not included */ -func (a *api) listDiscussionsHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) listDiscussionsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) @@ -68,7 +68,7 @@ func (a *api) listDiscussionsHandler(w http.ResponseWriter, r *http.Request) { PerPage: 250, } - discussions, res, err := a.client.ListMergeRequestDiscussions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &mergeRequestDiscussionOptions, nil) + discussions, res, err := a.client.ListMergeRequestDiscussions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &mergeRequestDiscussionOptions) if err != nil { handleError(w, err, "Could not list discussions", http.StatusInternalServerError) diff --git a/cmd/list_discussions_test.go b/cmd/list_discussions_test.go index 6e512244..37f9b16b 100644 --- a/cmd/list_discussions_test.go +++ b/cmd/list_discussions_test.go @@ -1,65 +1,64 @@ package main import ( - "errors" "net/http" "testing" "time" "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" + "go.uber.org/mock/gomock" ) -func listMergeRequestDiscussions(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) { - now := time.Now() - newer := now.Add(time.Second * 100) - discussions := []*gitlab.Discussion{ - { - Notes: []*gitlab.Note{ - { - CreatedAt: &now, - Type: "DiffNote", - Author: Author{ - Username: "hcramer", - }, +var now = time.Now() +var newer = now.Add(time.Second * 100) + +type Author struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Name string `json:"name"` + State string `json:"state"` + AvatarURL string `json:"avatar_url"` + WebURL string `json:"web_url"` +} + +var testListDiscussionsResponse = []*gitlab.Discussion{ + { + Notes: []*gitlab.Note{ + { + CreatedAt: &now, + Type: "DiffNote", + Author: Author{ + Username: "hcramer", }, }, }, - { - Notes: []*gitlab.Note{ - { - CreatedAt: &newer, - Type: "DiffNote", - Author: Author{ - Username: "hcramer2", - }, + }, + { + Notes: []*gitlab.Note{ + { + CreatedAt: &newer, + Type: "DiffNote", + Author: Author{ + Username: "hcramer2", }, }, }, - } - return discussions, makeResponse(http.StatusOK), nil -} - -func listMergeRequestDiscussionsErr(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) { - return nil, nil, errors.New("Some error from Gitlab") -} - -func listMergeRequestDiscussionsNon200(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) { - return nil, makeResponse(http.StatusSeeOther), nil -} - -func listMergeRequestAwardEmojiOnNote(pid interface{}, mr int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) { - return []*gitlab.AwardEmoji{}, makeResponse(http.StatusOK), nil -} - -func listMergeRequestAwardEmojiOnNoteFailure(pid interface{}, mr int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) { - return nil, makeResponse(http.StatusBadRequest), errors.New("Some error from Gitlab") + }, } func TestListDiscussionsHandler(t *testing.T) { t.Run("Returns sorted discussions", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ListMergeRequestDiscussions("", mock_main.MergeId, gomock.Any()).Return(testListDiscussionsResponse, makeResponse(http.StatusOK), nil) + client.EXPECT().ListMergeRequestAwardEmojiOnNote("", mock_main.MergeId, gomock.Any(), gomock.Any()).Return([]*gitlab.AwardEmoji{}, makeResponse(http.StatusOK), nil).Times(2) + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) - server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussions, listMergeRequestAwardEmojiOnNote: listMergeRequestAwardEmojiOnNote}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, DiscussionsResponse{}) + assert(t, data.SuccessResponse.Message, "Discussions retrieved") assert(t, data.SuccessResponse.Status, http.StatusOK) assert(t, data.Discussions[0].Notes[0].Author.Username, "hcramer2") /* Sorting applied */ @@ -67,9 +66,15 @@ func TestListDiscussionsHandler(t *testing.T) { }) t.Run("Uses blacklist to filter unwanted authors", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ListMergeRequestDiscussions("", mock_main.MergeId, gomock.Any()).Return(testListDiscussionsResponse, makeResponse(http.StatusOK), nil) + client.EXPECT().ListMergeRequestAwardEmojiOnNote("", mock_main.MergeId, gomock.Any(), gomock.Any()).Return([]*gitlab.AwardEmoji{}, makeResponse(http.StatusOK), nil).Times(2) + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{"hcramer"}}) - server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussions, listMergeRequestAwardEmojiOnNote: listMergeRequestAwardEmojiOnNote}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, DiscussionsResponse{}) + assert(t, data.SuccessResponse.Message, "Discussions retrieved") assert(t, data.SuccessResponse.Status, http.StatusOK) assert(t, len(data.Discussions), 1) @@ -77,30 +82,51 @@ func TestListDiscussionsHandler(t *testing.T) { }) t.Run("Disallows non-POST method", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/mr/discussions/list", DiscussionsRequest{}) - server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussions, listMergeRequestAwardEmojiOnNote: listMergeRequestAwardEmojiOnNote}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + + request := makeRequest(t, http.MethodPut, "/mr/discussions/list", DiscussionsRequest{}) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, ErrorResponse{}) checkBadMethod(t, *data, http.MethodPost) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ListMergeRequestDiscussions("", mock_main.MergeId, gomock.Any()).Return(nil, nil, errorFromGitlab) + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) - server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussionsErr, listMergeRequestAwardEmojiOnNote: listMergeRequestAwardEmojiOnNote}) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, ErrorResponse{}) checkErrorFromGitlab(t, *data, "Could not list discussions") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ListMergeRequestDiscussions("", mock_main.MergeId, gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) - server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussionsNon200, listMergeRequestAwardEmojiOnNote: listMergeRequestAwardEmojiOnNote}) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not list discussions", "/mr/discussions/list") }) t.Run("Handles error from emoji service", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ListMergeRequestDiscussions("", mock_main.MergeId, gomock.Any()).Return(testListDiscussionsResponse, makeResponse(http.StatusOK), nil) + client.EXPECT().ListMergeRequestAwardEmojiOnNote("", mock_main.MergeId, gomock.Any(), gomock.Any()).Return(nil, nil, errorFromGitlab).Times(2) + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) - server, _ := createRouterAndApi(fakeClient{listMergeRequestDiscussions: listMergeRequestDiscussions, listMergeRequestAwardEmojiOnNote: listMergeRequestAwardEmojiOnNoteFailure}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not fetch emojis") + }) } diff --git a/cmd/members.go b/cmd/members.go index 53470ed1..eedce018 100644 --- a/cmd/members.go +++ b/cmd/members.go @@ -13,7 +13,7 @@ type ProjectMembersResponse struct { } /* projectMembersHandler returns all members of the current Gitlab project */ -func (a *api) projectMembersHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) projectMembersHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodGet { w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) diff --git a/cmd/members_test.go b/cmd/members_test.go index d33e6272..f62f500b 100644 --- a/cmd/members_test.go +++ b/cmd/members_test.go @@ -1,52 +1,56 @@ package main import ( - "errors" "net/http" "testing" "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" + "go.uber.org/mock/gomock" ) -func listAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) { - return []*gitlab.ProjectMember{}, makeResponse(http.StatusOK), nil -} - -func listAllProjectMembersErr(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) { - return nil, nil, errors.New("Some error from Gitlab") -} - -func listAllProjectMembersNon200(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) { - return nil, makeResponse(http.StatusSeeOther), nil -} - func TestMembersHandler(t *testing.T) { t.Run("Returns project members", func(t *testing.T) { + client := mock_main.NewMockClient(t) + client.EXPECT().ListAllProjectMembers("", gomock.Any()).Return([]*gitlab.ProjectMember{}, makeResponse(http.StatusOK), nil) + request := makeRequest(t, http.MethodGet, "/project/members", nil) - server, _ := createRouterAndApi(fakeClient{listAllProjectMembers: listAllProjectMembers}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ProjectMembersResponse{}) + assert(t, data.SuccessResponse.Message, "Project members retrieved") assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Disallows non-GET method", func(t *testing.T) { + client := mock_main.NewMockClient(t) + request := makeRequest(t, http.MethodPost, "/project/members", nil) - server, _ := createRouterAndApi(fakeClient{listAllProjectMembers: listAllProjectMembers}) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, ErrorResponse{}) checkBadMethod(t, *data, http.MethodGet) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { + client := mock_main.NewMockClient(t) + client.EXPECT().ListAllProjectMembers("", gomock.Any()).Return(nil, nil, errorFromGitlab) + request := makeRequest(t, http.MethodGet, "/project/members", nil) - server, _ := createRouterAndApi(fakeClient{listAllProjectMembers: listAllProjectMembersErr}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not retrieve project members") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + client := mock_main.NewMockClient(t) + client.EXPECT().ListAllProjectMembers("", gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) + request := makeRequest(t, http.MethodGet, "/project/members", nil) - server, _ := createRouterAndApi(fakeClient{listAllProjectMembers: listAllProjectMembersNon200}) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not retrieve project members", "/project/members") }) } diff --git a/cmd/merge.go b/cmd/merge.go index 2586698e..0e679601 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -15,7 +15,7 @@ type AcceptMergeRequestRequest struct { } /* acceptAndMergeHandler merges a given merge request into the target branch */ -func (a *api) acceptAndMergeHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) acceptAndMergeHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) if r.Method != http.MethodPost { diff --git a/cmd/merge_requests.go b/cmd/merge_requests.go index 48cd2f22..d8136e2c 100644 --- a/cmd/merge_requests.go +++ b/cmd/merge_requests.go @@ -20,7 +20,7 @@ type ListMergeRequestResponse struct { MergeRequests []*gitlab.MergeRequest `json:"merge_requests"` } -func (a *api) mergeRequestsHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) mergeRequestsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) @@ -49,11 +49,18 @@ func (a *api) mergeRequestsHandler(w http.ResponseWriter, r *http.Request) { NotLabels: (*gitlab.LabelOptions)(&listMergeRequestRequest.NotLabel), } - mergeRequests, _, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, &options) + mergeRequests, res, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, &options) + if err != nil { handleError(w, fmt.Errorf("Failed to list merge requests: %w", err), "Failed to list merge requests", http.StatusInternalServerError) return } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/merge_requests"}, "Failed to list merge requests", res.StatusCode) + return + } + if len(mergeRequests) == 0 { handleError(w, errors.New("No merge requests found"), "No merge requests found", http.StatusNotFound) return diff --git a/cmd/merge_requests_test.go b/cmd/merge_requests_test.go index 7d55d89d..14210805 100644 --- a/cmd/merge_requests_test.go +++ b/cmd/merge_requests_test.go @@ -1,44 +1,72 @@ package main import ( - "errors" "net/http" "testing" "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" + "go.uber.org/mock/gomock" ) -func listProjectMergeRequests200(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { - return []*gitlab.MergeRequest{{ID: 1}}, &gitlab.Response{}, nil -} - -func listProjectMergeRequestsEmpty(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { - return []*gitlab.MergeRequest{}, &gitlab.Response{}, nil -} - -func listProjectMergeRequestsErr(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { - return nil, nil, errors.New("Some error") +var testListMergeRequestsRequest = ListMergeRequestRequest{ + Label: []string{}, + NotLabel: []string{}, } func TestMergeRequestHandler(t *testing.T) { t.Run("Should fetch merge requests", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/merge_requests", ListMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequests200}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ListProjectMergeRequests("", gomock.Any()).Return([]*gitlab.MergeRequest{ + { + IID: 10, + }, + }, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ListMergeRequestResponse{}) + assert(t, data.Message, "Merge requests fetched successfully") assert(t, data.Status, http.StatusOK) }) - t.Run("Should handle an error", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/merge_requests", ListMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequestsErr}) + + t.Run("Should handle an error from Gitlab", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ListProjectMergeRequests("", gomock.Any()).Return(nil, nil, errorFromGitlab) + + request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + assert(t, data.Message, "Failed to list merge requests") assert(t, data.Status, http.StatusInternalServerError) }) - t.Run("Should handle not having any merge requests with 404", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/merge_requests", ListMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{listProjectMergeRequests: listProjectMergeRequestsEmpty}) + + t.Run("Should handle a non-200", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ListProjectMergeRequests("", gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) + + request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + + assert(t, data.Message, "Failed to list merge requests") + assert(t, data.Status, http.StatusSeeOther) + }) + + t.Run("Should handle not having any merge requests with 404", func(t *testing.T) { + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().ListProjectMergeRequests("", gomock.Any()).Return([]*gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, ListMergeRequestResponse{}) + assert(t, data.Message, "No merge requests found") assert(t, data.Status, http.StatusNotFound) }) diff --git a/cmd/merge_test.go b/cmd/merge_test.go index a9339b0d..3d78c949 100644 --- a/cmd/merge_test.go +++ b/cmd/merge_test.go @@ -1,52 +1,67 @@ package main import ( - "errors" "net/http" "testing" "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" + "go.uber.org/mock/gomock" ) -func acceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return &gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil -} - -func acceptMergeRequestErr(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return nil, nil, errors.New("Some error from Gitlab") -} - -func acceptAndMergeNon200(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return nil, makeResponse(http.StatusSeeOther), nil +var testAcceptMergeRequestPayload = AcceptMergeRequestRequest{ + Squash: false, + SquashMessage: "Squash me!", + DeleteBranch: false, } func TestAcceptAndMergeHandler(t *testing.T) { t.Run("Accepts and merges a merge request", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequest}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().AcceptMergeRequest("", mock_main.MergeId, gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, SuccessResponse{}) + assert(t, data.Message, "MR merged successfully") assert(t, data.Status, http.StatusOK) }) t.Run("Disallows non-POST methods", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/mr/merge", AcceptMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequest}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().AcceptMergeRequest("", mock_main.MergeId, gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPatch, "/mr/merge", testAcceptMergeRequestPayload) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkBadMethod(t, *data, http.MethodPost) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptMergeRequestErr}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().AcceptMergeRequest("", mock_main.MergeId, gomock.Any()).Return(nil, nil, errorFromGitlab) + + request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not merge MR") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/merge", AcceptMergeRequestRequest{}) - server, _ := createRouterAndApi(fakeClient{acceptMergeRequest: acceptAndMergeNon200}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().AcceptMergeRequest("", mock_main.MergeId, gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) + + request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) + server, _ := CreateRouterAndApi(client) data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not merge MR", "/mr/merge") }) } diff --git a/cmd/mocks/fake_client.go b/cmd/mocks/fake_client.go new file mode 100644 index 00000000..639edd07 --- /dev/null +++ b/cmd/mocks/fake_client.go @@ -0,0 +1,686 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: cmd/types.go +// +// Generated by this command: +// +// mockgen -source cmd/types.go +// + +// Package mock_main is a generated GoMock package. +package mock_main + +import ( + bytes "bytes" + io "io" + reflect "reflect" + + gitlab "github.com/xanzy/go-gitlab" + gomock "go.uber.org/mock/gomock" +) + +// MockClientInterface is a mock of ClientInterface interface. +type MockClientInterface struct { + ctrl *gomock.Controller + recorder *MockClientInterfaceMockRecorder +} + +// MockClientInterfaceMockRecorder is the mock recorder for MockClientInterface. +type MockClientInterfaceMockRecorder struct { + mock *MockClientInterface +} + +// NewMockClientInterface creates a new mock instance. +func NewMockClientInterface(ctrl *gomock.Controller) *MockClientInterface { + mock := &MockClientInterface{ctrl: ctrl} + mock.recorder = &MockClientInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClientInterface) EXPECT() *MockClientInterfaceMockRecorder { + return m.recorder +} + +// AcceptMergeRequest mocks base method. +func (m *MockClientInterface) AcceptMergeRequest(pid any, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "AcceptMergeRequest", varargs...) + ret0, _ := ret[0].(*gitlab.MergeRequest) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// AcceptMergeRequest indicates an expected call of AcceptMergeRequest. +func (mr *MockClientInterfaceMockRecorder) AcceptMergeRequest(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcceptMergeRequest", reflect.TypeOf((*MockClientInterface)(nil).AcceptMergeRequest), varargs...) +} + +// AddMergeRequestDiscussionNote mocks base method. +func (m *MockClientInterface) AddMergeRequestDiscussionNote(pid any, mergeRequestIID int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, discussion, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "AddMergeRequestDiscussionNote", varargs...) + ret0, _ := ret[0].(*gitlab.Note) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// AddMergeRequestDiscussionNote indicates an expected call of AddMergeRequestDiscussionNote. +func (mr *MockClientInterfaceMockRecorder) AddMergeRequestDiscussionNote(pid, mergeRequestIID, discussion, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, discussion, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddMergeRequestDiscussionNote", reflect.TypeOf((*MockClientInterface)(nil).AddMergeRequestDiscussionNote), varargs...) +} + +// ApproveMergeRequest mocks base method. +func (m *MockClientInterface) ApproveMergeRequest(pid any, mergeRequestIID int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ApproveMergeRequest", varargs...) + ret0, _ := ret[0].(*gitlab.MergeRequestApprovals) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ApproveMergeRequest indicates an expected call of ApproveMergeRequest. +func (mr *MockClientInterfaceMockRecorder) ApproveMergeRequest(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApproveMergeRequest", reflect.TypeOf((*MockClientInterface)(nil).ApproveMergeRequest), varargs...) +} + +// CreateDraftNote mocks base method. +func (m *MockClientInterface) CreateDraftNote(pid any, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CreateDraftNote", varargs...) + ret0, _ := ret[0].(*gitlab.DraftNote) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateDraftNote indicates an expected call of CreateDraftNote. +func (mr *MockClientInterfaceMockRecorder) CreateDraftNote(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateDraftNote", reflect.TypeOf((*MockClientInterface)(nil).CreateDraftNote), varargs...) +} + +// CreateMergeRequest mocks base method. +func (m *MockClientInterface) CreateMergeRequest(pid any, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CreateMergeRequest", varargs...) + ret0, _ := ret[0].(*gitlab.MergeRequest) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateMergeRequest indicates an expected call of CreateMergeRequest. +func (mr *MockClientInterfaceMockRecorder) CreateMergeRequest(pid, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMergeRequest", reflect.TypeOf((*MockClientInterface)(nil).CreateMergeRequest), varargs...) +} + +// CreateMergeRequestAwardEmojiOnNote mocks base method. +func (m *MockClientInterface) CreateMergeRequestAwardEmojiOnNote(pid any, mergeRequestIID, noteID int, opt *gitlab.CreateAwardEmojiOptions, options ...gitlab.RequestOptionFunc) (*gitlab.AwardEmoji, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, noteID, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CreateMergeRequestAwardEmojiOnNote", varargs...) + ret0, _ := ret[0].(*gitlab.AwardEmoji) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateMergeRequestAwardEmojiOnNote indicates an expected call of CreateMergeRequestAwardEmojiOnNote. +func (mr *MockClientInterfaceMockRecorder) CreateMergeRequestAwardEmojiOnNote(pid, mergeRequestIID, noteID, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, noteID, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMergeRequestAwardEmojiOnNote", reflect.TypeOf((*MockClientInterface)(nil).CreateMergeRequestAwardEmojiOnNote), varargs...) +} + +// CreateMergeRequestDiscussion mocks base method. +func (m *MockClientInterface) CreateMergeRequestDiscussion(pid any, mergeRequestIID int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CreateMergeRequestDiscussion", varargs...) + ret0, _ := ret[0].(*gitlab.Discussion) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CreateMergeRequestDiscussion indicates an expected call of CreateMergeRequestDiscussion. +func (mr *MockClientInterfaceMockRecorder) CreateMergeRequestDiscussion(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMergeRequestDiscussion", reflect.TypeOf((*MockClientInterface)(nil).CreateMergeRequestDiscussion), varargs...) +} + +// CurrentUser mocks base method. +func (m *MockClientInterface) CurrentUser(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CurrentUser", varargs...) + ret0, _ := ret[0].(*gitlab.User) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// CurrentUser indicates an expected call of CurrentUser. +func (mr *MockClientInterfaceMockRecorder) CurrentUser(options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CurrentUser", reflect.TypeOf((*MockClientInterface)(nil).CurrentUser), options...) +} + +// DeleteDraftNote mocks base method. +func (m *MockClientInterface) DeleteDraftNote(pid any, mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequest, note} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DeleteDraftNote", varargs...) + ret0, _ := ret[0].(*gitlab.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteDraftNote indicates an expected call of DeleteDraftNote. +func (mr *MockClientInterfaceMockRecorder) DeleteDraftNote(pid, mergeRequest, note any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequest, note}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteDraftNote", reflect.TypeOf((*MockClientInterface)(nil).DeleteDraftNote), varargs...) +} + +// DeleteMergeRequestAwardEmojiOnNote mocks base method. +func (m *MockClientInterface) DeleteMergeRequestAwardEmojiOnNote(pid any, mergeRequestIID, noteID, awardID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, noteID, awardID} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DeleteMergeRequestAwardEmojiOnNote", varargs...) + ret0, _ := ret[0].(*gitlab.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteMergeRequestAwardEmojiOnNote indicates an expected call of DeleteMergeRequestAwardEmojiOnNote. +func (mr *MockClientInterfaceMockRecorder) DeleteMergeRequestAwardEmojiOnNote(pid, mergeRequestIID, noteID, awardID any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, noteID, awardID}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMergeRequestAwardEmojiOnNote", reflect.TypeOf((*MockClientInterface)(nil).DeleteMergeRequestAwardEmojiOnNote), varargs...) +} + +// DeleteMergeRequestDiscussionNote mocks base method. +func (m *MockClientInterface) DeleteMergeRequestDiscussionNote(pid any, mergeRequestIID int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, discussion, note} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DeleteMergeRequestDiscussionNote", varargs...) + ret0, _ := ret[0].(*gitlab.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteMergeRequestDiscussionNote indicates an expected call of DeleteMergeRequestDiscussionNote. +func (mr *MockClientInterfaceMockRecorder) DeleteMergeRequestDiscussionNote(pid, mergeRequestIID, discussion, note any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, discussion, note}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMergeRequestDiscussionNote", reflect.TypeOf((*MockClientInterface)(nil).DeleteMergeRequestDiscussionNote), varargs...) +} + +// GetMergeRequest mocks base method. +func (m *MockClientInterface) GetMergeRequest(pid any, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetMergeRequest", varargs...) + ret0, _ := ret[0].(*gitlab.MergeRequest) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetMergeRequest indicates an expected call of GetMergeRequest. +func (mr *MockClientInterfaceMockRecorder) GetMergeRequest(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMergeRequest", reflect.TypeOf((*MockClientInterface)(nil).GetMergeRequest), varargs...) +} + +// GetMergeRequestDiffVersions mocks base method. +func (m *MockClientInterface) GetMergeRequestDiffVersions(pid any, mergeRequestIID int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetMergeRequestDiffVersions", varargs...) + ret0, _ := ret[0].([]*gitlab.MergeRequestDiffVersion) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetMergeRequestDiffVersions indicates an expected call of GetMergeRequestDiffVersions. +func (mr *MockClientInterfaceMockRecorder) GetMergeRequestDiffVersions(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMergeRequestDiffVersions", reflect.TypeOf((*MockClientInterface)(nil).GetMergeRequestDiffVersions), varargs...) +} + +// GetTraceFile mocks base method. +func (m *MockClientInterface) GetTraceFile(pid any, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, jobID} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetTraceFile", varargs...) + ret0, _ := ret[0].(*bytes.Reader) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetTraceFile indicates an expected call of GetTraceFile. +func (mr *MockClientInterfaceMockRecorder) GetTraceFile(pid, jobID any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, jobID}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTraceFile", reflect.TypeOf((*MockClientInterface)(nil).GetTraceFile), varargs...) +} + +// ListAllProjectMembers mocks base method. +func (m *MockClientInterface) ListAllProjectMembers(pid any, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListAllProjectMembers", varargs...) + ret0, _ := ret[0].([]*gitlab.ProjectMember) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListAllProjectMembers indicates an expected call of ListAllProjectMembers. +func (mr *MockClientInterfaceMockRecorder) ListAllProjectMembers(pid, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAllProjectMembers", reflect.TypeOf((*MockClientInterface)(nil).ListAllProjectMembers), varargs...) +} + +// ListDraftNotes mocks base method. +func (m *MockClientInterface) ListDraftNotes(pid any, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequest, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListDraftNotes", varargs...) + ret0, _ := ret[0].([]*gitlab.DraftNote) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListDraftNotes indicates an expected call of ListDraftNotes. +func (mr *MockClientInterfaceMockRecorder) ListDraftNotes(pid, mergeRequest, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequest, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListDraftNotes", reflect.TypeOf((*MockClientInterface)(nil).ListDraftNotes), varargs...) +} + +// ListLabels mocks base method. +func (m *MockClientInterface) ListLabels(pid any, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListLabels", varargs...) + ret0, _ := ret[0].([]*gitlab.Label) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListLabels indicates an expected call of ListLabels. +func (mr *MockClientInterfaceMockRecorder) ListLabels(pid, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListLabels", reflect.TypeOf((*MockClientInterface)(nil).ListLabels), varargs...) +} + +// ListMergeRequestAwardEmojiOnNote mocks base method. +func (m *MockClientInterface) ListMergeRequestAwardEmojiOnNote(pid any, mergeRequestIID, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, noteID, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListMergeRequestAwardEmojiOnNote", varargs...) + ret0, _ := ret[0].([]*gitlab.AwardEmoji) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListMergeRequestAwardEmojiOnNote indicates an expected call of ListMergeRequestAwardEmojiOnNote. +func (mr *MockClientInterfaceMockRecorder) ListMergeRequestAwardEmojiOnNote(pid, mergeRequestIID, noteID, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, noteID, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListMergeRequestAwardEmojiOnNote", reflect.TypeOf((*MockClientInterface)(nil).ListMergeRequestAwardEmojiOnNote), varargs...) +} + +// ListMergeRequestDiscussions mocks base method. +func (m *MockClientInterface) ListMergeRequestDiscussions(pid any, mergeRequestIID int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListMergeRequestDiscussions", varargs...) + ret0, _ := ret[0].([]*gitlab.Discussion) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListMergeRequestDiscussions indicates an expected call of ListMergeRequestDiscussions. +func (mr *MockClientInterfaceMockRecorder) ListMergeRequestDiscussions(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListMergeRequestDiscussions", reflect.TypeOf((*MockClientInterface)(nil).ListMergeRequestDiscussions), varargs...) +} + +// ListPipelineJobs mocks base method. +func (m *MockClientInterface) ListPipelineJobs(pid any, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, pipelineID, opts} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListPipelineJobs", varargs...) + ret0, _ := ret[0].([]*gitlab.Job) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListPipelineJobs indicates an expected call of ListPipelineJobs. +func (mr *MockClientInterfaceMockRecorder) ListPipelineJobs(pid, pipelineID, opts any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, pipelineID, opts}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPipelineJobs", reflect.TypeOf((*MockClientInterface)(nil).ListPipelineJobs), varargs...) +} + +// ListProjectMergeRequests mocks base method. +func (m *MockClientInterface) ListProjectMergeRequests(pid any, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListProjectMergeRequests", varargs...) + ret0, _ := ret[0].([]*gitlab.MergeRequest) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListProjectMergeRequests indicates an expected call of ListProjectMergeRequests. +func (mr *MockClientInterfaceMockRecorder) ListProjectMergeRequests(pid, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProjectMergeRequests", reflect.TypeOf((*MockClientInterface)(nil).ListProjectMergeRequests), varargs...) +} + +// ListProjectPipelines mocks base method. +func (m *MockClientInterface) ListProjectPipelines(pid any, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListProjectPipelines", varargs...) + ret0, _ := ret[0].([]*gitlab.PipelineInfo) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListProjectPipelines indicates an expected call of ListProjectPipelines. +func (mr *MockClientInterfaceMockRecorder) ListProjectPipelines(pid, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProjectPipelines", reflect.TypeOf((*MockClientInterface)(nil).ListProjectPipelines), varargs...) +} + +// PublishAllDraftNotes mocks base method. +func (m *MockClientInterface) PublishAllDraftNotes(pid any, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequest} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "PublishAllDraftNotes", varargs...) + ret0, _ := ret[0].(*gitlab.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PublishAllDraftNotes indicates an expected call of PublishAllDraftNotes. +func (mr *MockClientInterfaceMockRecorder) PublishAllDraftNotes(pid, mergeRequest any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequest}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishAllDraftNotes", reflect.TypeOf((*MockClientInterface)(nil).PublishAllDraftNotes), varargs...) +} + +// PublishDraftNote mocks base method. +func (m *MockClientInterface) PublishDraftNote(pid any, mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequest, note} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "PublishDraftNote", varargs...) + ret0, _ := ret[0].(*gitlab.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PublishDraftNote indicates an expected call of PublishDraftNote. +func (mr *MockClientInterfaceMockRecorder) PublishDraftNote(pid, mergeRequest, note any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequest, note}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishDraftNote", reflect.TypeOf((*MockClientInterface)(nil).PublishDraftNote), varargs...) +} + +// ResolveMergeRequestDiscussion mocks base method. +func (m *MockClientInterface) ResolveMergeRequestDiscussion(pid any, mergeRequestIID int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, discussion, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ResolveMergeRequestDiscussion", varargs...) + ret0, _ := ret[0].(*gitlab.Discussion) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ResolveMergeRequestDiscussion indicates an expected call of ResolveMergeRequestDiscussion. +func (mr *MockClientInterfaceMockRecorder) ResolveMergeRequestDiscussion(pid, mergeRequestIID, discussion, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, discussion, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveMergeRequestDiscussion", reflect.TypeOf((*MockClientInterface)(nil).ResolveMergeRequestDiscussion), varargs...) +} + +// RetryPipelineBuild mocks base method. +func (m *MockClientInterface) RetryPipelineBuild(pid any, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, pipeline} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "RetryPipelineBuild", varargs...) + ret0, _ := ret[0].(*gitlab.Pipeline) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// RetryPipelineBuild indicates an expected call of RetryPipelineBuild. +func (mr *MockClientInterfaceMockRecorder) RetryPipelineBuild(pid, pipeline any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, pipeline}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RetryPipelineBuild", reflect.TypeOf((*MockClientInterface)(nil).RetryPipelineBuild), varargs...) +} + +// UnapproveMergeRequest mocks base method. +func (m *MockClientInterface) UnapproveMergeRequest(pid any, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "UnapproveMergeRequest", varargs...) + ret0, _ := ret[0].(*gitlab.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UnapproveMergeRequest indicates an expected call of UnapproveMergeRequest. +func (mr *MockClientInterfaceMockRecorder) UnapproveMergeRequest(pid, mergeRequestIID any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnapproveMergeRequest", reflect.TypeOf((*MockClientInterface)(nil).UnapproveMergeRequest), varargs...) +} + +// UpdateDraftNote mocks base method. +func (m *MockClientInterface) UpdateDraftNote(pid any, mergeRequest, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequest, note, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "UpdateDraftNote", varargs...) + ret0, _ := ret[0].(*gitlab.DraftNote) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UpdateDraftNote indicates an expected call of UpdateDraftNote. +func (mr *MockClientInterfaceMockRecorder) UpdateDraftNote(pid, mergeRequest, note, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequest, note, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDraftNote", reflect.TypeOf((*MockClientInterface)(nil).UpdateDraftNote), varargs...) +} + +// UpdateMergeRequest mocks base method. +func (m *MockClientInterface) UpdateMergeRequest(pid any, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "UpdateMergeRequest", varargs...) + ret0, _ := ret[0].(*gitlab.MergeRequest) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UpdateMergeRequest indicates an expected call of UpdateMergeRequest. +func (mr *MockClientInterfaceMockRecorder) UpdateMergeRequest(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMergeRequest", reflect.TypeOf((*MockClientInterface)(nil).UpdateMergeRequest), varargs...) +} + +// UpdateMergeRequestDiscussionNote mocks base method. +func (m *MockClientInterface) UpdateMergeRequestDiscussionNote(pid any, mergeRequestIID int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, mergeRequestIID, discussion, note, opt} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "UpdateMergeRequestDiscussionNote", varargs...) + ret0, _ := ret[0].(*gitlab.Note) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UpdateMergeRequestDiscussionNote indicates an expected call of UpdateMergeRequestDiscussionNote. +func (mr *MockClientInterfaceMockRecorder) UpdateMergeRequestDiscussionNote(pid, mergeRequestIID, discussion, note, opt any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, mergeRequestIID, discussion, note, opt}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMergeRequestDiscussionNote", reflect.TypeOf((*MockClientInterface)(nil).UpdateMergeRequestDiscussionNote), varargs...) +} + +// UploadFile mocks base method. +func (m *MockClientInterface) UploadFile(pid any, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { + m.ctrl.T.Helper() + varargs := []any{pid, content, filename} + for _, a := range options { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "UploadFile", varargs...) + ret0, _ := ret[0].(*gitlab.ProjectFile) + ret1, _ := ret[1].(*gitlab.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// UploadFile indicates an expected call of UploadFile. +func (mr *MockClientInterfaceMockRecorder) UploadFile(pid, content, filename any, options ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{pid, content, filename}, options...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadFile", reflect.TypeOf((*MockClientInterface)(nil).UploadFile), varargs...) +} diff --git a/cmd/mocks/helpers.go b/cmd/mocks/helpers.go new file mode 100644 index 00000000..43f6858c --- /dev/null +++ b/cmd/mocks/helpers.go @@ -0,0 +1,60 @@ +package mock_main + +import ( + bytes "bytes" + io "io" + "net/http" + "testing" + + gitlab "github.com/xanzy/go-gitlab" + gomock "go.uber.org/mock/gomock" +) + +var MergeId = 3 + +func NewListMrOptions() *gitlab.ListProjectMergeRequestsOptions { + return &gitlab.ListProjectMergeRequestsOptions{ + Scope: gitlab.Ptr("all"), + State: gitlab.Ptr("opened"), + SourceBranch: gitlab.Ptr(""), + } +} + +/* Make response makes a simple response value with the right status code */ +func makeResponse(status int) *gitlab.Response { + return &gitlab.Response{ + Response: &http.Response{ + StatusCode: status, + }, + } +} + +type MockOpts struct { + MergeId int +} + +func NewMockClient(t *testing.T) *MockClientInterface { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockObj := NewMockClientInterface(ctrl) + return mockObj +} + +/** Adds a handler to satisfy the withMrs middleware by returning an MR from that endpoint with the given ID */ +func WithMr(t *testing.T, m *MockClientInterface) *MockClientInterface { + options := gitlab.ListProjectMergeRequestsOptions{ + Scope: gitlab.Ptr("all"), + State: gitlab.Ptr("opened"), + SourceBranch: gitlab.Ptr(""), + } + + m.EXPECT().ListProjectMergeRequests("", &options).Return([]*gitlab.MergeRequest{{IID: MergeId}}, makeResponse(http.StatusOK), nil) + + return m +} + +type MockAttachmentReader struct{} + +func (mf MockAttachmentReader) ReadFile(path string) (io.Reader, error) { + return bytes.NewReader([]byte{}), nil +} diff --git a/cmd/pipeline.go b/cmd/pipeline.go index 877904cf..f025a99a 100644 --- a/cmd/pipeline.go +++ b/cmd/pipeline.go @@ -30,7 +30,7 @@ type GetPipelineAndJobsResponse struct { pipelineHandler fetches information about the current pipeline, and retriggers a pipeline run. For more detailed information about a given job in a pipeline, see the jobHandler function */ -func (a *api) pipelineHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) pipelineHandler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: a.GetPipelineAndJobs(w, r) @@ -44,22 +44,23 @@ func (a *api) pipelineHandler(w http.ResponseWriter, r *http.Request) { } /* Gets the latest pipeline for a given commit, returns an error if there is no pipeline */ -func (a *api) GetLastPipeline(commit string) (*gitlab.PipelineInfo, error) { +func (a *Api) GetLastPipeline(commit string) (*gitlab.PipelineInfo, error) { l := &gitlab.ListProjectPipelinesOptions{ SHA: gitlab.Ptr(commit), Sort: gitlab.Ptr("desc"), } - l.Page = 1 - l.PerPage = 1 - - pipes, _, err := a.client.ListProjectPipelines(a.projectInfo.ProjectId, l) + pipes, res, err := a.client.ListProjectPipelines(a.projectInfo.ProjectId, l) if err != nil { return nil, err } + if res.StatusCode >= 300 { + return nil, errors.New("Could not get pipelines") + } + if len(pipes) == 0 { return nil, errors.New("No pipeline running or available for commit " + commit) } @@ -68,13 +69,12 @@ func (a *api) GetLastPipeline(commit string) (*gitlab.PipelineInfo, error) { } /* Gets the latest pipeline and job information for the current branch */ -func (a *api) GetPipelineAndJobs(w http.ResponseWriter, r *http.Request) { +func (a *Api) GetPipelineAndJobs(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") commit, err := a.gitInfo.GetLatestCommitOnRemote(a) if err != nil { - fmt.Println(err) handleError(w, err, "Error getting commit on remote branch", http.StatusInternalServerError) return } @@ -121,7 +121,7 @@ func (a *api) GetPipelineAndJobs(w http.ResponseWriter, r *http.Request) { } } -func (a *api) RetriggerPipeline(w http.ResponseWriter, r *http.Request) { +func (a *Api) RetriggerPipeline(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") id := strings.TrimPrefix(r.URL.Path, "/pipeline/trigger/") diff --git a/cmd/pipeline_test.go b/cmd/pipeline_test.go index 2ef44821..8657f079 100644 --- a/cmd/pipeline_test.go +++ b/cmd/pipeline_test.go @@ -1,46 +1,23 @@ package main import ( - "errors" + "fmt" "net/http" "testing" "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" + "go.uber.org/mock/gomock" ) -func listPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) { - return []*gitlab.Job{}, makeResponse(http.StatusOK), nil -} - -func listPipelineJobsErr(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) { - return nil, nil, errors.New("Some error from Gitlab") -} - -func listPipelineJobsNon200(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) { - return nil, makeResponse(http.StatusSeeOther), nil -} - -func retryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { - return &gitlab.Pipeline{}, makeResponse(http.StatusOK), nil -} - -func retryPipelineBuildErr(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { - return nil, nil, errors.New("Some error from Gitlab") -} +var testPipelineId = 12435 +var testPipelineCommit = "abc123" +var fakeProjectPipelines = []*gitlab.PipelineInfo{{ID: testPipelineId}} -func retryPipelineBuildNon200(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { - return nil, makeResponse(http.StatusSeeOther), nil -} - -func listProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) { - return []*gitlab.PipelineInfo{ - {ID: 12345}, - }, makeResponse(http.StatusOK), nil -} - -func withGitInfo(a *api) error { - a.gitInfo.GetLatestCommitOnRemote = func(a *api) (string, error) { - return "123abc", nil +/* This helps us stub out git interactions that the server would normally run in the project directory */ +func withGitInfo(a *Api) error { + a.gitInfo.GetLatestCommitOnRemote = func(a *Api) (string, error) { + return testPipelineCommit, nil } a.gitInfo.BranchName = "some-feature" return nil @@ -48,74 +25,82 @@ func withGitInfo(a *api) error { func TestPipelineHandler(t *testing.T) { t.Run("Gets all pipeline jobs", func(t *testing.T) { + client := mock_main.NewMockClient(t) + client.EXPECT().ListProjectPipelines("", gomock.Any()).Return(fakeProjectPipelines, makeResponse(http.StatusOK), nil) + client.EXPECT().ListPipelineJobs("", testPipelineId, &gitlab.ListJobsOptions{}).Return([]*gitlab.Job{}, makeResponse(http.StatusOK), nil) + request := makeRequest(t, http.MethodGet, "/pipeline", nil) - server, _ := createRouterAndApi(fakeClient{ - listPipelineJobs: listPipelineJobs, - listProjectPipelines: listProjectPipelines, - }, withGitInfo) + server, _ := CreateRouterAndApi(client, withGitInfo) data := serveRequest(t, server, request, GetPipelineAndJobsResponse{}) + assert(t, data.SuccessResponse.Message, "Pipeline retrieved") assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Disallows non-GET, non-POST methods", func(t *testing.T) { + client := mock_main.NewMockClient(t) request := makeRequest(t, http.MethodPatch, "/pipeline", nil) - server, _ := createRouterAndApi(fakeClient{ - listPipelineJobs: listPipelineJobs, - listProjectPipelines: listProjectPipelines, - }, withGitInfo) + server, _ := CreateRouterAndApi(client, withGitInfo) + data := serveRequest(t, server, request, ErrorResponse{}) checkBadMethod(t, *data, http.MethodGet, http.MethodPost) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { + client := mock_main.NewMockClient(t) + client.EXPECT().ListProjectPipelines("", gomock.Any()).Return(fakeProjectPipelines, makeResponse(http.StatusOK), nil) + client.EXPECT().ListPipelineJobs("", testPipelineId, &gitlab.ListJobsOptions{}).Return(nil, nil, errorFromGitlab) + request := makeRequest(t, http.MethodGet, "/pipeline", nil) - server, _ := createRouterAndApi(fakeClient{ - listPipelineJobs: listPipelineJobsErr, - listProjectPipelines: listProjectPipelines, - }, withGitInfo) + server, _ := CreateRouterAndApi(client, withGitInfo) + data := serveRequest(t, server, request, ErrorResponse{}) checkErrorFromGitlab(t, *data, "Could not get pipeline jobs") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + client := mock_main.NewMockClient(t) + client.EXPECT().ListProjectPipelines("", gomock.Any()).Return(fakeProjectPipelines, makeResponse(http.StatusOK), nil) + client.EXPECT().ListPipelineJobs("", testPipelineId, &gitlab.ListJobsOptions{}).Return(nil, makeResponse(http.StatusSeeOther), nil) + request := makeRequest(t, http.MethodGet, "/pipeline", nil) - server, _ := createRouterAndApi(fakeClient{ - listPipelineJobs: listPipelineJobsNon200, - listProjectPipelines: listProjectPipelines, - }, withGitInfo) - data := serveRequest(t, server, request, ErrorResponse{}) - checkNon200(t, *data, "Could not get pipeline jobs", "/pipeline") - }) + server, _ := CreateRouterAndApi(client, withGitInfo) - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil) - server, _ := createRouterAndApi(fakeClient{ - retryPipelineBuild: retryPipelineBuildErr, - listProjectPipelines: listProjectPipelines, - }, withGitInfo) data := serveRequest(t, server, request, ErrorResponse{}) - checkErrorFromGitlab(t, *data, "Could not retrigger pipeline") + checkNon200(t, *data, "Could not get pipeline jobs", "/pipeline") }) t.Run("Retriggers pipeline", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil) - server, _ := createRouterAndApi(fakeClient{ - retryPipelineBuild: retryPipelineBuild, - listProjectPipelines: listProjectPipelines, - }, withGitInfo) + client := mock_main.NewMockClient(t) + client.EXPECT().RetryPipelineBuild("", testPipelineId).Return(&gitlab.Pipeline{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPost, fmt.Sprintf("/pipeline/trigger/%d", testPipelineId), nil) + server, _ := CreateRouterAndApi(client, withGitInfo) + data := serveRequest(t, server, request, GetPipelineAndJobsResponse{}) assert(t, data.SuccessResponse.Message, "Pipeline retriggered") assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Handles non-200s from Gitlab client on retrigger", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/pipeline/trigger/1", nil) - server, _ := createRouterAndApi(fakeClient{ - retryPipelineBuild: retryPipelineBuildNon200, - listProjectPipelines: listProjectPipelines, - }, withGitInfo) + client := mock_main.NewMockClient(t) + client.EXPECT().RetryPipelineBuild("", testPipelineId).Return(nil, makeResponse(http.StatusSeeOther), nil) + + request := makeRequest(t, http.MethodPost, fmt.Sprintf("/pipeline/trigger/%d", testPipelineId), nil) + server, _ := CreateRouterAndApi(client, withGitInfo) + data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not retrigger pipeline", "/pipeline") }) + + t.Run("Handles error from Gitlab client on retrigger", func(t *testing.T) { + client := mock_main.NewMockClient(t) + client.EXPECT().RetryPipelineBuild("", testPipelineId).Return(nil, nil, errorFromGitlab) + + request := makeRequest(t, http.MethodPost, fmt.Sprintf("/pipeline/trigger/%d", testPipelineId), nil) + server, _ := CreateRouterAndApi(client, withGitInfo) + + data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not retrigger pipeline") + }) } diff --git a/cmd/reply.go b/cmd/reply.go index 4d104fdc..1b1fe54d 100644 --- a/cmd/reply.go +++ b/cmd/reply.go @@ -21,7 +21,7 @@ type ReplyResponse struct { } /* replyHandler sends a reply to a note or comment */ -func (a *api) replyHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) replyHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) diff --git a/cmd/reply_test.go b/cmd/reply_test.go index f5ff73db..51d48071 100644 --- a/cmd/reply_test.go +++ b/cmd/reply_test.go @@ -1,51 +1,80 @@ package main import ( - "errors" "net/http" "testing" "github.com/xanzy/go-gitlab" + mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" + "go.uber.org/mock/gomock" ) -func addMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { - return &gitlab.Note{}, makeResponse(http.StatusOK), nil -} - -func addMergeRequestDiscussionNoteErr(pid interface{}, mergeRequest int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { - return nil, nil, errors.New("Some error from Gitlab") -} - -func addMergeRequestDiscussionNoteNon200(pid interface{}, mergeRequest int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { - return nil, makeResponse(http.StatusSeeOther), nil +var testReplyRequest = ReplyRequest{ + DiscussionId: "abc123", + Reply: "Some Reply", + IsDraft: false, } func TestReplyHandler(t *testing.T) { t.Run("Sends a reply", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/reply", ReplyRequest{}) - server, _ := createRouterAndApi(fakeClient{addMergeRequestDiscussionNote: addMergeRequestDiscussionNote}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().AddMergeRequestDiscussionNote( + "", + mock_main.MergeId, + testReplyRequest.DiscussionId, + gomock.Any(), + ).Return(&gitlab.Note{}, makeResponse(http.StatusOK), nil) + + request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, ReplyResponse{}) assert(t, data.SuccessResponse.Message, "Replied to comment") assert(t, data.SuccessResponse.Status, http.StatusOK) }) t.Run("Disallows non-POST methods", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/mr/reply", ReplyRequest{}) - server, _ := createRouterAndApi(fakeClient{addMergeRequestDiscussionNote: addMergeRequestDiscussionNote}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + + request := makeRequest(t, http.MethodPut, "/mr/reply", testReplyRequest) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, ErrorResponse{}) checkBadMethod(t, *data, http.MethodPost) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/reply", ReplyRequest{}) - server, _ := createRouterAndApi(fakeClient{addMergeRequestDiscussionNote: addMergeRequestDiscussionNoteErr}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().AddMergeRequestDiscussionNote( + "", + mock_main.MergeId, + testReplyRequest.DiscussionId, + gomock.Any(), + ).Return(nil, nil, errorFromGitlab) + + request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, ErrorResponse{}) checkErrorFromGitlab(t, *data, "Could not leave reply") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/reply", ReplyRequest{}) - server, _ := createRouterAndApi(fakeClient{addMergeRequestDiscussionNote: addMergeRequestDiscussionNoteNon200}) + client := mock_main.NewMockClient(t) + mock_main.WithMr(t, client) + client.EXPECT().AddMergeRequestDiscussionNote( + "", + mock_main.MergeId, + testReplyRequest.DiscussionId, + gomock.Any(), + ).Return(nil, makeResponse(http.StatusSeeOther), nil) + + request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) + server, _ := CreateRouterAndApi(client) + data := serveRequest(t, server, request, ErrorResponse{}) checkNon200(t, *data, "Could not leave reply", "/mr/reply") }) diff --git a/cmd/resolve_discussion.go b/cmd/resolve_discussion.go index 3b9249c6..f5efd7d3 100644 --- a/cmd/resolve_discussion.go +++ b/cmd/resolve_discussion.go @@ -15,7 +15,7 @@ type DiscussionResolveRequest struct { } /* discussionsResolveHandler sets a discussion to be "resolved" or not resolved, depending on the payload */ -func (a *api) discussionsResolveHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) discussionsResolveHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPut { w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) diff --git a/cmd/reviewer.go b/cmd/reviewer.go index 78b17791..b4790272 100644 --- a/cmd/reviewer.go +++ b/cmd/reviewer.go @@ -23,7 +23,7 @@ type ReviewersRequestResponse struct { } /* reviewersHandler adds or removes reviewers from an MR */ -func (a *api) reviewersHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) reviewersHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPut { w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) diff --git a/cmd/revisions.go b/cmd/revisions.go index f85498d4..f8814283 100644 --- a/cmd/revisions.go +++ b/cmd/revisions.go @@ -16,7 +16,7 @@ type RevisionsResponse struct { revisionsHandler gets revision information about the current MR. This data is not used directly but is a precursor API call for other functionality */ -func (a *api) revisionsHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) revisionsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodGet { w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) diff --git a/cmd/revoke.go b/cmd/revoke.go index 00d473a3..c13168f6 100644 --- a/cmd/revoke.go +++ b/cmd/revoke.go @@ -6,7 +6,7 @@ import ( ) /* revokeHandler revokes approval for the current merge request */ -func (a *api) revokeHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) revokeHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) diff --git a/cmd/server.go b/cmd/server.go index 4bf20547..d2c70f66 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -19,24 +19,24 @@ to handle potential shutdown requests and incoming HTTP requests. */ func startServer(client *Client, projectInfo *ProjectInfo, gitInfo GitProjectInfo) { - m, a := createRouterAndApi(client, - func(a *api) error { + m, a := CreateRouterAndApi(client, + func(a *Api) error { a.projectInfo = projectInfo return nil }, - func(a *api) error { + func(a *Api) error { a.fileReader = attachmentReader{} return nil }, - func(a *api) error { + func(a *Api) error { a.gitInfo = &gitInfo return nil }, - func(a *api) error { + func(a *Api) error { err := attachEmojisToApi(a) return err }, - func(a *api) error { + func(a *Api) error { a.gitInfo.GetLatestCommitOnRemote = GetLatestCommitOnRemote return nil }) @@ -78,13 +78,13 @@ func startServer(client *Client, projectInfo *ProjectInfo, gitInfo GitProjectInf } /* -The api struct contains common configuration that's accessible to all handlers, such as the gitlab +The Api struct contains common configuration that's accessible to all handlers, such as the gitlab client, the project information, and the channels for signaling error or shutdown requests -The handlers for different Gitlab operations are are all methods on the api struct and interact +The handlers for different Gitlab operations are are all methods on the Api struct and interact with the client value, which is a go-gitlab client. */ -type api struct { +type Api struct { client ClientInterface projectInfo *ProjectInfo gitInfo *GitProjectInfo @@ -93,17 +93,17 @@ type api struct { sigCh chan os.Signal } -type optFunc func(a *api) error +type optFunc func(a *Api) error /* -createRouterAndApi wires up the router and attaches all handlers to their respective routes. It also +CreateRouterAndApi wires up the router and attaches all handlers to their respective routes. It also iterates over all option functions to configure API fields such as the project information and default file reader functionality */ -func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.ServeMux, api) { +func CreateRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.ServeMux, Api) { m := http.NewServeMux() - a := api{ + a := Api{ client: client, projectInfo: &ProjectInfo{}, gitInfo: &GitProjectInfo{}, @@ -188,7 +188,7 @@ func createListener() (l net.Listener) { } /* withMr is a Middlware that gets the current merge request ID and attaches it to the projectInfo */ -func (a *api) withMr(f func(w http.ResponseWriter, r *http.Request)) func(http.ResponseWriter, *http.Request) { +func (a *Api) withMr(f func(w http.ResponseWriter, r *http.Request)) func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { if a.projectInfo.MergeId != 0 { diff --git a/cmd/shutdown.go b/cmd/shutdown.go index 35b121f6..733f03aa 100644 --- a/cmd/shutdown.go +++ b/cmd/shutdown.go @@ -19,7 +19,7 @@ type ShutdownRequest struct { } /* shutdownHandler will shutdown the HTTP server and exit the process by signaling to the shutdown channel */ -func (a *api) shutdownHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) shutdownHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) diff --git a/cmd/summary.go b/cmd/summary.go index 01472944..0d4d6c5c 100644 --- a/cmd/summary.go +++ b/cmd/summary.go @@ -18,7 +18,7 @@ type SummaryUpdateResponse struct { MergeRequest *gitlab.MergeRequest `json:"mr"` } -func (a *api) summaryHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) summaryHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPut { diff --git a/cmd/test.go b/cmd/test.go index e22d75e7..58e0482d 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -17,177 +18,7 @@ import ( The FakeHandlerClient is used to create a fake gitlab client for testing our handlers, where the gitlab APIs are all mocked depending on what is provided during the variable initialization, so that we can simulate different responses from Gitlab */ -type fakeClient struct { - createMrFn func(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - getMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - updateMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - acceptMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - unapproveMergeRequest func(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) - uploadFile func(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) - getMergeRequestDiffVersions func(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) - approveMergeRequest func(pid interface{}, mergeRequestIID int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) - listMergeRequestDiscussions func(pid interface{}, mergeRequestIID int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) - resolveMergeRequestDiscussion func(pid interface{}, mergeRequestIID int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) - createMergeRequestDiscussion func(pid interface{}, mergeRequestIID int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) - updateMergeRequestDiscussionNote func(pid interface{}, mergeRequestIID int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) - deleteMergeRequestDiscussionNote func(pid interface{}, mergeRequestIID int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) - addMergeRequestDiscussionNote func(pid interface{}, mergeRequestIID int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) - listAllProjectMembers func(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) - retryPipelineBuild func(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) - listPipelineJobs func(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) - listProjectPipelines func(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) - getTraceFile func(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) - listLabels func(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) - listMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) - deleteMergeRequestAwardEmojiOnNote func(pid interface{}, mergeRequestIID, noteID, awardID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) - currentUser func(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) - createDraftNote func(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) - listDraftNotes func(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) - deleteDraftNote func(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) - updateDraftNote func(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) - publishAllDraftNotes func(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) - publishDraftNote func(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) - listProjectMergeRequests func(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) -} - -type Author struct { - ID int `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - Name string `json:"name"` - State string `json:"state"` - AvatarURL string `json:"avatar_url"` - WebURL string `json:"web_url"` -} - -func (f fakeClient) CreateMergeRequest(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return f.createMrFn(pid, opt, options...) -} - -func (f fakeClient) AcceptMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return f.acceptMergeRequest(pid, mergeRequestIID, opt, options...) -} - -func (f fakeClient) GetMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return f.getMergeRequest(pid, mergeRequestIID, opt, options...) -} - -func (f fakeClient) UpdateMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - return f.updateMergeRequest(pid, mergeRequestIID, opt, options...) -} - -func (f fakeClient) UnapproveMergeRequest(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return f.unapproveMergeRequest(pid, mergeRequestIID, options...) -} - -func (f fakeClient) UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { - return f.uploadFile(pid, content, filename, options...) -} - -func (f fakeClient) GetMergeRequestDiffVersions(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) { - return f.getMergeRequestDiffVersions(pid, mergeRequestIID, opt, options...) -} - -func (f fakeClient) ApproveMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) { - return f.approveMergeRequest(pid, mergeRequestIID, opt, options...) -} - -func (f fakeClient) ListMergeRequestDiscussions(pid interface{}, mergeRequestIID int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) { - return f.listMergeRequestDiscussions(pid, mergeRequestIID, opt, options...) -} - -func (f fakeClient) ResolveMergeRequestDiscussion(pid interface{}, mergeRequestIID int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { - return f.resolveMergeRequestDiscussion(pid, mergeRequestIID, discussion, opt, options...) -} - -func (f fakeClient) CreateMergeRequestDiscussion(pid interface{}, mergeRequestIID int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { - return f.createMergeRequestDiscussion(pid, mergeRequestIID, opt, options...) -} - -func (f fakeClient) UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { - return f.updateMergeRequestDiscussionNote(pid, mergeRequestIID, discussion, note, opt, options...) -} - -func (f fakeClient) DeleteMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return f.deleteMergeRequestDiscussionNote(pid, mergeRequestIID, discussion, note, options...) -} - -func (f fakeClient) AddMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { - return f.addMergeRequestDiscussionNote(pid, mergeRequestIID, discussion, opt, options...) -} - -func (f fakeClient) ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) { - return f.listAllProjectMembers(pid, opt, options...) -} - -func (f fakeClient) RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { - return f.retryPipelineBuild(pid, pipeline, options...) -} - -func (f fakeClient) ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) { - return f.listPipelineJobs(pid, pipelineID, opts, options...) -} - -func (f fakeClient) ListProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) { - return f.listProjectPipelines(pid, opt, options...) -} - -func (f fakeClient) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { - return f.getTraceFile(pid, jobID, options...) -} - -func (f fakeClient) ListLabels(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) { - return f.listLabels(pid, opt, options...) -} - -func (f fakeClient) ListMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) { - return f.listMergeRequestAwardEmojiOnNote(pid, mergeRequestIID, noteID, opt, options...) -} - -func (f fakeClient) DeleteMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID, noteID, awardID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return f.deleteMergeRequestAwardEmojiOnNote(pid, mergeRequestIID, noteID, awardID) -} - -func (f fakeClient) CreateDraftNote(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { - return f.createDraftNote(pid, mergeRequestIID, opt) -} - -func (f fakeClient) ListDraftNotes(pid interface{}, mergeRequestIID int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) { - return f.listDraftNotes(pid, mergeRequestIID, opt) -} - -func (f fakeClient) CurrentUser(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) { - return f.currentUser() -} - -func (f fakeClient) CreateMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID, noteID int, opt *gitlab.CreateAwardEmojiOptions, options ...gitlab.RequestOptionFunc) (*gitlab.AwardEmoji, *gitlab.Response, error) { - return &gitlab.AwardEmoji{}, &gitlab.Response{}, nil -} - -func (f fakeClient) UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { - return f.updateDraftNote(pid, mergeRequest, note, opt) -} - -func (f fakeClient) DeleteDraftNote(pid interface{}, mergeRequestIID int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return f.deleteDraftNote(pid, mergeRequestIID, note) -} - -func (f fakeClient) PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return f.publishDraftNote(pid, mergeRequest, note) -} - -func (f fakeClient) PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - return f.publishAllDraftNotes(pid, mergeRequest) -} - -/* This middleware function needs to return an ID for the rest of the handlers */ -func (f fakeClient) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { - if f.listProjectMergeRequests == nil { - return []*gitlab.MergeRequest{{ID: 1}}, &gitlab.Response{}, nil - } else { - return f.listProjectMergeRequests(pid, opt) - } -} +var errorFromGitlab = errors.New("Some error from Gitlab") /* The assert function is a helper function used to check two comparables */ func assert[T comparable](t *testing.T, got T, want T) { @@ -247,7 +78,7 @@ func checkErrorFromGitlab(t *testing.T, data ErrorResponse, msg string) { t.Helper() assert(t, data.Status, http.StatusInternalServerError) assert(t, data.Message, msg) - assert(t, data.Details, "Some error from Gitlab") + assert(t, data.Details, errorFromGitlab.Error()) } func checkBadMethod(t *testing.T, data ErrorResponse, methods ...string) { diff --git a/cmd/user.go b/cmd/user.go index 14b3fb7e..dcc3ea91 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -12,7 +12,7 @@ type UserResponse struct { User *gitlab.User `json:"user"` } -func (a *api) meHandler(w http.ResponseWriter, r *http.Request) { +func (a *Api) meHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodGet { w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) diff --git a/example.lua b/example.lua deleted file mode 100644 index 928a2dae..00000000 --- a/example.lua +++ /dev/null @@ -1,36 +0,0 @@ -local Layout = require("nui.layout") -local Popup = require("nui.popup") - -local opts = { - buf_options = { - filetype = "markdown", - }, - focusable = true, - border = { - style = "rounded", - }, -} - -local title_popup = Popup(opts) -local description_popup = Popup(opts) -local info_popup = Popup(opts) - -local layout = Layout( - { - position = "50%", - relative = "editor", - size = { - width = "95%", - height = "95%", - }, - }, - Layout.Box({ - Layout.Box(title_popup, { size = { height = 3 } }), - Layout.Box({ - Layout.Box(description_popup, { grow = 1 }), - Layout.Box(info_popup, { size = { height = 15 } }), - }, { dir = "col", size = "100%" }), - }, { dir = "col" }) -) - -layout:mount() diff --git a/go.mod b/go.mod index 16a7329b..6167caf3 100644 --- a/go.mod +++ b/go.mod @@ -11,9 +11,13 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + go.uber.org/mock v0.4.0 // indirect + golang.org/x/mod v0.11.0 // indirect golang.org/x/net v0.8.0 // indirect golang.org/x/oauth2 v0.6.0 // indirect + golang.org/x/sys v0.6.0 // indirect golang.org/x/time v0.3.0 // indirect + golang.org/x/tools v0.2.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.29.1 // indirect ) diff --git a/go.sum b/go.sum index c00df949..d1992dc2 100644 --- a/go.sum +++ b/go.sum @@ -21,18 +21,26 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/xanzy/go-gitlab v0.102.0 h1:ExHuJ1OTQ2yt25zBMMj0G96ChBirGYv8U7HyUiYkZ+4= github.com/xanzy/go-gitlab v0.102.0/go.mod h1:ETg8tcj4OhrB84UEgeE8dSuV/0h4BBL1uOV/qK0vlyI= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= +golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.2.0 h1:G6AHpWxTMGY1KyEYoAQ5WTtIekUUvDNjan3ugu60JvE= +golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= From 6f4053eacf26673b0ffc10fb8d1b26448c73ad3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Mon, 19 Aug 2024 14:46:49 +0200 Subject: [PATCH 79/97] Fix: Error with removed config fields (#351) --- lua/gitlab/health.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/health.lua b/lua/gitlab/health.lua index a81d7672..fbb87121 100644 --- a/lua/gitlab/health.lua +++ b/lua/gitlab/health.lua @@ -111,7 +111,7 @@ M.check = function(return_results) for _, field in ipairs(removed_settings_fields) do if u.get_nested_field(state.settings, field) ~= nil then - vim.health.warn(warnings, field) + table.insert(removed_fields_in_user_config, field) end end From 4589ea57de81e6398a6c72510a18411a1ec28954 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Thu, 22 Aug 2024 20:45:51 -0400 Subject: [PATCH 80/97] feat: Make remote configurable (#348) feat: Makes it possible to point at a remote other than origin --- README.md | 3 -- cmd/client.go | 56 +++++++------------------------------- cmd/git.go | 12 ++++---- cmd/git_test.go | 2 +- cmd/main.go | 25 +++++++++++++++++ cmd/server.go | 6 +--- doc/gitlab.nvim.txt | 5 +--- lua/gitlab/annotations.lua | 1 + lua/gitlab/git.lua | 13 ++++++--- lua/gitlab/server.lua | 25 +++++++++-------- lua/gitlab/state.lua | 3 +- 11 files changed, 69 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 54f2d835..4660110d 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,6 @@ To view these help docs and to get more detailed help information, please run `: This will checkout the branch locally, and open the plugin's reviewer pane. -NOTE: At the moment, the plugin assumes that the remote where you want to merge your feature branch -is called "origin". - For more detailed information about the Lua APIs please run `:h gitlab.nvim.api` ## Installation diff --git a/cmd/client.go b/cmd/client.go index 9cd43914..60fc3466 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -14,15 +14,6 @@ import ( "github.com/xanzy/go-gitlab" ) -type DebugSettings struct { - GoRequest bool `json:"go_request"` - GoResponse bool `json:"go_response"` -} - -type ConnectionOptions struct { - Insecure bool `json:"insecure"` -} - type ProjectInfo struct { ProjectId string MergeId int @@ -46,53 +37,27 @@ type Client struct { /* initGitlabClient parses and validates the project settings and initializes the Gitlab client. */ func initGitlabClient() (error, *Client) { - if len(os.Args) < 7 { - return errors.New("Must provide gitlab url, port, auth token, debug settings, log path, and connection settings"), nil - } - - gitlabInstance := os.Args[1] - if gitlabInstance == "" { + if pluginOptions.GitlabUrl == "" { return errors.New("GitLab instance URL cannot be empty"), nil } - authToken := os.Args[3] - if authToken == "" { - return errors.New("Auth token cannot be empty"), nil - } - - /* Parse debug settings and initialize logger handlers */ - debugSettings := os.Args[4] - var debugObject DebugSettings - err := json.Unmarshal([]byte(debugSettings), &debugObject) - if err != nil { - return fmt.Errorf("Could not parse debug settings: %w, %s", err, debugSettings), nil - } - - /* Parse connection options */ - connectionSettings := os.Args[6] - var connectionObject ConnectionOptions - err = json.Unmarshal([]byte(connectionSettings), &connectionObject) - if err != nil { - return fmt.Errorf("Could not parse connection settings: %w, %s", err, connectionSettings), nil - } - - var apiCustUrl = fmt.Sprintf(gitlabInstance + "/api/v4") + var apiCustUrl = fmt.Sprintf(pluginOptions.GitlabUrl + "/api/v4") gitlabOptions := []gitlab.ClientOptionFunc{ gitlab.WithBaseURL(apiCustUrl), } - if debugObject.GoRequest { + if pluginOptions.Debug.Request { gitlabOptions = append(gitlabOptions, gitlab.WithRequestLogHook(requestLogger)) } - if debugObject.GoResponse { + if pluginOptions.Debug.Response { gitlabOptions = append(gitlabOptions, gitlab.WithResponseLogHook(responseLogger)) } tr := &http.Transport{ TLSClientConfig: &tls.Config{ - InsecureSkipVerify: connectionObject.Insecure, + InsecureSkipVerify: pluginOptions.ConnectionSettings.Insecure, }, } @@ -100,7 +65,7 @@ func initGitlabClient() (error, *Client) { retryClient.HTTPClient.Transport = tr gitlabOptions = append(gitlabOptions, gitlab.WithHTTPClient(retryClient.HTTPClient)) - client, err := gitlab.NewClient(authToken, gitlabOptions...) + client, err := gitlab.NewClient(pluginOptions.AuthToken, gitlabOptions...) if err != nil { return fmt.Errorf("Failed to create client: %v", err), nil @@ -192,15 +157,14 @@ var responseLogger retryablehttp.ResponseLogHook = func(l retryablehttp.Logger, } func openLogFile() *os.File { - logFile := os.Args[5] - file, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + file, err := os.OpenFile(pluginOptions.LogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { if os.IsNotExist(err) { - log.Printf("Log file %s does not exist", logFile) + log.Printf("Log file %s does not exist", pluginOptions.LogPath) } else if os.IsPermission(err) { - log.Printf("Permission denied for log file %s", logFile) + log.Printf("Permission denied for log file %s", pluginOptions.LogPath) } else { - log.Printf("Error opening log file %s: %v", logFile, err) + log.Printf("Error opening log file %s: %v", pluginOptions.LogPath, err) } os.Exit(1) diff --git a/cmd/git.go b/cmd/git.go index 53231ab6..5129bd6e 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -90,10 +90,10 @@ func GetCurrentBranchNameFromNativeGitCmd() (res string, e error) { /* Gets the project SSH or HTTPS url */ func GetProjectUrlFromNativeGitCmd() (string, error) { - cmd := exec.Command("git", "remote", "get-url", "origin") + cmd := exec.Command("git", "remote", "get-url", pluginOptions.ConnectionSettings.Remote) url, err := cmd.Output() if err != nil { - return "", fmt.Errorf("Could not get origin remote") + return "", fmt.Errorf("Could not get remote") } return strings.TrimSpace(string(url)), nil @@ -101,10 +101,10 @@ func GetProjectUrlFromNativeGitCmd() (string, error) { /* Pulls down latest commit information from Gitlab */ func RefreshProjectInfo() error { - cmd := exec.Command("git", "fetch", "origin") + cmd := exec.Command("git", "fetch", pluginOptions.ConnectionSettings.Remote) _, err := cmd.Output() if err != nil { - return fmt.Errorf("Failed to run `git fetch origin`: %v", err) + return fmt.Errorf("Failed to run `git fetch %s`: %v", pluginOptions.ConnectionSettings.Remote, err) } return nil @@ -114,11 +114,11 @@ func RefreshProjectInfo() error { The GetLatestCommitOnRemote function is attached during the CreateRouterAndApi call, since it needs to be called every time to get the latest commit. */ func GetLatestCommitOnRemote(a *Api) (string, error) { - cmd := exec.Command("git", "log", "-1", "--format=%H", fmt.Sprintf("origin/%s", a.gitInfo.BranchName)) + cmd := exec.Command("git", "log", "-1", "--format=%H", fmt.Sprintf("%s/%s", pluginOptions.ConnectionSettings.Remote, a.gitInfo.BranchName)) out, err := cmd.Output() if err != nil { - return "", fmt.Errorf("Failed to run `git log -1 --format=%%H " + fmt.Sprintf("origin/%s", a.gitInfo.BranchName)) + return "", fmt.Errorf("Failed to run `git log -1 --format=%%H " + fmt.Sprintf("%s/%s", pluginOptions.ConnectionSettings.Remote, a.gitInfo.BranchName)) } commit := strings.TrimSpace(string(out)) diff --git a/cmd/git_test.go b/cmd/git_test.go index d2f263bd..72f02dcc 100644 --- a/cmd/git_test.go +++ b/cmd/git_test.go @@ -249,7 +249,7 @@ func TestExtractGitInfo_FailToGetCurrentBranchName(t *testing.T) { } func TestRefreshGitRemote_FailToRefreshRemote(t *testing.T) { - expectedErrNestedMsg := "error when fetching origin commits" + expectedErrNestedMsg := "error when fetching commits" _, actualErr := extractGitInfo( func() error { return errors.New(expectedErrNestedMsg) diff --git a/cmd/main.go b/cmd/main.go index 02e1fae6..68baeca1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,11 +1,36 @@ package main import ( + "encoding/json" "log" + "os" ) +type PluginOptions struct { + GitlabUrl string `json:"gitlab_url"` + Port int `json:"port"` + AuthToken string `json:"auth_token"` + LogPath string `json:"log_path"` + Debug struct { + Request bool `json:"go_request"` + Response bool `json:"go_response"` + } `json:"debug"` + ConnectionSettings struct { + Insecure bool `json:"insecure"` + Remote string `json:"remote"` + } `json:"connection_settings"` +} + +var pluginOptions PluginOptions + func main() { log.SetFlags(0) + + err := json.Unmarshal([]byte(os.Args[1]), &pluginOptions) + if err != nil { + log.Fatalf("Failure parsing plugin settings: %v", err) + } + gitInfo, err := extractGitInfo(RefreshProjectInfo, GetProjectUrlFromNativeGitCmd, GetCurrentBranchNameFromNativeGitCmd) if err != nil { log.Fatalf("Failure initializing plugin: %v", err) diff --git a/cmd/server.go b/cmd/server.go index d2c70f66..733768a5 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -173,11 +173,7 @@ func checkServer(port int) error { /* Creates a TCP listener on the port specified by the user or a random port */ func createListener() (l net.Listener) { - port := os.Args[2] - if port == "" { - port = "0" - } - addr := fmt.Sprintf("localhost:%s", port) + addr := fmt.Sprintf("localhost:%d", pluginOptions.Port) l, err := net.Listen("tcp", addr) if err != nil { fmt.Fprintf(os.Stderr, "Error starting server: %s\n", err) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index a9ce172e..4c8dc128 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -55,10 +55,6 @@ QUICK START *gitlab.nvim.quick-start* This will checkout the branch locally, and up the plugin's reviewer pane. -NOTE: At the moment, the plugin assumes that the remote where you want to -merge your feature branch is called "origin". - - INSTALLATION *gitlab.nvim.installation* With Lazy: @@ -165,6 +161,7 @@ you call this function with no values the defaults will be used: }, connection_settings = { insecure = false, -- Like curl's --insecure option, ignore bad x509 certificates on connection + remote = "origin", -- The default remote that your MRs target }, keymaps = { disable_all = false, -- Disable all mappings created by the plugin diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index 5360cc36..82a675b9 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -142,6 +142,7 @@ --- ---@class Settings ---@field port? number -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically +---@field remote_branch "origin" | string -- The remote, "origin" by default ---@field log_path? string -- Log path for the Go server ---@field string? any -- Custom path for `.gitlab.nvim` file, please read the "Connecting to Gitlab" section ---@field debug? DebugSettings -- Which values to log diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index debe8314..cced16ad 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -64,6 +64,7 @@ end ---Return the list of names of all remote-tracking branches or an empty list. ---@return table, string|nil M.get_all_remote_branches = function() + local state = require("gitlab.state") local all_branches, err = M.branches({ "--remotes" }) if err ~= nil then return {}, err @@ -76,8 +77,8 @@ M.get_all_remote_branches = function() local lines = u.lines_into_table(all_branches) return List.new(lines) :map(function(line) - -- Trim "origin/" - return line:match("origin/(%S+)") + -- Trim the remote branch + return line:match(state.settings.connection_settings.remote .. "/(%S+)") end) :filter(function(branch) -- Don't include the HEAD pointer @@ -96,6 +97,7 @@ end ---@param log_level integer ---@return boolean|nil M.current_branch_up_to_date_on_remote = function(log_level) + local state = require("gitlab.state") local current_branch = M.get_current_branch() local handle = io.popen("git branch -r --contains " .. current_branch .. " 2>&1") if not handle then @@ -110,13 +112,16 @@ M.current_branch_up_to_date_on_remote = function(log_level) handle:close() local current_head_on_remote = List.new(remote_branches_with_current_head):filter(function(line) - return line == " origin/" .. current_branch + return line == string.format(" %s/", state.settings.connection_settings.remote) .. current_branch end) local remote_up_to_date = #current_head_on_remote == 1 if not remote_up_to_date then require("gitlab.utils").notify( - "You have local commits that are not on origin. Have you forgotten to push?", + string.format( + "You have local commits that are not on %s. Have you forgotten to push?", + state.settings.connection_settings.remote + ), log_level ) end diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua index 60047116..05d28354 100644 --- a/lua/gitlab/server.lua +++ b/lua/gitlab/server.lua @@ -9,20 +9,21 @@ local M = {} -- Starts the Go server and call the callback provided M.start = function(callback) - local empty_port = "''" - local port = state.settings.port or empty_port + local port = state.settings.port or 0 local parsed_port = nil local callback_called = false - local command = string.format( - "%s %s %s %s '%s' %s '%s'", - state.settings.bin, - state.settings.gitlab_url, - port, - state.settings.auth_token, - vim.json.encode(state.settings.debug), - state.settings.log_path, - vim.json.encode(state.settings.connection_settings) - ) + + local go_server_settings = { + gitlab_url = state.settings.gitlab_url, + port = port, + auth_token = state.settings.auth_token, + debug = state.settings.debug, + log_path = state.settings.log_path, + connection_settings = state.settings.connection_settings, + } + + local settings = vim.json.encode(go_server_settings) + local command = string.format("%s '%s'", state.settings.bin, settings) local job_id = vim.fn.jobstart(command, { on_stdout = function(_, data) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index c6a84f9e..a359d7f8 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -57,7 +57,8 @@ M.settings = { }, }, connection_settings = { - insecure = true, + insecure = false, + remote = "origin", }, attachment_dir = "", keymaps = { From 070d20788aaf61e36f0b4aaabd8f4ace4bb2c387 Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Thu, 22 Aug 2024 22:53:45 -0400 Subject: [PATCH 81/97] Convert port to number --- lua/gitlab/server.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua index 05d28354..7f1ad6a9 100644 --- a/lua/gitlab/server.lua +++ b/lua/gitlab/server.lua @@ -9,7 +9,7 @@ local M = {} -- Starts the Go server and call the callback provided M.start = function(callback) - local port = state.settings.port or 0 + local port = tonumber(state.settings.port) or 0 local parsed_port = nil local callback_called = false From 9c46394be15b40174a394104c1f644bbd18c8bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Thu, 5 Sep 2024 15:30:53 +0200 Subject: [PATCH 82/97] Feat: Accept count with commenting keybindings (#353) Feat: Enables motions for easier range selection when creating comments/suggestions (e.g. s3j, c3j) --- doc/gitlab.nvim.txt | 29 ++++++++++++++++++--------- lua/gitlab/reviewer/init.lua | 39 ++++++++++++++++++++++++++---------- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 4c8dc128..85040d3a 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -651,22 +651,31 @@ Reviewer keybindings ~ Most of the keybindings `gitlab.nvim` sets are normal mode mappings, with the exception of `keymaps.reviewer.create_comment` and `keymaps.reviewer.create_suggestion` which work in both normal and visual -mode. In normal mode, these keybindings are |operator|s that accept a |motion| -(with an optional |count|). E.g., `c2j` will create a comment for the current -and the next 2 lines. Similarly, `sip` will create a suggestion for the "inner -paragraph". The operator forces |linewise| visual selection, so it works -correctly even if the motion itself works |characterwise| (e.g., |i(| for -selecting the inner parentheses block). +mode. In normal mode, these keybindings are |operator|s that must be followed +by a |motion|. + +Either the operator or the motion can be preceded by a count, so that `3sj` is +equivalent to `s3j`, and they both create a comment for the current line and +three more lines downwards. Similarly, both `2s`|ap| and `s2`|ap| create a suggestion +for two "outer" paragraphs. + +The operators force |linewise| visual selection, so they work correctly even +if the motion itself works |characterwise| (e.g., |i(| for selecting the inner +parentheses block). + +To create a comment or suggestion for the current line, just duplicate the +keybinding: `cc` and `ss`. This also accepts a count, so you can use `2cc` or +`c2c` to create a comment on two lines. The same logic applies also when you +change these keybindings, e.g., to something like `c`. The keybindings also work in visual mode, e.g., if you first want to make sure you are commenting on the right text segment/object, you can do `v4j` to visually select the current and the next 4 lines, followed by either `c` for a normal comment or `s` for a suggestion. -To create a comment or suggestion for the current line, just duplicate the -keybinding: `cc` and `ss`. Alternatively, you can use any motion that moves on -that line only, e.g., `c$`. The same logic applies also when you change these -keybindings, e.g., to something like `c`. +NOTE: Due to limitations of the Nvim API, unlike with builtin operators `d`, `c`, +etc., the operator count and motion count are NOT |multiplied|, so that the key +presses `3s2s` are not equivalent to `6ss`, but result in `s32s`. Delay in keybindings ~ diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 1b32e9a0..ed0cce95 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -276,8 +276,9 @@ M.set_callback_for_reviewer_enter = function(callback) }) end ----Create the line-wise visual selection in the range of the motion and execute the gitlab.nvim API ----function. After that, restore the cursor position and the original operatorfunc. +---Create the line-wise visual selection in the range of the motion (or on the [count] number of +---lines) and execute the gitlab.nvim API function. After that, restore the cursor position and the +---original operatorfunc. ---@param callback string Name of the gitlab.nvim API function to call M.execute_callback = function(callback) return function() @@ -299,7 +300,9 @@ local function execute_operatorfunc(cb) M.old_winnr = vim.api.nvim_get_current_win() M.old_cursor_position = vim.api.nvim_win_get_cursor(M.old_winnr) vim.opt.operatorfunc = ("v:lua.require'gitlab.reviewer'.execute_callback'%s'"):format(cb) - vim.api.nvim_feedkeys("g@", "n", false) + -- Use the operator count before motion to allow, e.g., 2cc == c2c + local count = M.operator_count > 0 and tostring(M.operator_count) or "" + vim.api.nvim_feedkeys("g@" .. count, "n", false) end ---Set keymaps for creating comments, suggestions and for jumping to discussion tree. @@ -308,16 +311,21 @@ end local set_keymaps = function(bufnr, keymaps) -- Set mappings for creating comments if keymaps.reviewer.create_comment ~= false then - vim.keymap.set( - "o", - keymaps.reviewer.create_comment, - "$", - { buffer = bufnr, desc = "Create comment for current line", nowait = keymaps.reviewer.create_comment_nowait } - ) + -- Set keymap for repeated operator keybinding + vim.keymap.set("o", keymaps.reviewer.create_comment, function() + vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { tostring(vim.v.count1) .. "j" } }, {}) + end, { + buffer = bufnr, + desc = "Create comment for [count] lines", + nowait = keymaps.reviewer.create_comment_nowait, + }) + + -- Set operator keybinding vim.keymap.set( "n", keymaps.reviewer.create_comment, function() + M.operator_count = vim.v.count execute_operatorfunc("create_multiline_comment") end, { buffer = bufnr, desc = "Create comment for range of motion", nowait = keymaps.reviewer.create_comment_nowait } @@ -333,18 +341,27 @@ local set_keymaps = function(bufnr, keymaps) -- Set mappings for creating suggestions if keymaps.reviewer.create_suggestion ~= false then - vim.keymap.set("o", keymaps.reviewer.create_suggestion, "$", { + -- Set keymap for repeated operator keybinding + vim.keymap.set("o", keymaps.reviewer.create_suggestion, function() + vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { tostring(vim.v.count1) .. "j" } }, {}) + end, { buffer = bufnr, - desc = "Create suggestion for current line", + desc = "Create suggestion for [count] lines", nowait = keymaps.reviewer.create_suggestion_nowait, }) + + -- Set operator keybinding vim.keymap.set("n", keymaps.reviewer.create_suggestion, function() + M.operator_count = vim.v.count + M.operator = keymaps.reviewer.create_suggestion execute_operatorfunc("create_comment_suggestion") end, { buffer = bufnr, desc = "Create suggestion for range of motion", nowait = keymaps.reviewer.create_suggestion_nowait, }) + + -- Set visual mode keybinding vim.keymap.set("v", keymaps.reviewer.create_suggestion, function() require("gitlab").create_comment_suggestion() end, { From db9197298d79bcabc6c9a73bd0180d71a4c232b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Thu, 5 Sep 2024 17:58:02 +0200 Subject: [PATCH 83/97] Feat: Enable always jumping to discussion tree (#352) Feat: Make it possible to always jump to the discussion tree, even if not hovering a diagnostic --- doc/gitlab.nvim.txt | 1 + lua/gitlab/actions/discussions/init.lua | 14 +++++++++++++- lua/gitlab/state.lua | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 85040d3a..d43afa2c 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -155,6 +155,7 @@ you call this function with no values the defaults will be used: debug = { go_request = false, go_response = false }, -- Which values to log attachment_dir = nil, -- The local directory for files (see the "summary" section) reviewer_settings = { + jump_with_no_diagnostics = false, -- Jump to last position in discussion tree if true, otherwise stay in reviewer and show warning. diffview = { imply_local = false, -- If true, will attempt to use --imply_local option when calling |:DiffviewOpen| }, diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index cc5421b6..5e7cfdda 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -202,7 +202,12 @@ M.move_to_discussion_tree = function() end if #d == 0 then - u.notify("No diagnostics for this line", vim.log.levels.WARN) + if state.settings.reviewer_settings.jump_with_no_diagnostics then + vim.api.nvim_win_set_cursor(M.split.winid, { M.last_row, M.last_column }) + vim.api.nvim_set_current_win(M.split.winid) + else + u.notify("No diagnostics for this line.", vim.log.levels.WARN) + end return elseif #d > 1 then vim.ui.select(d, { @@ -518,6 +523,13 @@ M.create_split_and_bufs = function() local linked_bufnr = vim.api.nvim_create_buf(true, false) local unlinked_bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_create_autocmd("WinLeave", { + buffer = linked_bufnr, + callback = function() + M.last_row, M.last_column = unpack(vim.api.nvim_win_get_cursor(0)) + end, + }) + return split, linked_bufnr, unlinked_bufnr end diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index a359d7f8..a5b8f583 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -52,6 +52,7 @@ M.settings = { config_path = nil, reviewer = "diffview", reviewer_settings = { + jump_with_no_diagnostics = false, diffview = { imply_local = false, }, From 8d1d35157f7fbc2083806fc4958267cdc4a73625 Mon Sep 17 00:00:00 2001 From: EpiCanard Date: Thu, 5 Sep 2024 19:07:26 +0200 Subject: [PATCH 84/97] fix: Makes help popup not editable and close it on BufLeave (#355) --- lua/gitlab/actions/help.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lua/gitlab/actions/help.lua b/lua/gitlab/actions/help.lua index 1db04489..d76c7b5e 100644 --- a/lua/gitlab/actions/help.lua +++ b/lua/gitlab/actions/help.lua @@ -1,6 +1,7 @@ local M = {} local u = require("gitlab.utils") +local event = require("nui.utils.autocmd").event local state = require("gitlab.state") local List = require("gitlab.utils.list") local Popup = require("nui.popup") @@ -18,11 +19,15 @@ M.open = function() local longest_line = u.get_longest_string(help_content_lines) local help_popup = Popup(u.create_popup_state("Help", state.settings.popup.help, longest_line + 3, #help_content_lines + 3, 60)) + help_popup:on(event.BufLeave, function() + help_popup:unmount() + end) help_popup:mount() state.set_popup_keymaps(help_popup, "Help", nil) local currentBuffer = vim.api.nvim_get_current_buf() vim.api.nvim_buf_set_lines(currentBuffer, 0, #help_content_lines, false, help_content_lines) + u.switch_can_edit_buf(currentBuffer, false) end return M From 6df467cb8abb0e2a83f9362ef33c0f597853f9ae Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Thu, 5 Sep 2024 14:00:48 -0400 Subject: [PATCH 85/97] Refactor: Splitting Up Go Interfaces (#354) refactor: Splits up Go interfaces and simplifies test suite, removes mocks, etc. --- .github/CONTRIBUTING.md | 5 +- .github/workflows/go.yaml | 1 + cmd/{ => app}/approve.go | 15 +- cmd/app/approve_test.go | 55 +++ cmd/{ => app}/assignee.go | 9 +- cmd/app/assignee_test.go | 57 +++ cmd/{ => app}/attachment.go | 16 +- cmd/app/attachment_test.go | 64 +++ cmd/{ => app}/client.go | 13 +- cmd/{ => app}/comment.go | 21 +- cmd/{ => app}/comment_helpers.go | 2 +- cmd/app/comment_test.go | 124 +++++ cmd/app/config.go | 22 + cmd/{ => app}/create_mr.go | 13 +- cmd/app/create_mr_test.go | 80 ++++ cmd/app/draft_note_publisher.go | 76 +++ cmd/app/draft_note_publisher_test.go | 74 +++ cmd/{ => app}/draft_notes.go | 106 ++--- cmd/app/draft_notes_test.go | 153 ++++++ cmd/{ => app}/emoji.go | 138 ++---- cmd/{ => app/git}/git.go | 63 +-- cmd/app/git/git_test.go | 219 +++++++++ cmd/{ => app}/info.go | 13 +- cmd/app/info_test.go | 49 ++ cmd/{ => app}/job.go | 16 +- cmd/app/job_test.go | 71 +++ cmd/{ => app}/label.go | 18 +- cmd/{ => app}/list_discussions.go | 83 +++- cmd/app/list_discussions_test.go | 113 +++++ cmd/{ => app}/members.go | 13 +- cmd/app/members_test.go | 48 ++ cmd/{merge.go => app/merge_mr.go} | 13 +- cmd/app/merge_mr_test.go | 50 ++ cmd/{ => app}/merge_requests.go | 16 +- cmd/app/merge_requests_test.go | 58 +++ cmd/{ => app}/pipeline.go | 27 +- cmd/app/pipeline_test.go | 86 ++++ cmd/{ => app}/reply.go | 13 +- cmd/app/reply_test.go | 45 ++ cmd/{ => app}/resolve_discussion.go | 13 +- cmd/app/response_types.go | 30 ++ cmd/{ => app}/reviewer.go | 13 +- cmd/{ => app}/revisions.go | 13 +- cmd/{ => app}/revoke.go | 15 +- cmd/app/server.go | 192 ++++++++ cmd/{ => app}/shutdown.go | 30 +- cmd/{ => app}/summary.go | 9 +- cmd/{test.go => app/test_helpers.go} | 93 +++- cmd/{ => app}/user.go | 13 +- cmd/approve_test.go | 59 --- cmd/assignee_test.go | 69 --- cmd/attachment_test.go | 69 --- cmd/comment_test.go | 182 ------- cmd/create_mr_test.go | 99 ---- cmd/draft_notes_test.go | 247 ---------- cmd/git_test.go | 272 ----------- cmd/info_test.go | 59 --- cmd/job_test.go | 59 --- cmd/list_discussions_test.go | 132 ------ cmd/main.go | 32 +- cmd/members_test.go | 56 --- cmd/merge_requests_test.go | 73 --- cmd/merge_test.go | 67 --- cmd/mocks/fake_client.go | 686 --------------------------- cmd/mocks/helpers.go | 60 --- cmd/pipeline_test.go | 106 ----- cmd/reply_test.go | 81 ---- cmd/server.go | 222 --------- cmd/types.go | 69 --- cmd/utils.go | 10 - go.mod | 10 +- go.sum | 26 +- makefile | 2 +- 73 files changed, 2220 insertions(+), 3006 deletions(-) rename cmd/{ => app}/approve.go (69%) create mode 100644 cmd/app/approve_test.go rename cmd/{ => app}/assignee.go (92%) create mode 100644 cmd/app/assignee_test.go rename cmd/{ => app}/attachment.go (86%) create mode 100644 cmd/app/attachment_test.go rename cmd/{ => app}/client.go (91%) rename cmd/{ => app}/comment.go (84%) rename cmd/{ => app}/comment_helpers.go (99%) create mode 100644 cmd/app/comment_test.go create mode 100644 cmd/app/config.go rename cmd/{ => app}/create_mr.go (86%) create mode 100644 cmd/app/create_mr_test.go create mode 100644 cmd/app/draft_note_publisher.go create mode 100644 cmd/app/draft_note_publisher_test.go rename cmd/{ => app}/draft_notes.go (73%) create mode 100644 cmd/app/draft_notes_test.go rename cmd/{ => app}/emoji.go (69%) rename cmd/{ => app/git}/git.go (58%) create mode 100644 cmd/app/git/git_test.go rename cmd/{ => app}/info.go (77%) create mode 100644 cmd/app/info_test.go rename cmd/{ => app}/job.go (82%) create mode 100644 cmd/app/job_test.go rename cmd/{ => app}/label.go (83%) rename cmd/{ => app}/list_discussions.go (64%) create mode 100644 cmd/app/list_discussions_test.go rename cmd/{ => app}/members.go (78%) create mode 100644 cmd/app/members_test.go rename cmd/{merge.go => app/merge_mr.go} (82%) create mode 100644 cmd/app/merge_mr_test.go rename cmd/{ => app}/merge_requests.go (80%) create mode 100644 cmd/app/merge_requests_test.go rename cmd/{ => app}/pipeline.go (75%) create mode 100644 cmd/app/pipeline_test.go rename cmd/{ => app}/reply.go (84%) create mode 100644 cmd/app/reply_test.go rename cmd/{ => app}/resolve_discussion.go (81%) create mode 100644 cmd/app/response_types.go rename cmd/{ => app}/reviewer.go (83%) rename cmd/{ => app}/revisions.go (77%) rename cmd/{ => app}/revoke.go (74%) create mode 100644 cmd/app/server.go rename cmd/{ => app}/shutdown.go (67%) rename cmd/{ => app}/summary.go (92%) rename cmd/{test.go => app/test_helpers.go} (54%) rename cmd/{ => app}/user.go (79%) delete mode 100644 cmd/approve_test.go delete mode 100644 cmd/assignee_test.go delete mode 100644 cmd/attachment_test.go delete mode 100644 cmd/comment_test.go delete mode 100644 cmd/create_mr_test.go delete mode 100644 cmd/draft_notes_test.go delete mode 100644 cmd/git_test.go delete mode 100644 cmd/info_test.go delete mode 100644 cmd/job_test.go delete mode 100644 cmd/list_discussions_test.go delete mode 100644 cmd/members_test.go delete mode 100644 cmd/merge_requests_test.go delete mode 100644 cmd/merge_test.go delete mode 100644 cmd/mocks/fake_client.go delete mode 100644 cmd/mocks/helpers.go delete mode 100644 cmd/pipeline_test.go delete mode 100644 cmd/reply_test.go delete mode 100644 cmd/server.go delete mode 100644 cmd/types.go delete mode 100644 cmd/utils.go diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 934a859d..0a07071c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -38,11 +38,10 @@ $ go fmt ./... $ golangci-lint run ``` -If you are writing tests and have added something to the Go client, you can re-generate the mocked client like so: +If you are writing tests and have added something to the Go client, you can test with: ```bash -$ go install go.uber.org/mock/mockgen@latest # Install the mockgen CLI on your machine -$ mockgen -source cmd/types.go > cmd/mocks/fake_client.go +$ make test ``` For changes to the Lua codebase: We use stylua for formatting and luacheck for linting. Run these commands in the root of the repository: diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index ce68a779..01e1dbee 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -19,6 +19,7 @@ jobs: with: version: v1.54 only-new-issues: true + skip-cache: true go_test: name: Test Go 🧪 needs: [go_lint] diff --git a/cmd/approve.go b/cmd/app/approve.go similarity index 69% rename from cmd/approve.go rename to cmd/app/approve.go index 9dc8b477..1426b7b6 100644 --- a/cmd/approve.go +++ b/cmd/app/approve.go @@ -1,12 +1,23 @@ -package main +package app import ( "encoding/json" "net/http" + + "github.com/xanzy/go-gitlab" ) +type MergeRequestApprover interface { + ApproveMergeRequest(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) +} + +type mergeRequestApproverService struct { + data + client MergeRequestApprover +} + /* approveHandler approves a merge request. */ -func (a *Api) approveHandler(w http.ResponseWriter, r *http.Request) { +func (a mergeRequestApproverService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) diff --git a/cmd/app/approve_test.go b/cmd/app/approve_test.go new file mode 100644 index 00000000..5baf1c8a --- /dev/null +++ b/cmd/app/approve_test.go @@ -0,0 +1,55 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeApproverClient struct { + testBase +} + +func (f fakeApproverClient) ApproveMergeRequest(pid interface{}, mr int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + return &gitlab.MergeRequestApprovals{}, resp, nil +} + +func TestApproveHandler(t *testing.T) { + t.Run("Approves merge request", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/approve", nil) + client := fakeApproverClient{} + svc := mergeRequestApproverService{testProjectData, client} + data := getSuccessData(t, svc, request) + assert(t, data.Message, "Approved MR") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Disallows non-POST method", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/approve", nil) + client := fakeApproverClient{} + svc := mergeRequestApproverService{testProjectData, client} + data := getFailData(t, svc, request) + checkBadMethod(t, data, http.MethodPost) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/approve", nil) + client := fakeApproverClient{testBase{errFromGitlab: true}} + svc := mergeRequestApproverService{testProjectData, client} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not approve merge request") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/approve", nil) + client := fakeApproverClient{testBase{status: http.StatusSeeOther}} + svc := mergeRequestApproverService{testProjectData, client} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not approve merge request", "/mr/approve") + }) +} diff --git a/cmd/assignee.go b/cmd/app/assignee.go similarity index 92% rename from cmd/assignee.go rename to cmd/app/assignee.go index 0d4f6c6d..cf419e19 100644 --- a/cmd/assignee.go +++ b/cmd/app/assignee.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -22,8 +22,13 @@ type AssigneesRequestResponse struct { Assignees []int `json:"assignees"` } +type assigneesService struct { + data + client MergeRequestUpdater +} + /* assigneesHandler adds or removes assignees from a merge request. */ -func (a *Api) assigneesHandler(w http.ResponseWriter, r *http.Request) { +func (a assigneesService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPut { w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) diff --git a/cmd/app/assignee_test.go b/cmd/app/assignee_test.go new file mode 100644 index 00000000..24e7e60c --- /dev/null +++ b/cmd/app/assignee_test.go @@ -0,0 +1,57 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeAssigneeClient struct { + testBase +} + +func (f fakeAssigneeClient) UpdateMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + return &gitlab.MergeRequest{}, resp, nil +} + +func TestAssigneeHandler(t *testing.T) { + var updatePayload = AssigneeUpdateRequest{Ids: []int{1, 2}} + + t.Run("Updates assignees", func(t *testing.T) { + request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload) + client := fakeAssigneeClient{} + svc := assigneesService{testProjectData, client} + data := getSuccessData(t, svc, request) + assert(t, data.Message, "Assignees updated") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Disallows non-PUT method", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/assignee", nil) + client := fakeAssigneeClient{} + svc := assigneesService{testProjectData, client} + data := getFailData(t, svc, request) + checkBadMethod(t, data, http.MethodPut) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPut, "/mr/approve", updatePayload) + client := fakeAssigneeClient{testBase{errFromGitlab: true}} + svc := assigneesService{testProjectData, client} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not modify merge request assignees") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPut, "/mr/approve", updatePayload) + client := fakeAssigneeClient{testBase{status: http.StatusSeeOther}} + svc := assigneesService{testProjectData, client} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not modify merge request assignees", "/mr/assignee") + }) +} diff --git a/cmd/attachment.go b/cmd/app/attachment.go similarity index 86% rename from cmd/attachment.go rename to cmd/app/attachment.go index fcbd64d8..10ee33dd 100644 --- a/cmd/attachment.go +++ b/cmd/app/attachment.go @@ -1,4 +1,4 @@ -package main +package app import ( "bytes" @@ -7,6 +7,8 @@ import ( "io" "net/http" "os" + + "github.com/xanzy/go-gitlab" ) type FileReader interface { @@ -45,8 +47,18 @@ func (ar attachmentReader) ReadFile(path string) (io.Reader, error) { return reader, nil } +type FileUploader interface { + UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) +} + +type attachmentService struct { + data + fileReader FileReader + client FileUploader +} + /* attachmentHandler uploads an attachment (file, image, etc) to Gitlab and returns metadata about the upload. */ -func (a *Api) attachmentHandler(w http.ResponseWriter, r *http.Request) { +func (a attachmentService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) diff --git a/cmd/app/attachment_test.go b/cmd/app/attachment_test.go new file mode 100644 index 00000000..8fefaf0b --- /dev/null +++ b/cmd/app/attachment_test.go @@ -0,0 +1,64 @@ +package app + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeFileUploaderClient struct { + testBase +} + +func (f fakeFileUploaderClient) UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + + return &gitlab.ProjectFile{}, resp, nil +} + +type fakeFileReader struct{} + +func (f fakeFileReader) ReadFile(path string) (io.Reader, error) { + return &bytes.Reader{}, nil +} + +func TestAttachmentHandler(t *testing.T) { + attachmentTestRequestData := AttachmentRequest{ + FileName: "some_file_name", + FilePath: "some_file_path", + } + + t.Run("Returns 200-status response after upload", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) + svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{}} + data := getSuccessData(t, svc, request) + assert(t, data.Status, http.StatusOK) + assert(t, data.Message, "File uploaded successfully") + }) + + t.Run("Disallows non-POST method", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/attachment", nil) + svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{}} + data := getFailData(t, svc, request) + checkBadMethod(t, data, http.MethodPost) + }) + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) + svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not upload some_file_name to Gitlab") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) + svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not upload some_file_name to Gitlab", "/attachment") + }) +} diff --git a/cmd/client.go b/cmd/app/client.go similarity index 91% rename from cmd/client.go rename to cmd/app/client.go index 60fc3466..c9e8ddb2 100644 --- a/cmd/client.go +++ b/cmd/app/client.go @@ -1,4 +1,4 @@ -package main +package app import ( "crypto/tls" @@ -10,6 +10,7 @@ import ( "net/http/httputil" "os" + "github.com/harrisoncramer/gitlab.nvim/cmd/app/git" "github.com/hashicorp/go-retryablehttp" "github.com/xanzy/go-gitlab" ) @@ -34,8 +35,8 @@ type Client struct { *gitlab.DraftNotesService } -/* initGitlabClient parses and validates the project settings and initializes the Gitlab client. */ -func initGitlabClient() (error, *Client) { +/* NewClient parses and validates the project settings and initializes the Gitlab client. */ +func NewClient() (error, *Client) { if pluginOptions.GitlabUrl == "" { return errors.New("GitLab instance URL cannot be empty"), nil @@ -86,11 +87,11 @@ func initGitlabClient() (error, *Client) { } } -/* initProjectSettings fetch the project ID using the client */ -func initProjectSettings(c *Client, gitInfo GitProjectInfo) (error, *ProjectInfo) { +/* InitProjectSettings fetch the project ID using the client */ +func InitProjectSettings(c *Client, gitInfo git.GitData) (error, *ProjectInfo) { opt := gitlab.GetProjectOptions{} - project, _, err := c.GetProject(gitInfo.projectPath(), &opt) + project, _, err := c.GetProject(gitInfo.ProjectPath(), &opt) if err != nil { return fmt.Errorf(fmt.Sprintf("Error getting project at %s", gitInfo.RemoteUrl), err), nil diff --git a/cmd/comment.go b/cmd/app/comment.go similarity index 84% rename from cmd/comment.go rename to cmd/app/comment.go index 7cbd36d0..6ccc14f3 100644 --- a/cmd/comment.go +++ b/cmd/app/comment.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -41,8 +41,19 @@ func (comment CommentWithPosition) GetPositionData() PositionData { return comment.PositionData } +type CommentManager interface { + CreateMergeRequestDiscussion(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) + UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) + DeleteMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) +} + +type commentService struct { + data + client CommentManager +} + /* commentHandler creates, edits, and deletes discussions (comments, multi-line comments) */ -func (a *Api) commentHandler(w http.ResponseWriter, r *http.Request) { +func (a commentService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodPost: @@ -58,7 +69,7 @@ func (a *Api) commentHandler(w http.ResponseWriter, r *http.Request) { } /* deleteComment deletes a note, multiline comment, or comment, which are all considered discussion notes. */ -func (a *Api) deleteComment(w http.ResponseWriter, r *http.Request) { +func (a commentService) deleteComment(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { handleError(w, err, "Could not read request body", http.StatusBadRequest) @@ -99,7 +110,7 @@ func (a *Api) deleteComment(w http.ResponseWriter, r *http.Request) { } /* postComment creates a note, multiline comment, or comment. */ -func (a *Api) postComment(w http.ResponseWriter, r *http.Request) { +func (a commentService) postComment(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { handleError(w, err, "Could not read request body", http.StatusBadRequest) @@ -156,7 +167,7 @@ func (a *Api) postComment(w http.ResponseWriter, r *http.Request) { } /* editComment changes the text of a comment or changes it's resolved status. */ -func (a *Api) editComment(w http.ResponseWriter, r *http.Request) { +func (a commentService) editComment(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { diff --git a/cmd/comment_helpers.go b/cmd/app/comment_helpers.go similarity index 99% rename from cmd/comment_helpers.go rename to cmd/app/comment_helpers.go index bd69507e..e6ba633f 100644 --- a/cmd/comment_helpers.go +++ b/cmd/app/comment_helpers.go @@ -1,4 +1,4 @@ -package main +package app import ( "crypto/sha1" diff --git a/cmd/app/comment_test.go b/cmd/app/comment_test.go new file mode 100644 index 00000000..03ed716e --- /dev/null +++ b/cmd/app/comment_test.go @@ -0,0 +1,124 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeCommentClient struct { + testBase +} + +func (f fakeCommentClient) CreateMergeRequestDiscussion(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + + return &gitlab.Discussion{Notes: []*gitlab.Note{{}}}, resp, err +} +func (f fakeCommentClient) UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + + return &gitlab.Note{}, resp, err +} + +func (f fakeCommentClient) DeleteMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, err + } + return resp, err +} + +func TestPostComment(t *testing.T) { + var testCommentCreationData = PostCommentRequest{Comment: "Some comment"} + t.Run("Creates a new note (unlinked comment)", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) + svc := commentService{testProjectData, fakeCommentClient{}} + data := getSuccessData(t, svc, request) + assert(t, data.Message, "Comment created successfully") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Creates a new comment", func(t *testing.T) { + testCommentCreationData := PostCommentRequest{ // Re-create comment creation data to avoid mutating this variable in other tests + Comment: "Some comment", + PositionData: PositionData{ + FileName: "file.txt", + }, + } + request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) + svc := commentService{testProjectData, fakeCommentClient{}} + data := getSuccessData(t, svc, request) + assert(t, data.Message, "Comment created successfully") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) + svc := commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not create discussion") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) + svc := commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not create discussion", "/mr/comment") + }) +} + +func TestDeleteComment(t *testing.T) { + var testCommentDeletionData = DeleteCommentRequest{NoteId: 3, DiscussionId: "abc123"} + t.Run("Deletes a comment", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData) + svc := commentService{testProjectData, fakeCommentClient{}} + data := getSuccessData(t, svc, request) + assert(t, data.Message, "Comment deleted successfully") + assert(t, data.Status, http.StatusOK) + }) + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData) + svc := commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not delete comment") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData) + svc := commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not delete comment", "/mr/comment") + }) +} + +func TestEditComment(t *testing.T) { + var testEditCommentData = EditCommentRequest{Comment: "Some comment", NoteId: 3, DiscussionId: "abc123"} + t.Run("Edits a comment", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData) + svc := commentService{testProjectData, fakeCommentClient{}} + data := getSuccessData(t, svc, request) + assert(t, data.Message, "Comment updated successfully") + assert(t, data.Status, http.StatusOK) + }) + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData) + svc := commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not update comment") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData) + svc := commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not update comment", "/mr/comment") + }) +} diff --git a/cmd/app/config.go b/cmd/app/config.go new file mode 100644 index 00000000..5b35b9e8 --- /dev/null +++ b/cmd/app/config.go @@ -0,0 +1,22 @@ +package app + +type PluginOptions struct { + GitlabUrl string `json:"gitlab_url"` + Port int `json:"port"` + AuthToken string `json:"auth_token"` + LogPath string `json:"log_path"` + Debug struct { + Request bool `json:"go_request"` + Response bool `json:"go_response"` + } `json:"debug"` + ConnectionSettings struct { + Insecure bool `json:"insecure"` + Remote string `json:"remote"` + } `json:"connection_settings"` +} + +var pluginOptions PluginOptions + +func SetPluginOptions(p PluginOptions) { + pluginOptions = p +} diff --git a/cmd/create_mr.go b/cmd/app/create_mr.go similarity index 86% rename from cmd/create_mr.go rename to cmd/app/create_mr.go index 47dcda3b..b8a318d7 100644 --- a/cmd/create_mr.go +++ b/cmd/app/create_mr.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -19,8 +19,17 @@ type CreateMrRequest struct { TargetProjectID int `json:"forked_project_id,omitempty"` } +type MergeRequestCreator interface { + CreateMergeRequest(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) +} + +type mergeRequestCreatorService struct { + data + client MergeRequestCreator +} + /* createMr creates a merge request */ -func (a *Api) createMr(w http.ResponseWriter, r *http.Request) { +func (a mergeRequestCreatorService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) if r.Method != http.MethodPost { diff --git a/cmd/app/create_mr_test.go b/cmd/app/create_mr_test.go new file mode 100644 index 00000000..9e5a78de --- /dev/null +++ b/cmd/app/create_mr_test.go @@ -0,0 +1,80 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeMergeCreatorClient struct { + testBase +} + +func (f fakeMergeCreatorClient) CreateMergeRequest(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + return &gitlab.MergeRequest{}, resp, nil +} + +func TestCreateMr(t *testing.T) { + var testCreateMrRequestData = CreateMrRequest{ + Title: "Some title", + Description: "Some description", + TargetBranch: "main", + DeleteBranch: false, + Squash: false, + } + t.Run("Creates an MR", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) + svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}} + data := getSuccessData(t, svc, request) + assert(t, data.Message, "MR 'Some title' created") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Disallows non-POST methods", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/create_mr", testCreateMrRequestData) + svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}} + data := getFailData(t, svc, request) + checkBadMethod(t, data, http.MethodPost) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) + svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not create MR") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) + svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not create MR", "/create_mr") + }) + + t.Run("Handles missing titles", func(t *testing.T) { + reqData := testCreateMrRequestData + reqData.Title = "" + request := makeRequest(t, http.MethodPost, "/create_mr", reqData) + svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}} + data := getFailData(t, svc, request) + assert(t, data.Status, http.StatusBadRequest) + assert(t, data.Message, "Could not create MR") + assert(t, data.Details, "Title cannot be empty") + }) + + t.Run("Handles missing target branch", func(t *testing.T) { + reqData := testCreateMrRequestData + reqData.TargetBranch = "" + request := makeRequest(t, http.MethodPost, "/create_mr", reqData) + svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}} + data := getFailData(t, svc, request) + assert(t, data.Status, http.StatusBadRequest) + assert(t, data.Message, "Could not create MR") + assert(t, data.Details, "Target branch cannot be empty") + }) +} diff --git a/cmd/app/draft_note_publisher.go b/cmd/app/draft_note_publisher.go new file mode 100644 index 00000000..57cfa033 --- /dev/null +++ b/cmd/app/draft_note_publisher.go @@ -0,0 +1,76 @@ +package app + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/xanzy/go-gitlab" +) + +type DraftNotePublisher interface { + PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) +} + +type draftNotePublisherService struct { + data + client DraftNotePublisher +} + +func (a draftNotePublisherService) handler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) + handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + defer r.Body.Close() + var draftNotePublishRequest DraftNotePublishRequest + err = json.Unmarshal(body, &draftNotePublishRequest) + + if err != nil { + handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + return + } + + var res *gitlab.Response + if draftNotePublishRequest.PublishAll { + res, err = a.client.PublishAllDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId) + } else { + if draftNotePublishRequest.Note == 0 { + handleError(w, errors.New("No ID provided"), "Must provide Note ID", http.StatusBadRequest) + return + } + res, err = a.client.PublishDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, draftNotePublishRequest.Note) + } + + if err != nil { + handleError(w, err, "Could not publish draft note(s)", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/mr/draft_notes/publish"}, "Could not publish dfaft note", res.StatusCode) + return + } + + w.WriteHeader(http.StatusOK) + response := SuccessResponse{ + Message: "Draft note(s) published", + Status: http.StatusOK, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} diff --git a/cmd/app/draft_note_publisher_test.go b/cmd/app/draft_note_publisher_test.go new file mode 100644 index 00000000..b53dda1f --- /dev/null +++ b/cmd/app/draft_note_publisher_test.go @@ -0,0 +1,74 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeDraftNotePublisher struct { + testBase +} + +func (f fakeDraftNotePublisher) PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return f.handleGitlabError() +} +func (f fakeDraftNotePublisher) PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return f.handleGitlabError() +} + +func TestPublishDraftNote(t *testing.T) { + var testDraftNotePublishRequest = DraftNotePublishRequest{Note: 3, PublishAll: false} + t.Run("Publishes draft note", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) + svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}} + data := getSuccessData(t, svc, request) + assert(t, data.Status, http.StatusOK) + assert(t, data.Message, "Draft note(s) published") + }) + t.Run("Disallows non-POST method", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/draft_notes/publish", testDraftNotePublishRequest) + svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}} + data := getFailData(t, svc, request) + checkBadMethod(t, data, http.MethodPost) + }) + t.Run("Handles bad ID", func(t *testing.T) { + badData := testDraftNotePublishRequest + badData.Note = 0 + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", badData) + svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}} + data := getFailData(t, svc, request) + assert(t, data.Status, http.StatusBadRequest) + assert(t, data.Message, "Must provide Note ID") + }) + t.Run("Handles error from Gitlab", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) + svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not publish draft note(s)") + }) +} + +func TestPublishAllDraftNotes(t *testing.T) { + var testDraftNotePublishRequest = DraftNotePublishRequest{PublishAll: true} + t.Run("Should publish all draft notes", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) + svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}} + data := getSuccessData(t, svc, request) + assert(t, data.Status, http.StatusOK) + assert(t, data.Message, "Draft note(s) published") + }) + t.Run("Disallows non-POST method", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/draft_notes/publish", testDraftNotePublishRequest) + svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}} + data := getFailData(t, svc, request) + checkBadMethod(t, data, http.MethodPost) + }) + t.Run("Handles error from Gitlab", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) + svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not publish draft note(s)") + }) +} diff --git a/cmd/draft_notes.go b/cmd/app/draft_notes.go similarity index 73% rename from cmd/draft_notes.go rename to cmd/app/draft_notes.go index b18262f5..6af8f0d7 100644 --- a/cmd/draft_notes.go +++ b/cmd/app/draft_notes.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -51,8 +51,20 @@ func (draftNote DraftNoteWithPosition) GetPositionData() PositionData { return draftNote.PositionData } +type DraftNoteManager interface { + ListDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) + CreateDraftNote(pid interface{}, mergeRequest int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) + DeleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) +} + +type draftNoteService struct { + data + client DraftNoteManager +} + /* draftNoteHandler creates, edits, and deletes draft notes */ -func (a *Api) draftNoteHandler(w http.ResponseWriter, r *http.Request) { +func (a draftNoteService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodGet: @@ -69,54 +81,29 @@ func (a *Api) draftNoteHandler(w http.ResponseWriter, r *http.Request) { } } -func (a *Api) draftNotePublisher(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost { - w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) - handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - defer r.Body.Close() - var draftNotePublishRequest DraftNotePublishRequest - err = json.Unmarshal(body, &draftNotePublishRequest) - - if err != nil { - handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) - return - } +/* listDraftNotes lists all draft notes for the currently authenticated user */ +func (a draftNoteService) listDraftNotes(w http.ResponseWriter, _ *http.Request) { - var res *gitlab.Response - if draftNotePublishRequest.PublishAll { - res, err = a.client.PublishAllDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId) - } else { - if draftNotePublishRequest.Note == 0 { - handleError(w, errors.New("No ID provided"), "Must provide Note ID", http.StatusBadRequest) - return - } - res, err = a.client.PublishDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, draftNotePublishRequest.Note) - } + opt := gitlab.ListDraftNotesOptions{} + draftNotes, res, err := a.client.ListDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt) if err != nil { - handleError(w, err, "Could not publish draft note(s)", http.StatusInternalServerError) + handleError(w, err, "Could not get draft notes", http.StatusInternalServerError) return } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/draft_notes/publish"}, "Could not publish dfaft note", res.StatusCode) + handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not get draft notes", res.StatusCode) return } w.WriteHeader(http.StatusOK) - response := SuccessResponse{ - Message: "Draft note(s) published", - Status: http.StatusOK, + response := ListDraftNotesResponse{ + SuccessResponse: SuccessResponse{ + Message: "Draft notes fetched successfully", + Status: http.StatusOK, + }, + DraftNotes: draftNotes, } err = json.NewEncoder(w).Encode(response) @@ -126,7 +113,7 @@ func (a *Api) draftNotePublisher(w http.ResponseWriter, r *http.Request) { } /* postDraftNote creates a draft note */ -func (a *Api) postDraftNote(w http.ResponseWriter, r *http.Request) { +func (a draftNoteService) postDraftNote(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { handleError(w, err, "Could not read request body", http.StatusBadRequest) @@ -184,7 +171,7 @@ func (a *Api) postDraftNote(w http.ResponseWriter, r *http.Request) { } /* deleteDraftNote deletes a draft note */ -func (a *Api) deleteDraftNote(w http.ResponseWriter, r *http.Request) { +func (a draftNoteService) deleteDraftNote(w http.ResponseWriter, r *http.Request) { suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/") id, err := strconv.Atoi(suffix) if err != nil { @@ -200,7 +187,7 @@ func (a *Api) deleteDraftNote(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not delete draft note", res.StatusCode) + handleError(w, GenericError{endpoint: fmt.Sprintf("/mr/draft_notes/%d", id)}, "Could not delete draft note", res.StatusCode) return } @@ -217,7 +204,7 @@ func (a *Api) deleteDraftNote(w http.ResponseWriter, r *http.Request) { } /* updateDraftNote edits the text of a draft comment */ -func (a *Api) updateDraftNote(w http.ResponseWriter, r *http.Request) { +func (a draftNoteService) updateDraftNote(w http.ResponseWriter, r *http.Request) { suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/") id, err := strconv.Atoi(suffix) if err != nil { @@ -258,7 +245,7 @@ func (a *Api) updateDraftNote(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not update draft note", res.StatusCode) + handleError(w, GenericError{endpoint: fmt.Sprintf("/mr/draft_notes/%d", id)}, "Could not update draft note", res.StatusCode) return } @@ -276,34 +263,3 @@ func (a *Api) updateDraftNote(w http.ResponseWriter, r *http.Request) { handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } - -/* listDraftNotes lists all draft notes for the currently authenticated user */ -func (a *Api) listDraftNotes(w http.ResponseWriter, _ *http.Request) { - - opt := gitlab.ListDraftNotesOptions{} - draftNotes, res, err := a.client.ListDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt) - - if err != nil { - handleError(w, err, "Could not get draft notes", http.StatusInternalServerError) - return - } - - if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/draft/comment"}, "Could not get draft notes", res.StatusCode) - return - } - - w.WriteHeader(http.StatusOK) - response := ListDraftNotesResponse{ - SuccessResponse: SuccessResponse{ - Message: "Draft notes fetched successfully", - Status: http.StatusOK, - }, - DraftNotes: draftNotes, - } - - err = json.NewEncoder(w).Encode(response) - if err != nil { - handleError(w, err, "Could not encode response", http.StatusInternalServerError) - } -} diff --git a/cmd/app/draft_notes_test.go b/cmd/app/draft_notes_test.go new file mode 100644 index 00000000..17b32599 --- /dev/null +++ b/cmd/app/draft_notes_test.go @@ -0,0 +1,153 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeDraftNoteManager struct { + testBase +} + +func (f fakeDraftNoteManager) ListDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + return []*gitlab.DraftNote{}, resp, err +} + +func (f fakeDraftNoteManager) CreateDraftNote(pid interface{}, mergeRequest int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + return &gitlab.DraftNote{}, resp, err +} + +func (f fakeDraftNoteManager) DeleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + return f.handleGitlabError() +} + +func (f fakeDraftNoteManager) UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + return &gitlab.DraftNote{}, resp, err +} + +func TestListDraftNotes(t *testing.T) { + t.Run("Lists all draft notes", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{}} + data := getSuccessData(t, svc, request) + assert(t, data.Status, http.StatusOK) + assert(t, data.Message, "Draft notes fetched successfully") + }) + t.Run("Handles error from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not get draft notes") + }) + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not get draft notes", "/mr/draft_notes/") + }) +} + +func TestPostDraftNote(t *testing.T) { + var testPostDraftNoteRequestData = PostDraftNoteRequest{Comment: "Some comment"} + t.Run("Posts new draft note", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{}} + data := getSuccessData(t, svc, request) + assert(t, data.Status, http.StatusOK) + assert(t, data.Message, "Draft note created successfully") + }) + t.Run("Handles error from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not create draft note") + }) + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not create draft note", "/mr/draft_notes/") + }) +} + +func TestDeleteDraftNote(t *testing.T) { + t.Run("Deletes new draft note", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{}} + data := getSuccessData(t, svc, request) + assert(t, data.Status, http.StatusOK) + assert(t, data.Message, "Draft note deleted") + }) + t.Run("Handles error from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not delete draft note") + }) + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not delete draft note", "/mr/draft_notes/3") + }) + t.Run("Handles bad ID", func(t *testing.T) { + request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/blah", nil) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + assert(t, data.Message, "Could not parse draft note ID") + assert(t, data.Status, http.StatusBadRequest) + }) +} + +func TestEditDraftNote(t *testing.T) { + var testUpdateDraftNoteRequest = UpdateDraftNoteRequest{Note: "Some new note"} + t.Run("Edits new draft note", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{}} + data := getSuccessData(t, svc, request) + assert(t, data.Status, http.StatusOK) + assert(t, data.Message, "Draft note updated") + }) + t.Run("Handles error from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not update draft note") + }) + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not update draft note", "/mr/draft_notes/3") + }) + t.Run("Handles bad ID", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/blah", testUpdateDraftNoteRequest) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + assert(t, data.Message, "Could not parse draft note ID") + assert(t, data.Status, http.StatusBadRequest) + }) + t.Run("Handles empty note", func(t *testing.T) { + requestData := testUpdateDraftNoteRequest + requestData.Note = "" + request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", requestData) + svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + assert(t, data.Message, "Must provide draft note text") + assert(t, data.Status, http.StatusBadRequest) + }) +} diff --git a/cmd/emoji.go b/cmd/app/emoji.go similarity index 69% rename from cmd/emoji.go rename to cmd/app/emoji.go index 45cd5b55..979fce6f 100644 --- a/cmd/emoji.go +++ b/cmd/app/emoji.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -10,7 +10,6 @@ import ( "path" "strconv" "strings" - "sync" "github.com/xanzy/go-gitlab" ) @@ -39,99 +38,17 @@ type CreateEmojiResponse struct { Emoji *gitlab.AwardEmoji } -/* -attachEmojisToApi reads the emojis from our external JSON file -and attaches them to the API so that they can be looked up later -*/ -func attachEmojisToApi(a *Api) error { - - e, err := os.Executable() - if err != nil { - return err - } - - binPath := path.Dir(e) - filePath := fmt.Sprintf("%s/config/emojis.json", binPath) - - reader, err := a.fileReader.ReadFile(filePath) - - if err != nil { - return fmt.Errorf("Could not find emojis at %s", filePath) - } - - bytes, err := io.ReadAll(reader) - if err != nil { - return errors.New("Could not read emoji file") - } - - var emojiMap EmojiMap - err = json.Unmarshal(bytes, &emojiMap) - if err != nil { - return errors.New("Could not unmarshal emojis") - } - - a.emojiMap = emojiMap - return nil +type EmojiManager interface { + DeleteMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, awardID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) + CreateMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.CreateAwardEmojiOptions, options ...gitlab.RequestOptionFunc) (*gitlab.AwardEmoji, *gitlab.Response, error) } -/* -Fetches emojis for a set of notes and comments in parallel and returns a map of note IDs to their emojis. -Gitlab's API does not allow for fetching notes for an entire discussion thread so we have to do it per-note. -*/ -func (a *Api) fetchEmojisForNotesAndComments(noteIDs []int) (map[int][]*gitlab.AwardEmoji, error) { - var wg sync.WaitGroup - - emojis := make(map[int][]*gitlab.AwardEmoji) - mu := &sync.Mutex{} - errs := make(chan error, len(noteIDs)) - emojiChan := make(chan struct { - noteID int - emojis []*gitlab.AwardEmoji - }, len(noteIDs)) - - for _, noteID := range noteIDs { - wg.Add(1) - go func(noteID int) { - defer wg.Done() - emojis, _, err := a.client.ListMergeRequestAwardEmojiOnNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, noteID, &gitlab.ListAwardEmojiOptions{}) - if err != nil { - errs <- err - return - } - emojiChan <- struct { - noteID int - emojis []*gitlab.AwardEmoji - }{noteID, emojis} - }(noteID) - } - - /* Close the channels when all goroutines finish */ - go func() { - wg.Wait() - close(errs) - close(emojiChan) - }() - - /* Collect emojis */ - for e := range emojiChan { - mu.Lock() - emojis[e.noteID] = e.emojis - mu.Unlock() - } - - /* Check if any errors occurred */ - if len(errs) > 0 { - for err := range errs { - if err != nil { - return nil, err - } - } - } - - return emojis, nil +type emojiService struct { + data + client EmojiManager } -func (a *Api) emojiNoteHandler(w http.ResponseWriter, r *http.Request) { +func (a emojiService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodPost: @@ -145,7 +62,7 @@ func (a *Api) emojiNoteHandler(w http.ResponseWriter, r *http.Request) { } /* deleteEmojiFromNote deletes an emoji from a note based on the emoji (awardable) ID and the note's ID */ -func (a *Api) deleteEmojiFromNote(w http.ResponseWriter, r *http.Request) { +func (a emojiService) deleteEmojiFromNote(w http.ResponseWriter, r *http.Request) { suffix := strings.TrimPrefix(r.URL.Path, "/mr/awardable/note/") ids := strings.Split(suffix, "/") @@ -187,7 +104,7 @@ func (a *Api) deleteEmojiFromNote(w http.ResponseWriter, r *http.Request) { } /* postEmojiOnNote adds an emojis to a note based on the note's ID */ -func (a *Api) postEmojiOnNote(w http.ResponseWriter, r *http.Request) { +func (a emojiService) postEmojiOnNote(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { handleError(w, err, "Could not read request body", http.StatusBadRequest) @@ -232,3 +149,38 @@ func (a *Api) postEmojiOnNote(w http.ResponseWriter, r *http.Request) { handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } + +/* +attachEmojis reads the emojis from our external JSON file +and attaches them to the data so that they can be looked up later +*/ +func attachEmojis(a *data, fr FileReader) error { + + e, err := os.Executable() + if err != nil { + return err + } + + binPath := path.Dir(e) + filePath := fmt.Sprintf("%s/config/emojis.json", binPath) + + reader, err := fr.ReadFile(filePath) + + if err != nil { + return fmt.Errorf("Could not find emojis at %s", filePath) + } + + bytes, err := io.ReadAll(reader) + if err != nil { + return errors.New("Could not read emoji file") + } + + var emojiMap EmojiMap + err = json.Unmarshal(bytes, &emojiMap) + if err != nil { + return errors.New("Could not unmarshal emojis") + } + + a.emojiMap = emojiMap + return nil +} diff --git a/cmd/git.go b/cmd/app/git/git.go similarity index 58% rename from cmd/git.go rename to cmd/app/git/git.go index 5129bd6e..2398d99f 100644 --- a/cmd/git.go +++ b/cmd/app/git/git.go @@ -1,4 +1,4 @@ -package main +package git import ( "fmt" @@ -7,19 +7,27 @@ import ( "strings" ) -type GitProjectInfo struct { - RemoteUrl string - Namespace string - ProjectName string - BranchName string - GetLatestCommitOnRemote func(a *Api) (string, error) +type GitManager interface { + RefreshProjectInfo(remote string) error + GetProjectUrlFromNativeGitCmd(remote string) (url string, err error) + GetCurrentBranchNameFromNativeGitCmd() (string, error) + GetLatestCommitOnRemote(remote string, branchName string) (string, error) } +type GitData struct { + RemoteUrl string + Namespace string + ProjectName string + BranchName string +} + +type Git struct{} + /* projectPath returns the Gitlab project full path, which isn't necessarily the same as its name. See https://docs.gitlab.com/ee/api/rest/index.html#namespaced-path-encoding for more information. */ -func (g GitProjectInfo) projectPath() string { +func (g GitData) ProjectPath() string { return g.Namespace + "/" + g.ProjectName } @@ -28,15 +36,15 @@ Extracts information about the current repository and returns it to the client for initialization. The current directory must be a valid Gitlab project and the branch must be a feature branch */ -func extractGitInfo(refreshGitInfo func() error, getProjectRemoteUrl func() (string, error), getCurrentBranchName func() (string, error)) (GitProjectInfo, error) { - err := refreshGitInfo() +func NewGitData(remote string, g GitManager) (GitData, error) { + err := g.RefreshProjectInfo(remote) if err != nil { - return GitProjectInfo{}, fmt.Errorf("Could not get latest information from remote: %v", err) + return GitData{}, fmt.Errorf("Could not get latest information from remote: %v", err) } - url, err := getProjectRemoteUrl() + url, err := g.GetProjectUrlFromNativeGitCmd(remote) if err != nil { - return GitProjectInfo{}, fmt.Errorf("Could not get project Url: %v", err) + return GitData{}, fmt.Errorf("Could not get project Url: %v", err) } /* @@ -54,18 +62,18 @@ func extractGitInfo(refreshGitInfo func() error, getProjectRemoteUrl func() (str re := regexp.MustCompile(`(?:^https?:\/\/|^ssh:\/\/|^git@)(?:[^\/:]+)(?::\d+)?[\/:](.*)\/([^\/]+?)(?:\.git)?$`) matches := re.FindStringSubmatch(url) if len(matches) != 3 { - return GitProjectInfo{}, fmt.Errorf("Invalid Git URL format: %s", url) + return GitData{}, fmt.Errorf("Invalid Git URL format: %s", url) } namespace := matches[1] projectName := matches[2] - branchName, err := getCurrentBranchName() + branchName, err := g.GetCurrentBranchNameFromNativeGitCmd() if err != nil { - return GitProjectInfo{}, fmt.Errorf("Failed to get current branch: %v", err) + return GitData{}, fmt.Errorf("Failed to get current branch: %v", err) } - return GitProjectInfo{ + return GitData{ RemoteUrl: url, Namespace: namespace, ProjectName: projectName, @@ -75,7 +83,7 @@ func extractGitInfo(refreshGitInfo func() error, getProjectRemoteUrl func() (str } /* Gets the current branch name */ -func GetCurrentBranchNameFromNativeGitCmd() (res string, e error) { +func (g Git) GetCurrentBranchNameFromNativeGitCmd() (res string, e error) { gitCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") output, err := gitCmd.Output() @@ -89,8 +97,8 @@ func GetCurrentBranchNameFromNativeGitCmd() (res string, e error) { } /* Gets the project SSH or HTTPS url */ -func GetProjectUrlFromNativeGitCmd() (string, error) { - cmd := exec.Command("git", "remote", "get-url", pluginOptions.ConnectionSettings.Remote) +func (g Git) GetProjectUrlFromNativeGitCmd(remote string) (string, error) { + cmd := exec.Command("git", "remote", "get-url", remote) url, err := cmd.Output() if err != nil { return "", fmt.Errorf("Could not get remote") @@ -100,25 +108,22 @@ func GetProjectUrlFromNativeGitCmd() (string, error) { } /* Pulls down latest commit information from Gitlab */ -func RefreshProjectInfo() error { - cmd := exec.Command("git", "fetch", pluginOptions.ConnectionSettings.Remote) +func (g Git) RefreshProjectInfo(remote string) error { + cmd := exec.Command("git", "fetch", remote) _, err := cmd.Output() if err != nil { - return fmt.Errorf("Failed to run `git fetch %s`: %v", pluginOptions.ConnectionSettings.Remote, err) + return fmt.Errorf("Failed to run `git fetch %s`: %v", remote, err) } return nil } -/* -The GetLatestCommitOnRemote function is attached during the CreateRouterAndApi call, since it needs to be called every time to get the latest commit. -*/ -func GetLatestCommitOnRemote(a *Api) (string, error) { - cmd := exec.Command("git", "log", "-1", "--format=%H", fmt.Sprintf("%s/%s", pluginOptions.ConnectionSettings.Remote, a.gitInfo.BranchName)) +func (g Git) GetLatestCommitOnRemote(remote string, branchName string) (string, error) { + cmd := exec.Command("git", "log", "-1", "--format=%H", fmt.Sprintf("%s/%s", remote, branchName)) out, err := cmd.Output() if err != nil { - return "", fmt.Errorf("Failed to run `git log -1 --format=%%H " + fmt.Sprintf("%s/%s", pluginOptions.ConnectionSettings.Remote, a.gitInfo.BranchName)) + return "", fmt.Errorf("Failed to run `git log -1 --format=%%H " + fmt.Sprintf("%s/%s", remote, branchName)) } commit := strings.TrimSpace(string(out)) diff --git a/cmd/app/git/git_test.go b/cmd/app/git/git_test.go new file mode 100644 index 00000000..60b496d8 --- /dev/null +++ b/cmd/app/git/git_test.go @@ -0,0 +1,219 @@ +package git + +import ( + "errors" + "testing" +) + +type FakeGitManager struct { + RemoteUrl string + BranchName string + ProjectName string + Namespace string +} + +func (f FakeGitManager) RefreshProjectInfo(remote string) error { + return nil +} + +func (f FakeGitManager) GetCurrentBranchNameFromNativeGitCmd() (string, error) { + return f.BranchName, nil +} + +func (f FakeGitManager) GetLatestCommitOnRemote(remote string, branchName string) (string, error) { + return "", nil +} + +func (f FakeGitManager) GetProjectUrlFromNativeGitCmd(string) (url string, err error) { + return f.RemoteUrl, nil +} + +type TestCase struct { + desc string + branch string + projectName string + namespace string + remote string +} + +func TestExtractGitInfo_Success(t *testing.T) { + testCases := []TestCase{ + { + desc: "Project configured in SSH under a single folder", + remote: "git@custom-gitlab.com:namespace-1/project-name.git", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1", + }, + { + desc: "Project configured in SSH under a single folder without .git extension", + remote: "git@custom-gitlab.com:namespace-1/project-name", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1", + }, + { + desc: "Project configured in SSH under one nested folder", + remote: "git@custom-gitlab.com:namespace-1/namespace-2/project-name.git", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1/namespace-2", + }, + { + desc: "Project configured in SSH under two nested folders", + remote: "git@custom-gitlab.com:namespace-1/namespace-2/namespace-3/project-name.git", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1/namespace-2/namespace-3", + }, + { + desc: "Project configured in SSH:// under a single folder", + remote: "ssh://custom-gitlab.com/namespace-1/project-name.git", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1", + }, + { + desc: "Project configured in SSH:// under a single folder without .git extension", + remote: "ssh://custom-gitlab.com/namespace-1/project-name", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1", + }, + { + desc: "Project configured in SSH:// under two nested folders", + remote: "ssh://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1/namespace-2/namespace-3", + }, + { + desc: "Project configured in SSH:// and have a custom port", + remote: "ssh://custom-gitlab.com:2222/namespace-1/project-name", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1", + }, + { + desc: "Project configured in HTTP and under a single folder without .git extension", + remote: "http://custom-gitlab.com/namespace-1/project-name", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1", + }, + { + desc: "Project configured in HTTPS and under a single folder", + remote: "https://custom-gitlab.com/namespace-1/project-name.git", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1", + }, + { + desc: "Project configured in HTTPS and under a nested folder", + remote: "https://custom-gitlab.com/namespace-1/namespace-2/project-name.git", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1/namespace-2", + }, + { + desc: "Project configured in HTTPS and under two nested folders", + remote: "https://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1/namespace-2/namespace-3", + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + g := FakeGitManager{ + Namespace: tC.namespace, + ProjectName: tC.projectName, + BranchName: tC.branch, + RemoteUrl: tC.remote, + } + data, err := NewGitData(tC.remote, g) + if err != nil { + t.Errorf("No error was expected, got %s", err) + } + if data.RemoteUrl != tC.remote { + t.Errorf("\nExpected Remote URL: %s\nActual: %s", tC.remote, data.RemoteUrl) + } + if data.BranchName != tC.branch { + t.Errorf("\nExpected Branch Name: %s\nActual: %s", tC.branch, data.BranchName) + } + if data.ProjectName != tC.projectName { + t.Errorf("\nExpected Project Name: %s\nActual: %s", tC.projectName, data.ProjectName) + } + if data.Namespace != tC.namespace { + t.Errorf("\nExpected Namespace: %s\nActual: %s", tC.namespace, data.Namespace) + } + }) + } +} + +type FailTestCase struct { + desc string + errMsg string + expectedErr string +} + +type failingUrlManager struct { + errMsg string + FakeGitManager +} + +func (f failingUrlManager) GetProjectUrlFromNativeGitCmd(string) (string, error) { + return "", errors.New(f.errMsg) +} + +func TestExtractGitInfo_FailToGetProjectRemoteUrl(t *testing.T) { + tC := FailTestCase{ + desc: "Error returned by function to get the project remote url", + errMsg: "Some error", + expectedErr: "Could not get project Url: Some error", + } + t.Run(tC.desc, func(t *testing.T) { + g := failingUrlManager{ + errMsg: tC.errMsg, + } + _, err := NewGitData("", g) + if err == nil { + t.Errorf("Expected an error, got none") + } + if err.Error() != tC.expectedErr { + t.Errorf("\nExpected: %s\nActual: %s", tC.expectedErr, err.Error()) + } + }) +} + +type failingBranchManager struct { + errMsg string + FakeGitManager +} + +func (f failingBranchManager) GetCurrentBranchNameFromNativeGitCmd() (string, error) { + return "", errors.New(f.errMsg) +} + +func TestExtractGitInfo_FailToGetCurrentBranchName(t *testing.T) { + tC := FailTestCase{ + desc: "Error returned by function to get the project remote url", + errMsg: "Some error", + expectedErr: "Failed to get current branch: Some error", + } + t.Run(tC.desc, func(t *testing.T) { + g := failingBranchManager{ + FakeGitManager: FakeGitManager{ + RemoteUrl: "git@custom-gitlab.com:namespace-1/project-name.git", + }, + errMsg: tC.errMsg, + } + _, err := NewGitData("", g) + if err == nil { + t.Errorf("Expected an error, got none") + } + if err.Error() != tC.expectedErr { + t.Errorf("\nExpected: %s\nActual: %s", tC.expectedErr, err.Error()) + } + }) +} diff --git a/cmd/info.go b/cmd/app/info.go similarity index 77% rename from cmd/info.go rename to cmd/app/info.go index 91b520c7..8838efdc 100644 --- a/cmd/info.go +++ b/cmd/app/info.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -12,8 +12,17 @@ type InfoResponse struct { Info *gitlab.MergeRequest `json:"info"` } +type MergeRequestGetter interface { + GetMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) +} + +type infoService struct { + data + client MergeRequestGetter +} + /* infoHandler fetches infomation about the current git project. The data returned here is used in many other API calls */ -func (a *Api) infoHandler(w http.ResponseWriter, r *http.Request) { +func (a infoService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodGet { w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) diff --git a/cmd/app/info_test.go b/cmd/app/info_test.go new file mode 100644 index 00000000..e2253b50 --- /dev/null +++ b/cmd/app/info_test.go @@ -0,0 +1,49 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeMergeRequestGetter struct { + testBase +} + +func (f fakeMergeRequestGetter) GetMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + + return &gitlab.MergeRequest{}, resp, err +} + +func TestInfoHandler(t *testing.T) { + t.Run("Returns normal information", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/info", nil) + svc := infoService{testProjectData, fakeMergeRequestGetter{}} + data := getSuccessData(t, svc, request) + assert(t, data.Message, "Merge requests retrieved") + assert(t, data.Status, http.StatusOK) + }) + t.Run("Disallows non-GET methods", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/info", nil) + svc := infoService{testProjectData, fakeMergeRequestGetter{}} + data := getFailData(t, svc, request) + checkBadMethod(t, data, http.MethodGet) + }) + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/info", nil) + svc := infoService{testProjectData, fakeMergeRequestGetter{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not get project info") + }) + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/info", nil) + svc := infoService{testProjectData, fakeMergeRequestGetter{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not get project info", "/mr/info") + }) +} diff --git a/cmd/job.go b/cmd/app/job.go similarity index 82% rename from cmd/job.go rename to cmd/app/job.go index 993be73e..4fc7abd9 100644 --- a/cmd/job.go +++ b/cmd/app/job.go @@ -1,9 +1,12 @@ -package main +package app import ( + "bytes" "encoding/json" "io" "net/http" + + "github.com/xanzy/go-gitlab" ) type JobTraceRequest struct { @@ -15,8 +18,17 @@ type JobTraceResponse struct { File string `json:"file"` } +type TraceFileGetter interface { + GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) +} + +type traceFileService struct { + data + client TraceFileGetter +} + /* jobHandler returns a string that shows the output of a specific job run in a Gitlab pipeline */ -func (a *Api) jobHandler(w http.ResponseWriter, r *http.Request) { +func (a traceFileService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodGet { w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) diff --git a/cmd/app/job_test.go b/cmd/app/job_test.go new file mode 100644 index 00000000..37073707 --- /dev/null +++ b/cmd/app/job_test.go @@ -0,0 +1,71 @@ +package app + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeTraceFileGetter struct { + testBase +} + +func getTraceFileData(t *testing.T, svc ServiceWithHandler, request *http.Request) JobTraceResponse { + res := httptest.NewRecorder() + svc.handler(res, request) + + var data JobTraceResponse + err := json.Unmarshal(res.Body.Bytes(), &data) + if err != nil { + t.Error(err) + } + return data +} + +func (f fakeTraceFileGetter) GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + re := bytes.NewReader([]byte("Some data")) + return re, resp, err +} + +// var jobId = 0 +func TestJobHandler(t *testing.T) { + t.Run("Should read a job trace file", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) + client := fakeTraceFileGetter{} + svc := traceFileService{testProjectData, client} + data := getTraceFileData(t, svc, request) + assert(t, data.Message, "Log file read") + assert(t, data.Status, http.StatusOK) + assert(t, data.File, "Some data") + }) + t.Run("Disallows non-GET methods", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/job", JobTraceRequest{}) + client := fakeTraceFileGetter{} + svc := traceFileService{testProjectData, client} + data := getFailData(t, svc, request) + checkBadMethod(t, data, http.MethodGet) + }) + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) + client := fakeTraceFileGetter{testBase{errFromGitlab: true}} + svc := traceFileService{testProjectData, client} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not get trace file for job") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) + client := fakeTraceFileGetter{testBase{status: http.StatusSeeOther}} + svc := traceFileService{testProjectData, client} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not get trace file for job", "/job") + }) +} diff --git a/cmd/label.go b/cmd/app/label.go similarity index 83% rename from cmd/label.go rename to cmd/app/label.go index 2157fc7e..af09088e 100644 --- a/cmd/label.go +++ b/cmd/app/label.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -28,8 +28,18 @@ type LabelsRequestResponse struct { Labels []Label `json:"labels"` } +type LabelManager interface { + UpdateMergeRequest(interface{}, int, *gitlab.UpdateMergeRequestOptions, ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) + ListLabels(interface{}, *gitlab.ListLabelsOptions, ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) +} + +type labelService struct { + data + client LabelManager +} + /* labelsHandler adds or removes labels from a merge request, and returns all labels for the current project */ -func (a *Api) labelHandler(w http.ResponseWriter, r *http.Request) { +func (a labelService) handler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: a.getLabels(w, r) @@ -42,7 +52,7 @@ func (a *Api) labelHandler(w http.ResponseWriter, r *http.Request) { } } -func (a *Api) getLabels(w http.ResponseWriter, r *http.Request) { +func (a labelService) getLabels(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") labels, res, err := a.client.ListLabels(a.projectInfo.ProjectId, &gitlab.ListLabelsOptions{}) @@ -82,7 +92,7 @@ func (a *Api) getLabels(w http.ResponseWriter, r *http.Request) { } -func (a *Api) updateLabels(w http.ResponseWriter, r *http.Request) { +func (a labelService) updateLabels(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") body, err := io.ReadAll(r.Body) if err != nil { diff --git a/cmd/list_discussions.go b/cmd/app/list_discussions.go similarity index 64% rename from cmd/list_discussions.go rename to cmd/app/list_discussions.go index 4cc464fa..c2a47b7d 100644 --- a/cmd/list_discussions.go +++ b/cmd/app/list_discussions.go @@ -1,15 +1,25 @@ -package main +package app import ( "io" "net/http" "sort" + "sync" "encoding/json" "github.com/xanzy/go-gitlab" ) +func Contains[T comparable](elems []T, v T) bool { + for _, s := range elems { + if v == s { + return true + } + } + return false +} + type DiscussionsRequest struct { Blacklist []string `json:"blacklist"` } @@ -37,11 +47,21 @@ func (n SortableDiscussions) Swap(i, j int) { n[i], n[j] = n[j], n[i] } +type DiscussionsLister interface { + ListMergeRequestDiscussions(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) + ListMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) +} + +type discussionsListerService struct { + data + client DiscussionsLister +} + /* listDiscussionsHandler lists all discusions for a given merge request, both those linked and unlinked to particular points in the code. The responses are sorted by date created, and blacklisted users are not included */ -func (a *Api) listDiscussionsHandler(w http.ResponseWriter, r *http.Request) { +func (a discussionsListerService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) @@ -86,7 +106,7 @@ func (a *Api) listDiscussionsHandler(w http.ResponseWriter, r *http.Request) { var linkedDiscussions []*gitlab.Discussion for _, discussion := range discussions { - if discussion.Notes == nil || len(discussion.Notes) == 0 || Contains(requestBody.Blacklist, discussion.Notes[0].Author.Username) > -1 { + if discussion.Notes == nil || len(discussion.Notes) == 0 || Contains(requestBody.Blacklist, discussion.Notes[0].Author.Username) { continue } for _, note := range discussion.Notes { @@ -136,3 +156,60 @@ func (a *Api) listDiscussionsHandler(w http.ResponseWriter, r *http.Request) { handleError(w, err, "Could not encode response", http.StatusInternalServerError) } } + +/* +Fetches emojis for a set of notes and comments in parallel and returns a map of note IDs to their emojis. +Gitlab's API does not allow for fetching notes for an entire discussion thread so we have to do it per-note. +*/ +func (a discussionsListerService) fetchEmojisForNotesAndComments(noteIDs []int) (map[int][]*gitlab.AwardEmoji, error) { + var wg sync.WaitGroup + + emojis := make(map[int][]*gitlab.AwardEmoji) + mu := &sync.Mutex{} + errs := make(chan error, len(noteIDs)) + emojiChan := make(chan struct { + noteID int + emojis []*gitlab.AwardEmoji + }, len(noteIDs)) + + for _, noteID := range noteIDs { + wg.Add(1) + go func(noteID int) { + defer wg.Done() + emojis, _, err := a.client.ListMergeRequestAwardEmojiOnNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, noteID, &gitlab.ListAwardEmojiOptions{}) + if err != nil { + errs <- err + return + } + emojiChan <- struct { + noteID int + emojis []*gitlab.AwardEmoji + }{noteID, emojis} + }(noteID) + } + + /* Close the channels when all goroutines finish */ + go func() { + wg.Wait() + close(errs) + close(emojiChan) + }() + + /* Collect emojis */ + for e := range emojiChan { + mu.Lock() + emojis[e.noteID] = e.emojis + mu.Unlock() + } + + /* Check if any errors occurred */ + if len(errs) > 0 { + for err := range errs { + if err != nil { + return nil, err + } + } + } + + return emojis, nil +} diff --git a/cmd/app/list_discussions_test.go b/cmd/app/list_discussions_test.go new file mode 100644 index 00000000..ee4e0020 --- /dev/null +++ b/cmd/app/list_discussions_test.go @@ -0,0 +1,113 @@ +package app + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/xanzy/go-gitlab" +) + +type fakeDiscussionsLister struct { + testBase + badEmojiResponse bool +} + +func (f fakeDiscussionsLister) ListMergeRequestDiscussions(pid interface{}, mergeRequest int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + now := time.Now() + newer := now.Add(time.Second * 100) + + type Author struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Name string `json:"name"` + State string `json:"state"` + AvatarURL string `json:"avatar_url"` + WebURL string `json:"web_url"` + } + + testListDiscussionsResponse := []*gitlab.Discussion{ + {Notes: []*gitlab.Note{{CreatedAt: &now, Type: "DiffNote", Author: Author{Username: "hcramer"}}}}, + {Notes: []*gitlab.Note{{CreatedAt: &newer, Type: "DiffNote", Author: Author{Username: "hcramer2"}}}}, + } + return testListDiscussionsResponse, resp, err +} + +func (f fakeDiscussionsLister) ListMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + + if f.badEmojiResponse { + return nil, nil, errors.New("Some error from emoji service") + } + return []*gitlab.AwardEmoji{}, resp, err +} + +func getDiscussionsList(t *testing.T, svc ServiceWithHandler, request *http.Request) DiscussionsResponse { + res := httptest.NewRecorder() + svc.handler(res, request) + + var data DiscussionsResponse + err := json.Unmarshal(res.Body.Bytes(), &data) + if err != nil { + t.Error(err) + } + return data +} + +func TestListDiscussions(t *testing.T) { + t.Run("Returns sorted discussions", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) + svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}} + data := getDiscussionsList(t, svc, request) + assert(t, data.Message, "Discussions retrieved") + assert(t, data.SuccessResponse.Status, http.StatusOK) + assert(t, data.Discussions[0].Notes[0].Author.Username, "hcramer2") /* Sorting applied */ + assert(t, data.Discussions[1].Notes[0].Author.Username, "hcramer") + }) + + t.Run("Uses blacklist to filter unwanted authors", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{"hcramer"}}) + svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}} + data := getDiscussionsList(t, svc, request) + assert(t, data.SuccessResponse.Message, "Discussions retrieved") + assert(t, data.SuccessResponse.Status, http.StatusOK) + assert(t, len(data.Discussions), 1) + assert(t, data.Discussions[0].Notes[0].Author.Username, "hcramer2") + }) + t.Run("Disallows non-GET methods", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/discussions/list", DiscussionsRequest{}) + svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}} + data := getFailData(t, svc, request) + checkBadMethod(t, data, http.MethodPost) + }) + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) + svc := discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not list discussions") + }) + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) + svc := discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not list discussions", "/mr/discussions/list") + }) + t.Run("Handles error from emoji service", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) + svc := discussionsListerService{testProjectData, fakeDiscussionsLister{badEmojiResponse: true}} + data := getFailData(t, svc, request) + assert(t, data.Message, "Could not fetch emojis") + assert(t, data.Details, "Some error from emoji service") + }) +} diff --git a/cmd/members.go b/cmd/app/members.go similarity index 78% rename from cmd/members.go rename to cmd/app/members.go index eedce018..9002ac0b 100644 --- a/cmd/members.go +++ b/cmd/app/members.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -12,8 +12,17 @@ type ProjectMembersResponse struct { ProjectMembers []*gitlab.ProjectMember } +type ProjectMemberLister interface { + ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) +} + +type projectMemberService struct { + data + client ProjectMemberLister +} + /* projectMembersHandler returns all members of the current Gitlab project */ -func (a *Api) projectMembersHandler(w http.ResponseWriter, r *http.Request) { +func (a projectMemberService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodGet { w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) diff --git a/cmd/app/members_test.go b/cmd/app/members_test.go new file mode 100644 index 00000000..cdf10efe --- /dev/null +++ b/cmd/app/members_test.go @@ -0,0 +1,48 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeMemberLister struct { + testBase +} + +func (f fakeMemberLister) ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + return []*gitlab.ProjectMember{}, resp, err +} + +func TestMembersHandler(t *testing.T) { + t.Run("Returns project members", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/project/members", nil) + svc := projectMemberService{testProjectData, fakeMemberLister{}} + data := getSuccessData(t, svc, request) + assert(t, data.Status, http.StatusOK) + assert(t, data.Message, "Project members retrieved") + }) + t.Run("Disallows non-GET methods", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/project/members", nil) + svc := projectMemberService{testProjectData, fakeMemberLister{}} + data := getFailData(t, svc, request) + checkBadMethod(t, data, http.MethodGet) + }) + t.Run("Handles error from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/project/members", nil) + svc := projectMemberService{testProjectData, fakeMemberLister{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not retrieve project members") + }) + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/project/members", nil) + svc := projectMemberService{testProjectData, fakeMemberLister{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not retrieve project members", "/project/members") + }) +} diff --git a/cmd/merge.go b/cmd/app/merge_mr.go similarity index 82% rename from cmd/merge.go rename to cmd/app/merge_mr.go index 0e679601..89245cb4 100644 --- a/cmd/merge.go +++ b/cmd/app/merge_mr.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -14,8 +14,17 @@ type AcceptMergeRequestRequest struct { DeleteBranch bool `json:"delete_branch"` } +type MergeRequestAccepter interface { + AcceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) +} + +type mergeRequestAccepterService struct { + data + client MergeRequestAccepter +} + /* acceptAndMergeHandler merges a given merge request into the target branch */ -func (a *Api) acceptAndMergeHandler(w http.ResponseWriter, r *http.Request) { +func (a mergeRequestAccepterService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) if r.Method != http.MethodPost { diff --git a/cmd/app/merge_mr_test.go b/cmd/app/merge_mr_test.go new file mode 100644 index 00000000..1adabba4 --- /dev/null +++ b/cmd/app/merge_mr_test.go @@ -0,0 +1,50 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeMergeRequestAccepter struct { + testBase +} + +func (f fakeMergeRequestAccepter) AcceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + + return &gitlab.MergeRequest{}, resp, err +} + +func TestAcceptAndMergeHandler(t *testing.T) { + var testAcceptMergeRequestPayload = AcceptMergeRequestRequest{Squash: false, SquashMessage: "Squash me!", DeleteBranch: false} + t.Run("Accepts and merges a merge request", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) + svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{}} + data := getSuccessData(t, svc, request) + assert(t, data.Message, "MR merged successfully") + assert(t, data.Status, http.StatusOK) + }) + t.Run("Disallows non-POST methods", func(t *testing.T) { + request := makeRequest(t, http.MethodPut, "/mr/merge", testAcceptMergeRequestPayload) + svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{}} + data := getFailData(t, svc, request) + checkBadMethod(t, data, http.MethodPost) + }) + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) + svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not merge MR") + }) + t.Run("Handles non-200s from Gitlab", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) + svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not merge MR", "/mr/merge") + }) +} diff --git a/cmd/merge_requests.go b/cmd/app/merge_requests.go similarity index 80% rename from cmd/merge_requests.go rename to cmd/app/merge_requests.go index d8136e2c..0e1af5c1 100644 --- a/cmd/merge_requests.go +++ b/cmd/app/merge_requests.go @@ -1,9 +1,8 @@ -package main +package app import ( "encoding/json" "errors" - "fmt" "io" "net/http" @@ -20,7 +19,16 @@ type ListMergeRequestResponse struct { MergeRequests []*gitlab.MergeRequest `json:"merge_requests"` } -func (a *Api) mergeRequestsHandler(w http.ResponseWriter, r *http.Request) { +type MergeRequestLister interface { + ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) +} + +type mergeRequestListerService struct { + data + client MergeRequestLister +} + +func (a mergeRequestListerService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) @@ -52,7 +60,7 @@ func (a *Api) mergeRequestsHandler(w http.ResponseWriter, r *http.Request) { mergeRequests, res, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, &options) if err != nil { - handleError(w, fmt.Errorf("Failed to list merge requests: %w", err), "Failed to list merge requests", http.StatusInternalServerError) + handleError(w, err, "Failed to list merge requests", http.StatusInternalServerError) return } diff --git a/cmd/app/merge_requests_test.go b/cmd/app/merge_requests_test.go new file mode 100644 index 00000000..1439da98 --- /dev/null +++ b/cmd/app/merge_requests_test.go @@ -0,0 +1,58 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeMergeRequestLister struct { + testBase + emptyResponse bool +} + +func (f fakeMergeRequestLister) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + + if f.emptyResponse { + return []*gitlab.MergeRequest{}, resp, err + } + + return []*gitlab.MergeRequest{{IID: 10}}, resp, err +} + +func TestMergeRequestHandler(t *testing.T) { + var testListMergeRequestsRequest = ListMergeRequestRequest{Label: []string{}, NotLabel: []string{}} + t.Run("Should fetch merge requests", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) + svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{}} + data := getSuccessData(t, svc, request) + assert(t, data.Status, http.StatusOK) + assert(t, data.Message, "Merge requests fetched successfully") + }) + t.Run("Handles error from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) + svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Failed to list merge requests") + assert(t, data.Status, http.StatusInternalServerError) + }) + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) + svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Failed to list merge requests", "/merge_requests") + assert(t, data.Status, http.StatusSeeOther) + }) + t.Run("Should handle not having any merge requests with 404", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) + svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{emptyResponse: true}} + data := getFailData(t, svc, request) + assert(t, data.Message, "No merge requests found") + assert(t, data.Status, http.StatusNotFound) + }) +} diff --git a/cmd/pipeline.go b/cmd/app/pipeline.go similarity index 75% rename from cmd/pipeline.go rename to cmd/app/pipeline.go index f025a99a..587c1e0c 100644 --- a/cmd/pipeline.go +++ b/cmd/app/pipeline.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/harrisoncramer/gitlab.nvim/cmd/app/git" "github.com/xanzy/go-gitlab" ) @@ -26,11 +27,23 @@ type GetPipelineAndJobsResponse struct { Pipeline PipelineWithJobs `json:"latest_pipeline"` } +type PipelineManager interface { + ListProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) + ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) + RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) +} + +type pipelineService struct { + data + client PipelineManager + gitService git.GitManager +} + /* pipelineHandler fetches information about the current pipeline, and retriggers a pipeline run. For more detailed information about a given job in a pipeline, see the jobHandler function */ -func (a *Api) pipelineHandler(w http.ResponseWriter, r *http.Request) { +func (a pipelineService) handler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: a.GetPipelineAndJobs(w, r) @@ -44,7 +57,7 @@ func (a *Api) pipelineHandler(w http.ResponseWriter, r *http.Request) { } /* Gets the latest pipeline for a given commit, returns an error if there is no pipeline */ -func (a *Api) GetLastPipeline(commit string) (*gitlab.PipelineInfo, error) { +func (a pipelineService) GetLastPipeline(commit string) (*gitlab.PipelineInfo, error) { l := &gitlab.ListProjectPipelinesOptions{ SHA: gitlab.Ptr(commit), @@ -69,10 +82,10 @@ func (a *Api) GetLastPipeline(commit string) (*gitlab.PipelineInfo, error) { } /* Gets the latest pipeline and job information for the current branch */ -func (a *Api) GetPipelineAndJobs(w http.ResponseWriter, r *http.Request) { +func (a pipelineService) GetPipelineAndJobs(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - commit, err := a.gitInfo.GetLatestCommitOnRemote(a) + commit, err := a.gitService.GetLatestCommitOnRemote(pluginOptions.ConnectionSettings.Remote, a.gitInfo.BranchName) if err != nil { handleError(w, err, "Error getting commit on remote branch", http.StatusInternalServerError) @@ -82,7 +95,7 @@ func (a *Api) GetPipelineAndJobs(w http.ResponseWriter, r *http.Request) { pipeline, err := a.GetLastPipeline(commit) if err != nil { - handleError(w, err, fmt.Sprintf("Gitlab failed to get latest pipeline for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError) + handleError(w, err, fmt.Sprintf("Failed to get latest pipeline for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError) return } @@ -121,7 +134,7 @@ func (a *Api) GetPipelineAndJobs(w http.ResponseWriter, r *http.Request) { } } -func (a *Api) RetriggerPipeline(w http.ResponseWriter, r *http.Request) { +func (a pipelineService) RetriggerPipeline(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") id := strings.TrimPrefix(r.URL.Path, "/pipeline/trigger/") diff --git a/cmd/app/pipeline_test.go b/cmd/app/pipeline_test.go new file mode 100644 index 00000000..d8fcecb9 --- /dev/null +++ b/cmd/app/pipeline_test.go @@ -0,0 +1,86 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakePipelineManager struct { + testBase +} + +func (f fakePipelineManager) ListProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + return []*gitlab.PipelineInfo{{ID: 1234}}, resp, err +} + +func (f fakePipelineManager) ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + return []*gitlab.Job{}, resp, err +} + +func (f fakePipelineManager) RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + return &gitlab.Pipeline{}, resp, err +} + +func TestPipelineGetter(t *testing.T) { + t.Run("Gets all pipeline jobs", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/pipeline", nil) + svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}} + data := getSuccessData(t, svc, request) + assert(t, data.Message, "Pipeline retrieved") + assert(t, data.Status, http.StatusOK) + }) + t.Run("Disallows non-GET, non-POST methods", func(t *testing.T) { + request := makeRequest(t, http.MethodPatch, "/pipeline", nil) + svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}} + data := getFailData(t, svc, request) + checkBadMethod(t, data, http.MethodGet, http.MethodPost) + }) + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/pipeline", nil) + svc := pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Failed to get latest pipeline for some-branch branch") + }) + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/pipeline", nil) + svc := pipelineService{testProjectData, fakePipelineManager{testBase: testBase{status: http.StatusSeeOther}}, FakeGitManager{}} + data := getFailData(t, svc, request) + assert(t, data.Message, "Failed to get latest pipeline for some-branch branch") // Expected, we treat this as an error + }) +} + +func TestPipelineTrigger(t *testing.T) { + t.Run("Retriggers pipeline", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil) + svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}} + data := getSuccessData(t, svc, request) + assert(t, data.Message, "Pipeline retriggered") + assert(t, data.Status, http.StatusOK) + }) + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil) + svc := pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not retrigger pipeline") + }) + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil) + svc := pipelineService{testProjectData, fakePipelineManager{testBase: testBase{status: http.StatusSeeOther}}, FakeGitManager{}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not retrigger pipeline", "/pipeline") + }) +} diff --git a/cmd/reply.go b/cmd/app/reply.go similarity index 84% rename from cmd/reply.go rename to cmd/app/reply.go index 1b1fe54d..eda9c80a 100644 --- a/cmd/reply.go +++ b/cmd/app/reply.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -20,8 +20,17 @@ type ReplyResponse struct { Note *gitlab.Note `json:"note"` } +type ReplyManager interface { + AddMergeRequestDiscussionNote(interface{}, int, string, *gitlab.AddMergeRequestDiscussionNoteOptions, ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) +} + +type replyService struct { + data + client ReplyManager +} + /* replyHandler sends a reply to a note or comment */ -func (a *Api) replyHandler(w http.ResponseWriter, r *http.Request) { +func (a replyService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) diff --git a/cmd/app/reply_test.go b/cmd/app/reply_test.go new file mode 100644 index 00000000..a57c2716 --- /dev/null +++ b/cmd/app/reply_test.go @@ -0,0 +1,45 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeReplyManager struct { + testBase +} + +func (f fakeReplyManager) AddMergeRequestDiscussionNote(interface{}, int, string, *gitlab.AddMergeRequestDiscussionNoteOptions, ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + + return &gitlab.Note{}, resp, err +} + +func TestReplyHandler(t *testing.T) { + var testReplyRequest = ReplyRequest{DiscussionId: "abc123", Reply: "Some Reply", IsDraft: false} + t.Run("Sends a reply", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) + svc := replyService{testProjectData, fakeReplyManager{}} + data := getSuccessData(t, svc, request) + assert(t, data.Message, "Replied to comment") + assert(t, data.Status, http.StatusOK) + }) + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) + svc := replyService{testProjectData, fakeReplyManager{testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not leave reply") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) + svc := replyService{testProjectData, fakeReplyManager{testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + checkNon200(t, data, "Could not leave reply", "/mr/reply") + }) +} diff --git a/cmd/resolve_discussion.go b/cmd/app/resolve_discussion.go similarity index 81% rename from cmd/resolve_discussion.go rename to cmd/app/resolve_discussion.go index f5efd7d3..89284574 100644 --- a/cmd/resolve_discussion.go +++ b/cmd/app/resolve_discussion.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -14,8 +14,17 @@ type DiscussionResolveRequest struct { Resolved bool `json:"resolved"` } +type DiscussionResolver interface { + ResolveMergeRequestDiscussion(pid interface{}, mergeRequest int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) +} + +type discussionsResolutionService struct { + data + client DiscussionResolver +} + /* discussionsResolveHandler sets a discussion to be "resolved" or not resolved, depending on the payload */ -func (a *Api) discussionsResolveHandler(w http.ResponseWriter, r *http.Request) { +func (a discussionsResolutionService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPut { w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) diff --git a/cmd/app/response_types.go b/cmd/app/response_types.go new file mode 100644 index 00000000..2fb3af04 --- /dev/null +++ b/cmd/app/response_types.go @@ -0,0 +1,30 @@ +package app + +import ( + "fmt" +) + +type ErrorResponse struct { + Message string `json:"message"` + Details string `json:"details"` + Status int `json:"status"` +} + +type SuccessResponse struct { + Message string `json:"message"` + Status int `json:"status"` +} + +type GenericError struct { + endpoint string +} + +func (e GenericError) Error() string { + return fmt.Sprintf("An error occurred on the %s endpoint", e.endpoint) +} + +type InvalidRequestError struct{} + +func (e InvalidRequestError) Error() string { + return "Invalid request type" +} diff --git a/cmd/reviewer.go b/cmd/app/reviewer.go similarity index 83% rename from cmd/reviewer.go rename to cmd/app/reviewer.go index b4790272..1ffd6a0e 100644 --- a/cmd/reviewer.go +++ b/cmd/app/reviewer.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -22,8 +22,17 @@ type ReviewersRequestResponse struct { Reviewers []int `json:"reviewers"` } +type MergeRequestUpdater interface { + UpdateMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) +} + +type reviewerService struct { + data + client MergeRequestUpdater +} + /* reviewersHandler adds or removes reviewers from an MR */ -func (a *Api) reviewersHandler(w http.ResponseWriter, r *http.Request) { +func (a reviewerService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPut { w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) diff --git a/cmd/revisions.go b/cmd/app/revisions.go similarity index 77% rename from cmd/revisions.go rename to cmd/app/revisions.go index f8814283..f423f656 100644 --- a/cmd/revisions.go +++ b/cmd/app/revisions.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -12,11 +12,20 @@ type RevisionsResponse struct { Revisions []*gitlab.MergeRequestDiffVersion } +type RevisionsGetter interface { + GetMergeRequestDiffVersions(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) +} + +type revisionsService struct { + data + client RevisionsGetter +} + /* revisionsHandler gets revision information about the current MR. This data is not used directly but is a precursor API call for other functionality */ -func (a *Api) revisionsHandler(w http.ResponseWriter, r *http.Request) { +func (a revisionsService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodGet { w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) diff --git a/cmd/revoke.go b/cmd/app/revoke.go similarity index 74% rename from cmd/revoke.go rename to cmd/app/revoke.go index c13168f6..c065b88f 100644 --- a/cmd/revoke.go +++ b/cmd/app/revoke.go @@ -1,12 +1,23 @@ -package main +package app import ( "encoding/json" "net/http" + + "github.com/xanzy/go-gitlab" ) +type MergeRequestRevoker interface { + UnapproveMergeRequest(interface{}, int, ...gitlab.RequestOptionFunc) (*gitlab.Response, error) +} + +type mergeRequestRevokerService struct { + data + client MergeRequestRevoker +} + /* revokeHandler revokes approval for the current merge request */ -func (a *Api) revokeHandler(w http.ResponseWriter, r *http.Request) { +func (a mergeRequestRevokerService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPost { w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) diff --git a/cmd/app/server.go b/cmd/app/server.go new file mode 100644 index 00000000..73df0c3c --- /dev/null +++ b/cmd/app/server.go @@ -0,0 +1,192 @@ +package app + +import ( + "errors" + "fmt" + "net" + "net/http" + "os" + "time" + + "github.com/harrisoncramer/gitlab.nvim/cmd/app/git" + "github.com/xanzy/go-gitlab" +) + +/* +startSever starts the server and runs concurrent goroutines +to handle potential shutdown requests and incoming HTTP requests. +*/ +func StartServer(client *Client, projectInfo *ProjectInfo, GitInfo git.GitData) { + + s := shutdown{ + sigCh: make(chan os.Signal, 1), + } + + fr := attachmentReader{} + r := CreateRouter( + client, + projectInfo, + s, + func(a *data) error { a.projectInfo = projectInfo; return nil }, + func(a *data) error { a.gitInfo = &GitInfo; return nil }, + func(a *data) error { err := attachEmojis(a, fr); return err }, + ) + l := createListener() + + server := &http.Server{Handler: r} + + /* Starts the Go server */ + go func() { + err := server.Serve(l) + if err != nil { + if errors.Is(err, http.ErrServerClosed) { + os.Exit(0) + } else { + fmt.Fprintf(os.Stderr, "Server did not respond: %s\n", err) + os.Exit(1) + } + } + }() + + port := l.Addr().(*net.TCPAddr).Port + err := checkServer(port) + if err != nil { + fmt.Fprintf(os.Stderr, "Server did not respond: %s\n", err) + os.Exit(1) + } + + /* This print is detected by the Lua code */ + fmt.Println("Server started on port: ", port) + + /* Handles shutdown requests */ + s.WatchForShutdown(server) +} + +/* +CreateRouterAndApi wires up the router and attaches all handlers to their respective routes. It also +iterates over all option functions to configure API fields such as the project information and default +file reader functionality +*/ + +type data struct { + projectInfo *ProjectInfo + gitInfo *git.GitData + emojiMap EmojiMap +} + +type optFunc func(a *data) error + +func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s ShutdownHandler, optFuncs ...optFunc) *http.ServeMux { + m := http.NewServeMux() + + d := data{ + projectInfo: &ProjectInfo{}, + gitInfo: &git.GitData{}, + } + + /* Mutates the API struct as necessary with configuration functions */ + for _, optFunc := range optFuncs { + err := optFunc(&d) + if err != nil { + panic(err) + } + } + + m.HandleFunc("/mr/approve", withMr(mergeRequestApproverService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/comment", withMr(commentService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/merge", withMr(mergeRequestAccepterService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/discussions/list", withMr(discussionsListerService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/discussions/resolve", withMr(discussionsResolutionService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/info", withMr(infoService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/assignee", withMr(assigneesService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/summary", withMr(summaryService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/reviewer", withMr(reviewerService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/revisions", withMr(revisionsService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/reply", withMr(replyService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/label", withMr(labelService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/revoke", withMr(mergeRequestRevokerService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/awardable/note/", withMr(emojiService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/draft_notes/", withMr(draftNoteService{d, gitlabClient}, d, gitlabClient)) + m.HandleFunc("/mr/draft_notes/publish", withMr(draftNotePublisherService{d, gitlabClient}, d, gitlabClient)) + + m.HandleFunc("/pipeline", pipelineService{d, gitlabClient, git.Git{}}.handler) + m.HandleFunc("/pipeline/trigger/", pipelineService{d, gitlabClient, git.Git{}}.handler) + m.HandleFunc("/users/me", meService{d, gitlabClient}.handler) + m.HandleFunc("/attachment", attachmentService{data: d, client: gitlabClient, fileReader: attachmentReader{}}.handler) + m.HandleFunc("/create_mr", mergeRequestCreatorService{d, gitlabClient}.handler) + m.HandleFunc("/job", traceFileService{d, gitlabClient}.handler) + m.HandleFunc("/project/members", projectMemberService{d, gitlabClient}.handler) + m.HandleFunc("/merge_requests", mergeRequestListerService{d, gitlabClient}.handler) + + m.HandleFunc("/shutdown", s.shutdownHandler) + m.Handle("/ping", http.HandlerFunc(pingHandler)) + + return m +} + +/* Used to check whether the server has started yet */ +func pingHandler(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "pong") +} + +/* checkServer pings the server repeatedly for 1 full second after startup in order to notify the plugin that the server is ready */ +func checkServer(port int) error { + for i := 0; i < 10; i++ { + resp, err := http.Get("http://localhost:" + fmt.Sprintf("%d", port) + "/ping") + if resp.StatusCode == 200 && err == nil { + return nil + } + time.Sleep(100 * time.Microsecond) + } + + return errors.New("Could not start server!") +} + +/* Creates a TCP listener on the port specified by the user or a random port */ +func createListener() (l net.Listener) { + addr := fmt.Sprintf("localhost:%d", pluginOptions.Port) + l, err := net.Listen("tcp", addr) + if err != nil { + fmt.Fprintf(os.Stderr, "Error starting server: %s\n", err) + os.Exit(1) + } + + return l +} + +type ServiceWithHandler interface { + handler(http.ResponseWriter, *http.Request) +} + +/* withMr is a Middlware that gets the current merge request ID and attaches it to the projectInfo */ +func withMr(svc ServiceWithHandler, c data, client MergeRequestLister) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // If the merge request is already attached, skip the middleware logic + if c.projectInfo.MergeId == 0 { + options := gitlab.ListProjectMergeRequestsOptions{ + Scope: gitlab.Ptr("all"), + State: gitlab.Ptr("opened"), + SourceBranch: &c.gitInfo.BranchName, + } + + mergeRequests, _, err := client.ListProjectMergeRequests(c.projectInfo.ProjectId, &options) + if err != nil { + handleError(w, fmt.Errorf("Failed to list merge requests: %w", err), "Failed to list merge requests", http.StatusInternalServerError) + return + } + + if len(mergeRequests) == 0 { + err := fmt.Errorf("No merge requests found for branch '%s'", c.gitInfo.BranchName) + handleError(w, err, "No merge requests found", http.StatusBadRequest) + return + } + + mergeIdInt := mergeRequests[0].IID + c.projectInfo.MergeId = mergeIdInt + } + + // Call the next handler if middleware succeeds + svc.handler(w, r) + } +} diff --git a/cmd/shutdown.go b/cmd/app/shutdown.go similarity index 67% rename from cmd/shutdown.go rename to cmd/app/shutdown.go index 733f03aa..9f1a6d6e 100644 --- a/cmd/shutdown.go +++ b/cmd/app/shutdown.go @@ -1,10 +1,13 @@ -package main +package app import ( + "context" "encoding/json" "errors" + "fmt" "io" "net/http" + "os" ) type killer struct{} @@ -14,12 +17,33 @@ func (k killer) String() string { return "0" } +type ShutdownHandler interface { + WatchForShutdown(server *http.Server) + shutdownHandler(w http.ResponseWriter, r *http.Request) +} + +type shutdown struct { + sigCh chan os.Signal +} + +func (s shutdown) WatchForShutdown(server *http.Server) { + /* Handles shutdown requests */ + <-s.sigCh + err := server.Shutdown(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "Server could not shut down gracefully: %s\n", err) + os.Exit(1) + } else { + os.Exit(0) + } +} + type ShutdownRequest struct { Restart bool `json:"restart"` } /* shutdownHandler will shutdown the HTTP server and exit the process by signaling to the shutdown channel */ -func (a *Api) shutdownHandler(w http.ResponseWriter, r *http.Request) { +func (s shutdown) shutdownHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) @@ -54,6 +78,6 @@ func (a *Api) shutdownHandler(w http.ResponseWriter, r *http.Request) { if err != nil { handleError(w, err, "Could not encode response", http.StatusInternalServerError) } else { - a.sigCh <- killer{} + s.sigCh <- killer{} } } diff --git a/cmd/summary.go b/cmd/app/summary.go similarity index 92% rename from cmd/summary.go rename to cmd/app/summary.go index 0d4d6c5c..411747a6 100644 --- a/cmd/summary.go +++ b/cmd/app/summary.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -18,7 +18,12 @@ type SummaryUpdateResponse struct { MergeRequest *gitlab.MergeRequest `json:"mr"` } -func (a *Api) summaryHandler(w http.ResponseWriter, r *http.Request) { +type summaryService struct { + data + client MergeRequestUpdater +} + +func (a summaryService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodPut { diff --git a/cmd/test.go b/cmd/app/test_helpers.go similarity index 54% rename from cmd/test.go rename to cmd/app/test_helpers.go index 58e0482d..6078029f 100644 --- a/cmd/test.go +++ b/cmd/app/test_helpers.go @@ -1,4 +1,4 @@ -package main +package app import ( "bytes" @@ -11,13 +11,10 @@ import ( "strings" "testing" + "github.com/harrisoncramer/gitlab.nvim/cmd/app/git" "github.com/xanzy/go-gitlab" ) -/* -The FakeHandlerClient is used to create a fake gitlab client for testing our handlers, where the gitlab APIs are all mocked depending on what is provided during the variable initialization, so that we can simulate different responses from Gitlab -*/ - var errorFromGitlab = errors.New("Some error from Gitlab") /* The assert function is a helper function used to check two comparables */ @@ -49,22 +46,6 @@ func makeRequest(t *testing.T, method string, endpoint string, body any) *http.R return request } -/* Serves and parses the JSON from an endpoint into the given type */ -func serveRequest[T any](t *testing.T, s *http.ServeMux, request *http.Request, i T) *T { - t.Helper() - recorder := httptest.NewRecorder() - s.ServeHTTP(recorder, request) - result := recorder.Result() - decoder := json.NewDecoder(result.Body) - err := decoder.Decode(&i) - if err != nil { - t.Fatal(err) - return nil - } - - return &i -} - /* Make response makes a simple response value with the right status code */ func makeResponse(status int) *gitlab.Response { return &gitlab.Response{ @@ -74,6 +55,53 @@ func makeResponse(status int) *gitlab.Response { } } +var testProjectData = data{ + projectInfo: &ProjectInfo{}, + gitInfo: &git.GitData{ + BranchName: "some-branch", + }, +} + +func getSuccessData(t *testing.T, svc ServiceWithHandler, request *http.Request) SuccessResponse { + res := httptest.NewRecorder() + svc.handler(res, request) + + var data SuccessResponse + err := json.Unmarshal(res.Body.Bytes(), &data) + if err != nil { + t.Error(err) + } + return data +} + +func getFailData(t *testing.T, svc ServiceWithHandler, request *http.Request) ErrorResponse { + res := httptest.NewRecorder() + svc.handler(res, request) + + var data ErrorResponse + err := json.Unmarshal(res.Body.Bytes(), &data) + if err != nil { + t.Error(err) + } + return data +} + +type testBase struct { + errFromGitlab bool + status int +} + +// Helper for easily mocking bad responses or errors from Gitlab +func (f *testBase) handleGitlabError() (*gitlab.Response, error) { + if f.errFromGitlab { + return nil, errorFromGitlab + } + if f.status == 0 { + f.status = 200 + } + return makeResponse(f.status), nil +} + func checkErrorFromGitlab(t *testing.T, data ErrorResponse, msg string) { t.Helper() assert(t, data.Status, http.StatusInternalServerError) @@ -95,3 +123,26 @@ func checkNon200(t *testing.T, data ErrorResponse, msg, endpoint string) { assert(t, data.Message, msg) assert(t, data.Details, fmt.Sprintf("An error occurred on the %s endpoint", endpoint)) } + +type FakeGitManager struct { + RemoteUrl string + BranchName string + ProjectName string + Namespace string +} + +func (f FakeGitManager) RefreshProjectInfo(remote string) error { + return nil +} + +func (f FakeGitManager) GetCurrentBranchNameFromNativeGitCmd() (string, error) { + return f.BranchName, nil +} + +func (f FakeGitManager) GetLatestCommitOnRemote(remote string, branchName string) (string, error) { + return "", nil +} + +func (f FakeGitManager) GetProjectUrlFromNativeGitCmd(string) (url string, err error) { + return f.RemoteUrl, nil +} diff --git a/cmd/user.go b/cmd/app/user.go similarity index 79% rename from cmd/user.go rename to cmd/app/user.go index dcc3ea91..ae2c6c58 100644 --- a/cmd/user.go +++ b/cmd/app/user.go @@ -1,4 +1,4 @@ -package main +package app import ( "encoding/json" @@ -12,7 +12,16 @@ type UserResponse struct { User *gitlab.User `json:"user"` } -func (a *Api) meHandler(w http.ResponseWriter, r *http.Request) { +type MeGetter interface { + CurrentUser(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) +} + +type meService struct { + data + client MeGetter +} + +func (a meService) handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.Method != http.MethodGet { w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) diff --git a/cmd/approve_test.go b/cmd/approve_test.go deleted file mode 100644 index 5ab77182..00000000 --- a/cmd/approve_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "net/http" - "testing" - - "github.com/xanzy/go-gitlab" - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" -) - -func TestApproveHandler(t *testing.T) { - t.Run("Approves merge request", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ApproveMergeRequest("", mock_main.MergeId, nil, nil).Return(&gitlab.MergeRequestApprovals{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/mr/approve", nil) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, SuccessResponse{}) - - assert(t, data.Message, "Approved MR") - assert(t, data.Status, http.StatusOK) - }) - - t.Run("Disallows non-POST method", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ApproveMergeRequest("", mock_main.MergeId, nil, nil).Return(&gitlab.MergeRequestApprovals{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPut, "/mr/approve", nil) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - checkBadMethod(t, *data, http.MethodPost) - }) - - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ApproveMergeRequest("", mock_main.MergeId, nil, nil).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodPost, "/mr/approve", nil) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkErrorFromGitlab(t, *data, "Could not approve merge request") - }) - - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ApproveMergeRequest("", mock_main.MergeId, nil, nil).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodPost, "/mr/approve", nil) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkNon200(t, *data, "Could not approve merge request", "/mr/approve") - }) -} diff --git a/cmd/assignee_test.go b/cmd/assignee_test.go deleted file mode 100644 index 9ffb9ee2..00000000 --- a/cmd/assignee_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package main - -import ( - "net/http" - "testing" - - "github.com/xanzy/go-gitlab" - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" - "go.uber.org/mock/gomock" -) - -var updatePayload = AssigneeUpdateRequest{Ids: []int{1, 2}} - -func TestAssigneeHandler(t *testing.T) { - t.Run("Updates assignees", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().UpdateMergeRequest("", mock_main.MergeId, gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, AssigneeUpdateResponse{}) - - assert(t, data.SuccessResponse.Message, "Assignees updated") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - - t.Run("Disallows non-PUT method", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().UpdateMergeRequest("", mock_main.MergeId, gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/mr/assignee", nil) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Status, http.StatusMethodNotAllowed) - assert(t, data.Details, "Invalid request type") - assert(t, data.Message, "Expected PUT") - }) - - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().UpdateMergeRequest("", mock_main.MergeId, gomock.Any()).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Status, http.StatusInternalServerError) - assert(t, data.Message, "Could not modify merge request assignees") - assert(t, data.Details, "Some error from Gitlab") - }) - - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().UpdateMergeRequest("", mock_main.MergeId, gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Status, http.StatusSeeOther) - assert(t, data.Message, "Could not modify merge request assignees") - assert(t, data.Details, "An error occurred on the /mr/assignee endpoint") - }) -} diff --git a/cmd/attachment_test.go b/cmd/attachment_test.go deleted file mode 100644 index d1390da1..00000000 --- a/cmd/attachment_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package main - -import ( - "bytes" - "net/http" - "testing" - - "github.com/xanzy/go-gitlab" - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" -) - -func withMockFileReader(a *Api) error { - reader := mock_main.MockAttachmentReader{} - a.fileReader = reader - return nil -} - -var reader = bytes.NewReader([]byte{}) -var attachmentTestRequestData = AttachmentRequest{ - FileName: "some_file_name", - FilePath: "some_file_path", -} - -func TestAttachmentHandler(t *testing.T) { - t.Run("Returns 200-status response after upload", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().UploadFile("", reader, attachmentTestRequestData.FileName).Return(&gitlab.ProjectFile{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) - router, _ := CreateRouterAndApi(client, withMockFileReader) - data := serveRequest(t, router, request, AttachmentResponse{}) - - assert(t, data.SuccessResponse.Status, http.StatusOK) - assert(t, data.SuccessResponse.Message, "File uploaded successfully") - }) - - t.Run("Disallows non-POST method", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().UploadFile("", reader, attachmentTestRequestData.FileName).Return(&gitlab.ProjectFile{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPut, "/attachment", attachmentTestRequestData) - router, _ := CreateRouterAndApi(client, withMockFileReader) - data := serveRequest(t, router, request, ErrorResponse{}) - - checkBadMethod(t, *data, http.MethodPost) - }) - - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().UploadFile("", reader, attachmentTestRequestData.FileName).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) - router, _ := CreateRouterAndApi(client, withMockFileReader) - - data := serveRequest(t, router, request, ErrorResponse{}) - checkErrorFromGitlab(t, *data, "Could not upload some_file_name to Gitlab") - }) - - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().UploadFile("", reader, attachmentTestRequestData.FileName).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) - router, _ := CreateRouterAndApi(client, withMockFileReader) - - data := serveRequest(t, router, request, ErrorResponse{}) - checkNon200(t, *data, "Could not upload some_file_name to Gitlab", "/attachment") - }) -} diff --git a/cmd/comment_test.go b/cmd/comment_test.go deleted file mode 100644 index 97d486ba..00000000 --- a/cmd/comment_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package main - -import ( - "errors" - "net/http" - "testing" - - "github.com/xanzy/go-gitlab" - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" - "go.uber.org/mock/gomock" -) - -var testCommentCreationData = PostCommentRequest{ - Comment: "Some comment", -} - -var testCommentDeletionData = DeleteCommentRequest{ - NoteId: 3, - DiscussionId: "abc123", -} - -var testEditCommentData = EditCommentRequest{ - Comment: "Some comment", - NoteId: 3, - DiscussionId: "abc123", -} - -func TestPostComment(t *testing.T) { - t.Run("Creates a new note (unlinked comment)", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().CreateMergeRequestDiscussion( - "", - mock_main.MergeId, - gomock.Any(), - ).Return(&gitlab.Discussion{Notes: []*gitlab.Note{{}}}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, CommentResponse{}) - - assert(t, data.SuccessResponse.Message, "Comment created successfully") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - - t.Run("Creates a new comment", func(t *testing.T) { - // Re-create comment creation data to avoid mutating this variable in other tests - testCommentCreationData := PostCommentRequest{ - Comment: "Some comment", - PositionData: PositionData{ - FileName: "file.txt", - }, - } - - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().CreateMergeRequestDiscussion( - "", - mock_main.MergeId, - gomock.Any(), - ).Return(&gitlab.Discussion{Notes: []*gitlab.Note{{}}}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) - - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, CommentResponse{}) - assert(t, data.SuccessResponse.Message, "Comment created successfully") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().CreateMergeRequestDiscussion( - "", - mock_main.MergeId, - gomock.Any(), - ).Return(nil, nil, errors.New("Some error from Gitlab")) - - request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkErrorFromGitlab(t, *data, "Could not create discussion") - }) - - t.Run("Handles non-200s from Gitlab", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().CreateMergeRequestDiscussion( - "", - mock_main.MergeId, - gomock.Any(), - ).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkNon200(t, *data, "Could not create discussion", "/mr/comment") - }) -} - -func TestDeleteComment(t *testing.T) { - t.Run("Deletes a comment", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().DeleteMergeRequestDiscussionNote("", mock_main.MergeId, testCommentDeletionData.DiscussionId, testCommentDeletionData.NoteId).Return(makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, CommentResponse{}) - - assert(t, data.SuccessResponse.Message, "Comment deleted successfully") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().DeleteMergeRequestDiscussionNote("", mock_main.MergeId, testCommentDeletionData.DiscussionId, testCommentDeletionData.NoteId).Return(nil, errorFromGitlab) - - request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkErrorFromGitlab(t, *data, "Could not delete comment") - }) - - t.Run("Handles non-200s from Gitlab", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().DeleteMergeRequestDiscussionNote("", mock_main.MergeId, testCommentDeletionData.DiscussionId, testCommentDeletionData.NoteId).Return(makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkNon200(t, *data, "Could not delete comment", "/mr/comment") - }) -} - -func TestEditComment(t *testing.T) { - t.Run("Edits a comment", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - opts := gitlab.UpdateMergeRequestDiscussionNoteOptions{ - Body: gitlab.Ptr(testEditCommentData.Comment), - } - client.EXPECT().UpdateMergeRequestDiscussionNote("", mock_main.MergeId, testEditCommentData.DiscussionId, testEditCommentData.NoteId, &opts).Return(&gitlab.Note{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, CommentResponse{}) - - assert(t, data.SuccessResponse.Message, "Comment updated successfully") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().UpdateMergeRequestDiscussionNote("", mock_main.MergeId, testEditCommentData.DiscussionId, testEditCommentData.NoteId, gomock.Any()).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkErrorFromGitlab(t, *data, "Could not update comment") - }) - - t.Run("Handles non-200s from Gitlab", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().UpdateMergeRequestDiscussionNote("", mock_main.MergeId, testEditCommentData.DiscussionId, testEditCommentData.NoteId, gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - checkNon200(t, *data, "Could not update comment", "/mr/comment") - }) -} diff --git a/cmd/create_mr_test.go b/cmd/create_mr_test.go deleted file mode 100644 index 49dd9078..00000000 --- a/cmd/create_mr_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package main - -import ( - "net/http" - "testing" - - "github.com/xanzy/go-gitlab" - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" - "go.uber.org/mock/gomock" -) - -var testCreateMrRequestData = CreateMrRequest{ - Title: "Some title", - Description: "Some description", - TargetBranch: "main", - DeleteBranch: false, - Squash: false, -} - -func TestCreateMr(t *testing.T) { - t.Run("Creates an MR", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().CreateMergeRequest("", gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) - server, _ := CreateRouterAndApi(client) - - data := serveRequest(t, server, request, SuccessResponse{}) - assert(t, data.Message, "MR 'Some title' created") - assert(t, data.Status, http.StatusOK) - }) - - t.Run("Disallows non-POST methods", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().CreateMergeRequest("", gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodPatch, "/create_mr", testCreateMrRequestData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkBadMethod(t, *data, http.MethodPost) - }) - - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().CreateMergeRequest("", gomock.Any()).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkErrorFromGitlab(t, *data, "Could not create MR") - }) - - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().CreateMergeRequest("", gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkNon200(t, *data, "Could not create MR", "/create_mr") - }) - - t.Run("Handles missing titles", func(t *testing.T) { - client := mock_main.NewMockClient(t) - - missingTitleRequest := testCreateMrRequestData - missingTitleRequest.Title = "" - - client.EXPECT().CreateMergeRequest("", gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/create_mr", missingTitleRequest) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Status, http.StatusBadRequest) - assert(t, data.Message, "Could not create MR") - assert(t, data.Details, "Title cannot be empty") - }) - - t.Run("Handles missing target branch", func(t *testing.T) { - client := mock_main.NewMockClient(t) - - missingTitleRequest := testCreateMrRequestData - missingTitleRequest.TargetBranch = "" - - client.EXPECT().CreateMergeRequest("", gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/create_mr", missingTitleRequest) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Status, http.StatusBadRequest) - assert(t, data.Message, "Could not create MR") - assert(t, data.Details, "Target branch cannot be empty") - }) -} diff --git a/cmd/draft_notes_test.go b/cmd/draft_notes_test.go deleted file mode 100644 index a8dd4f54..00000000 --- a/cmd/draft_notes_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "testing" - - "github.com/xanzy/go-gitlab" - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" - "go.uber.org/mock/gomock" -) - -var testPostDraftNoteRequestData = PostDraftNoteRequest{ - Comment: "Some comment", -} - -var testUpdateDraftNoteRequest = UpdateDraftNoteRequest{ - Note: "Some new note", -} - -var testDraftNotePublishRequest = DraftNotePublishRequest{ - Note: 3, - PublishAll: false, -} - -func TestListDraftNotes(t *testing.T) { - t.Run("Lists all draft notes", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ListDraftNotes("", mock_main.MergeId, gomock.Any()).Return([]*gitlab.DraftNote{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ListDraftNotesResponse{}) - - assert(t, data.SuccessResponse.Message, "Draft notes fetched successfully") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - - t.Run("Handles error", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ListDraftNotes("", mock_main.MergeId, gomock.Any()).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Message, "Could not get draft notes") - assert(t, data.Status, http.StatusInternalServerError) - assert(t, data.Details, errorFromGitlab.Error()) - }) -} - -func TestPostDraftNote(t *testing.T) { - t.Run("Posts new draft note", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().CreateDraftNote("", mock_main.MergeId, gomock.Any()).Return(&gitlab.DraftNote{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, DraftNoteResponse{}) - - assert(t, data.SuccessResponse.Message, "Draft note created successfully") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - - t.Run("Handles errors on draft note creation", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().CreateDraftNote("", mock_main.MergeId, gomock.Any()).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Message, "Could not create draft note") - assert(t, data.Status, http.StatusInternalServerError) - assert(t, data.Details, errorFromGitlab.Error()) - }) -} - -func TestDeleteDraftNote(t *testing.T) { - t.Run("Deletes draft note", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - urlId := 10 - client.EXPECT().DeleteDraftNote("", mock_main.MergeId, urlId).Return(makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodDelete, fmt.Sprintf("/mr/draft_notes/%d", urlId), nil) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, DraftNoteResponse{}) - - assert(t, data.SuccessResponse.Message, "Draft note deleted") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - - t.Run("Handles error", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - urlId := 10 - client.EXPECT().DeleteDraftNote("", mock_main.MergeId, urlId).Return(nil, errorFromGitlab) - - request := makeRequest(t, http.MethodDelete, fmt.Sprintf("/mr/draft_notes/%d", urlId), nil) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Message, "Could not delete draft note") - assert(t, data.Status, http.StatusInternalServerError) - assert(t, data.Details, errorFromGitlab.Error()) - }) - - t.Run("Handles bad ID", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - urlId := "abc" - - request := makeRequest(t, http.MethodDelete, fmt.Sprintf("/mr/draft_notes/%s", urlId), nil) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Message, "Could not parse draft note ID") - assert(t, data.Status, http.StatusBadRequest) - }) -} - -func TestEditDraftNote(t *testing.T) { - t.Run("Edits draft note", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - urlId := 10 - client.EXPECT().UpdateDraftNote("", mock_main.MergeId, urlId, gomock.Any()).Return(&gitlab.DraftNote{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPatch, fmt.Sprintf("/mr/draft_notes/%d", urlId), testUpdateDraftNoteRequest) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, DraftNoteResponse{}) - - assert(t, data.Message, "Draft note updated") - assert(t, data.Status, http.StatusOK) - }) - - t.Run("Handles bad ID", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - urlId := "abc" - client.EXPECT().UpdateDraftNote("", mock_main.MergeId, urlId, gomock.Any()).Return(&gitlab.DraftNote{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPatch, fmt.Sprintf("/mr/draft_notes/%s", urlId), testUpdateDraftNoteRequest) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Message, "Could not parse draft note ID") - assert(t, data.Status, http.StatusBadRequest) - }) - - t.Run("Handles empty note", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - urlId := 10 - - testEmptyUpdateDraftNoteRequest := testUpdateDraftNoteRequest - testEmptyUpdateDraftNoteRequest.Note = "" - - client.EXPECT().UpdateDraftNote("", mock_main.MergeId, urlId, gomock.Any()).Return(&gitlab.DraftNote{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPatch, fmt.Sprintf("/mr/draft_notes/%d", urlId), testEmptyUpdateDraftNoteRequest) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Message, "Must provide draft note text") - assert(t, data.Status, http.StatusBadRequest) - }) -} - -func TestPublishDraftNote(t *testing.T) { - t.Run("Should publish a draft note", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().PublishDraftNote("", mock_main.MergeId, testDraftNotePublishRequest.Note).Return(makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) - server, _ := CreateRouterAndApi(client) - - data := serveRequest(t, server, request, SuccessResponse{}) - assert(t, data.Message, "Draft note(s) published") - assert(t, data.Status, http.StatusOK) - }) - - t.Run("Handles bad/missing ID", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - - // Missing Note ID - testDraftNotePublishRequest := DraftNotePublishRequest{ - PublishAll: false, - } - - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) - server, _ := CreateRouterAndApi(client) - - data := serveRequest(t, server, request, ErrorResponse{}) - assert(t, data.Message, "Must provide Note ID") - assert(t, data.Status, http.StatusBadRequest) - }) - - t.Run("Handles error", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().PublishDraftNote("", mock_main.MergeId, testDraftNotePublishRequest.Note).Return(nil, errorFromGitlab) - - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Message, "Could not publish draft note(s)") - assert(t, data.Status, http.StatusInternalServerError) - }) -} - -func TestPublishAllDraftNotes(t *testing.T) { - t.Run("Should publish all draft notes", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().PublishAllDraftNotes("", mock_main.MergeId).Return(makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: true}) - server, _ := CreateRouterAndApi(client) - - data := serveRequest(t, server, request, SuccessResponse{}) - assert(t, data.Message, "Draft note(s) published") - assert(t, data.Status, http.StatusOK) - }) - - t.Run("Should handle an error", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().PublishAllDraftNotes("", mock_main.MergeId).Return(nil, errorFromGitlab) - - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", DraftNotePublishRequest{PublishAll: true}) - server, _ := CreateRouterAndApi(client) - - data := serveRequest(t, server, request, ErrorResponse{}) - assert(t, data.Message, "Could not publish draft note(s)") - assert(t, data.Status, http.StatusInternalServerError) - }) -} diff --git a/cmd/git_test.go b/cmd/git_test.go deleted file mode 100644 index 72f02dcc..00000000 --- a/cmd/git_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "testing" -) - -func TestExtractGitInfo_Success(t *testing.T) { - getCurrentBranchName := func() (string, error) { - return "feature/abc", nil - } - refreshGitInfo := func() error { - return nil - } - testCases := []struct { - getProjectRemoteUrl func() (string, error) - expected GitProjectInfo - desc string - }{ - { - desc: "Project configured in SSH under a single folder", - getProjectRemoteUrl: func() (string, error) { - return "git@custom-gitlab.com:namespace-1/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "git@custom-gitlab.com:namespace-1/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1", - }, - }, - { - desc: "Project configured in SSH under a single folder without .git extension", - getProjectRemoteUrl: func() (string, error) { - return "git@custom-gitlab.com:namespace-1/project-name", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "git@custom-gitlab.com:namespace-1/project-name", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1", - }, - }, - { - desc: "Project configured in SSH under one nested folder", - getProjectRemoteUrl: func() (string, error) { - return "git@custom-gitlab.com:namespace-1/namespace-2/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "git@custom-gitlab.com:namespace-1/namespace-2/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1/namespace-2", - }, - }, - { - desc: "Project configured in SSH under two nested folders", - getProjectRemoteUrl: func() (string, error) { - return "git@custom-gitlab.com:namespace-1/namespace-2/namespace-3/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "git@custom-gitlab.com:namespace-1/namespace-2/namespace-3/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1/namespace-2/namespace-3", - }, - }, - { - desc: "Project configured in SSH:// under a single folder", - getProjectRemoteUrl: func() (string, error) { - return "ssh://custom-gitlab.com/namespace-1/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "ssh://custom-gitlab.com/namespace-1/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1", - }, - }, - { - desc: "Project configured in SSH:// under a single folder without .git extension", - getProjectRemoteUrl: func() (string, error) { - return "ssh://custom-gitlab.com/namespace-1/project-name", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "ssh://custom-gitlab.com/namespace-1/project-name", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1", - }, - }, - { - desc: "Project configured in SSH:// under two nested folders", - getProjectRemoteUrl: func() (string, error) { - return "ssh://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "ssh://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1/namespace-2/namespace-3", - }, - }, - { - desc: "Project configured in SSH:// and have a custom port", - getProjectRemoteUrl: func() (string, error) { - return "ssh://custom-gitlab.com:2222/namespace-1/project-name", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "ssh://custom-gitlab.com:2222/namespace-1/project-name", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1", - }, - }, - { - desc: "Project configured in HTTP and under a single folder without .git extension", - getProjectRemoteUrl: func() (string, error) { - return "http://custom-gitlab.com/namespace-1/project-name", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "http://custom-gitlab.com/namespace-1/project-name", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1", - }, - }, - { - desc: "Project configured in HTTPS and under a single folder", - getProjectRemoteUrl: func() (string, error) { - return "https://custom-gitlab.com/namespace-1/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "https://custom-gitlab.com/namespace-1/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1", - }, - }, - { - desc: "Project configured in HTTPS and under a nested folder", - getProjectRemoteUrl: func() (string, error) { - return "https://custom-gitlab.com/namespace-1/namespace-2/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "https://custom-gitlab.com/namespace-1/namespace-2/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1/namespace-2", - }, - }, - { - desc: "Project configured in HTTPS and under two nested folders", - getProjectRemoteUrl: func() (string, error) { - return "https://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git", nil - }, - expected: GitProjectInfo{ - RemoteUrl: "https://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git", - BranchName: "feature/abc", - ProjectName: "project-name", - Namespace: "namespace-1/namespace-2/namespace-3", - }, - }, - } - for _, tC := range testCases { - t.Run(tC.desc, func(t *testing.T) { - actual, err := extractGitInfo(refreshGitInfo, tC.getProjectRemoteUrl, getCurrentBranchName) - if err != nil { - t.Errorf("No error was expected, got %s", err) - } - if actual.RemoteUrl != tC.expected.RemoteUrl { - t.Errorf("\nExpected Remote URL: %s\nActual: %s", tC.expected.RemoteUrl, actual.RemoteUrl) - } - if actual.BranchName != tC.expected.BranchName { - t.Errorf("\nExpected Branch Name: %s\nActual: %s", tC.expected.BranchName, actual.BranchName) - } - if actual.ProjectName != tC.expected.ProjectName { - t.Errorf("\nExpected Project Name: %s\nActual: %s", tC.expected.ProjectName, actual.ProjectName) - } - if actual.Namespace != tC.expected.Namespace { - t.Errorf("\nExpected Namespace: %s\nActual: %s", tC.expected.Namespace, actual.Namespace) - } - }) - } -} - -func TestExtractGitInfo_FailToGetProjectRemoteUrl(t *testing.T) { - getCurrentBranchName := func() (string, error) { - return "feature/abc", nil - } - refreshGitInfo := func() error { - return nil - } - testCases := []struct { - getProjectRemoteUrl func() (string, error) - expectedErrorMessage string - desc string - }{ - { - desc: "Error returned by function to get the project remote url", - getProjectRemoteUrl: func() (string, error) { - return "", errors.New("error when getting project remote url") - }, - expectedErrorMessage: "Could not get project Url: error when getting project remote url", - }, - { - desc: "Invalid project remote url", - getProjectRemoteUrl: func() (string, error) { - return "git@invalid", nil - }, - expectedErrorMessage: "Invalid Git URL format: git@invalid", - }, - } - for _, tC := range testCases { - t.Run(tC.desc, func(t *testing.T) { - _, actualErr := extractGitInfo(refreshGitInfo, tC.getProjectRemoteUrl, getCurrentBranchName) - if actualErr == nil { - t.Errorf("Expected an error, got none") - } - if actualErr.Error() != tC.expectedErrorMessage { - t.Errorf("\nExpected: %s\nActual: %s", tC.expectedErrorMessage, actualErr.Error()) - } - }) - } -} - -func TestExtractGitInfo_FailToGetCurrentBranchName(t *testing.T) { - expectedErrNestedMsg := "error when getting current branch name" - - refreshGitInfo := func() error { - return nil - } - _, actualErr := extractGitInfo(refreshGitInfo, - func() (string, error) { - return "git@custom-gitlab.com:namespace/project.git", nil - }, - func() (string, error) { - return "", errors.New(expectedErrNestedMsg) - }) - - if actualErr == nil { - t.Errorf("Expected an error, got none") - } - expectedErr := fmt.Errorf("Failed to get current branch: %s", expectedErrNestedMsg) - if actualErr.Error() != expectedErr.Error() { - t.Errorf("\nExpected: %s\nActual: %s", expectedErr, actualErr) - } -} - -func TestRefreshGitRemote_FailToRefreshRemote(t *testing.T) { - expectedErrNestedMsg := "error when fetching commits" - _, actualErr := extractGitInfo( - func() error { - return errors.New(expectedErrNestedMsg) - }, - func() (string, error) { - return "git@custom-gitlab.com:namespace/project.git", nil - }, - func() (string, error) { - return "feature/abc", nil - }, - ) - - if actualErr == nil { - t.Errorf("Expected an error, got none") - } - expectedErr := fmt.Errorf("Could not get latest information from remote: %s", expectedErrNestedMsg) - if actualErr.Error() != expectedErr.Error() { - t.Errorf("\nExpected: %s\nActual: %s", expectedErr, actualErr) - } -} diff --git a/cmd/info_test.go b/cmd/info_test.go deleted file mode 100644 index 030ea60b..00000000 --- a/cmd/info_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "net/http" - "testing" - - "github.com/xanzy/go-gitlab" - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" -) - -func TestInfoHandler(t *testing.T) { - t.Run("Returns normal information", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().GetMergeRequest("", mock_main.MergeId, &gitlab.GetMergeRequestsOptions{}).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) - - server, _ := CreateRouterAndApi(client) - request := makeRequest(t, http.MethodGet, "/mr/info", nil) - data := serveRequest(t, server, request, InfoResponse{}) - - assert(t, data.SuccessResponse.Message, "Merge requests retrieved") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - - t.Run("Disallows non-GET method", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - - server, _ := CreateRouterAndApi(client) - request := makeRequest(t, http.MethodPost, "/mr/info", nil) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkBadMethod(t, *data, http.MethodGet) - }) - - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().GetMergeRequest("", mock_main.MergeId, &gitlab.GetMergeRequestsOptions{}).Return(nil, nil, errorFromGitlab) - - server, _ := CreateRouterAndApi(client) - request := makeRequest(t, http.MethodGet, "/mr/info", nil) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkErrorFromGitlab(t, *data, "Could not get project info") - }) - - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().GetMergeRequest("", mock_main.MergeId, &gitlab.GetMergeRequestsOptions{}).Return(nil, makeResponse(http.StatusSeeOther), nil) - - server, _ := CreateRouterAndApi(client) - request := makeRequest(t, http.MethodGet, "/mr/info", nil) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkNon200(t, *data, "Could not get project info", "/mr/info") - }) -} diff --git a/cmd/job_test.go b/cmd/job_test.go deleted file mode 100644 index 1914867b..00000000 --- a/cmd/job_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "bytes" - "net/http" - "testing" - - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" -) - -var jobId = 0 - -func TestJobHandler(t *testing.T) { - t.Run("Should read a job trace file", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().GetTraceFile("", jobId).Return(bytes.NewReader([]byte("Some data")), makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, JobTraceResponse{}) - - assert(t, data.SuccessResponse.Message, "Log file read") - assert(t, data.SuccessResponse.Status, http.StatusOK) - assert(t, data.File, "Some data") - }) - - t.Run("Disallows non-GET methods", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().GetTraceFile("", jobId).Return(bytes.NewReader([]byte("Some data")), makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/job", JobTraceRequest{}) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkBadMethod(t, *data, http.MethodGet) - }) - - t.Run("Should handle errors from Gitlab", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().GetTraceFile("", jobId).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkErrorFromGitlab(t, *data, "Could not get trace file for job") - }) - - t.Run("Should handle non-2jobIdjobIds", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().GetTraceFile("", jobId).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkNon200(t, *data, "Could not get trace file for job", "/job") - }) -} diff --git a/cmd/list_discussions_test.go b/cmd/list_discussions_test.go deleted file mode 100644 index 37f9b16b..00000000 --- a/cmd/list_discussions_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package main - -import ( - "net/http" - "testing" - "time" - - "github.com/xanzy/go-gitlab" - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" - "go.uber.org/mock/gomock" -) - -var now = time.Now() -var newer = now.Add(time.Second * 100) - -type Author struct { - ID int `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - Name string `json:"name"` - State string `json:"state"` - AvatarURL string `json:"avatar_url"` - WebURL string `json:"web_url"` -} - -var testListDiscussionsResponse = []*gitlab.Discussion{ - { - Notes: []*gitlab.Note{ - { - CreatedAt: &now, - Type: "DiffNote", - Author: Author{ - Username: "hcramer", - }, - }, - }, - }, - { - Notes: []*gitlab.Note{ - { - CreatedAt: &newer, - Type: "DiffNote", - Author: Author{ - Username: "hcramer2", - }, - }, - }, - }, -} - -func TestListDiscussionsHandler(t *testing.T) { - t.Run("Returns sorted discussions", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ListMergeRequestDiscussions("", mock_main.MergeId, gomock.Any()).Return(testListDiscussionsResponse, makeResponse(http.StatusOK), nil) - client.EXPECT().ListMergeRequestAwardEmojiOnNote("", mock_main.MergeId, gomock.Any(), gomock.Any()).Return([]*gitlab.AwardEmoji{}, makeResponse(http.StatusOK), nil).Times(2) - - request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, DiscussionsResponse{}) - - assert(t, data.SuccessResponse.Message, "Discussions retrieved") - assert(t, data.SuccessResponse.Status, http.StatusOK) - assert(t, data.Discussions[0].Notes[0].Author.Username, "hcramer2") /* Sorting applied */ - assert(t, data.Discussions[1].Notes[0].Author.Username, "hcramer") - }) - - t.Run("Uses blacklist to filter unwanted authors", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ListMergeRequestDiscussions("", mock_main.MergeId, gomock.Any()).Return(testListDiscussionsResponse, makeResponse(http.StatusOK), nil) - client.EXPECT().ListMergeRequestAwardEmojiOnNote("", mock_main.MergeId, gomock.Any(), gomock.Any()).Return([]*gitlab.AwardEmoji{}, makeResponse(http.StatusOK), nil).Times(2) - - request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{"hcramer"}}) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, DiscussionsResponse{}) - - assert(t, data.SuccessResponse.Message, "Discussions retrieved") - assert(t, data.SuccessResponse.Status, http.StatusOK) - assert(t, len(data.Discussions), 1) - assert(t, data.Discussions[0].Notes[0].Author.Username, "hcramer2") - }) - - t.Run("Disallows non-POST method", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - - request := makeRequest(t, http.MethodPut, "/mr/discussions/list", DiscussionsRequest{}) - server, _ := CreateRouterAndApi(client) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkBadMethod(t, *data, http.MethodPost) - }) - - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ListMergeRequestDiscussions("", mock_main.MergeId, gomock.Any()).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) - server, _ := CreateRouterAndApi(client) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkErrorFromGitlab(t, *data, "Could not list discussions") - }) - - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ListMergeRequestDiscussions("", mock_main.MergeId, gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) - server, _ := CreateRouterAndApi(client) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkNon200(t, *data, "Could not list discussions", "/mr/discussions/list") - }) - - t.Run("Handles error from emoji service", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ListMergeRequestDiscussions("", mock_main.MergeId, gomock.Any()).Return(testListDiscussionsResponse, makeResponse(http.StatusOK), nil) - client.EXPECT().ListMergeRequestAwardEmojiOnNote("", mock_main.MergeId, gomock.Any(), gomock.Any()).Return(nil, nil, errorFromGitlab).Times(2) - - request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkErrorFromGitlab(t, *data, "Could not fetch emojis") - - }) -} diff --git a/cmd/main.go b/cmd/main.go index 68baeca1..175aa30a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,47 +4,39 @@ import ( "encoding/json" "log" "os" -) -type PluginOptions struct { - GitlabUrl string `json:"gitlab_url"` - Port int `json:"port"` - AuthToken string `json:"auth_token"` - LogPath string `json:"log_path"` - Debug struct { - Request bool `json:"go_request"` - Response bool `json:"go_response"` - } `json:"debug"` - ConnectionSettings struct { - Insecure bool `json:"insecure"` - Remote string `json:"remote"` - } `json:"connection_settings"` -} + "github.com/harrisoncramer/gitlab.nvim/cmd/app" + "github.com/harrisoncramer/gitlab.nvim/cmd/app/git" +) -var pluginOptions PluginOptions +var pluginOptions app.PluginOptions func main() { log.SetFlags(0) err := json.Unmarshal([]byte(os.Args[1]), &pluginOptions) + app.SetPluginOptions(pluginOptions) + if err != nil { log.Fatalf("Failure parsing plugin settings: %v", err) } - gitInfo, err := extractGitInfo(RefreshProjectInfo, GetProjectUrlFromNativeGitCmd, GetCurrentBranchNameFromNativeGitCmd) + gitManager := git.Git{} + gitData, err := git.NewGitData(pluginOptions.ConnectionSettings.Remote, gitManager) + if err != nil { log.Fatalf("Failure initializing plugin: %v", err) } - err, client := initGitlabClient() + err, client := app.NewClient() if err != nil { log.Fatalf("Failed to initialize Gitlab client: %v", err) } - err, projectInfo := initProjectSettings(client, gitInfo) + err, projectInfo := app.InitProjectSettings(client, gitData) if err != nil { log.Fatalf("Failed to initialize project settings: %v", err) } - startServer(client, projectInfo, gitInfo) + app.StartServer(client, projectInfo, gitData) } diff --git a/cmd/members_test.go b/cmd/members_test.go deleted file mode 100644 index f62f500b..00000000 --- a/cmd/members_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "net/http" - "testing" - - "github.com/xanzy/go-gitlab" - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" - "go.uber.org/mock/gomock" -) - -func TestMembersHandler(t *testing.T) { - t.Run("Returns project members", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().ListAllProjectMembers("", gomock.Any()).Return([]*gitlab.ProjectMember{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodGet, "/project/members", nil) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ProjectMembersResponse{}) - - assert(t, data.SuccessResponse.Message, "Project members retrieved") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - - t.Run("Disallows non-GET method", func(t *testing.T) { - client := mock_main.NewMockClient(t) - - request := makeRequest(t, http.MethodPost, "/project/members", nil) - server, _ := CreateRouterAndApi(client) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkBadMethod(t, *data, http.MethodGet) - }) - - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().ListAllProjectMembers("", gomock.Any()).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodGet, "/project/members", nil) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkErrorFromGitlab(t, *data, "Could not retrieve project members") - }) - - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().ListAllProjectMembers("", gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodGet, "/project/members", nil) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkNon200(t, *data, "Could not retrieve project members", "/project/members") - }) -} diff --git a/cmd/merge_requests_test.go b/cmd/merge_requests_test.go deleted file mode 100644 index 14210805..00000000 --- a/cmd/merge_requests_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package main - -import ( - "net/http" - "testing" - - "github.com/xanzy/go-gitlab" - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" - "go.uber.org/mock/gomock" -) - -var testListMergeRequestsRequest = ListMergeRequestRequest{ - Label: []string{}, - NotLabel: []string{}, -} - -func TestMergeRequestHandler(t *testing.T) { - t.Run("Should fetch merge requests", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ListProjectMergeRequests("", gomock.Any()).Return([]*gitlab.MergeRequest{ - { - IID: 10, - }, - }, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ListMergeRequestResponse{}) - - assert(t, data.Message, "Merge requests fetched successfully") - assert(t, data.Status, http.StatusOK) - }) - - t.Run("Should handle an error from Gitlab", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ListProjectMergeRequests("", gomock.Any()).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Message, "Failed to list merge requests") - assert(t, data.Status, http.StatusInternalServerError) - }) - - t.Run("Should handle a non-200", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ListProjectMergeRequests("", gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - assert(t, data.Message, "Failed to list merge requests") - assert(t, data.Status, http.StatusSeeOther) - }) - - t.Run("Should handle not having any merge requests with 404", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().ListProjectMergeRequests("", gomock.Any()).Return([]*gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ListMergeRequestResponse{}) - - assert(t, data.Message, "No merge requests found") - assert(t, data.Status, http.StatusNotFound) - }) -} diff --git a/cmd/merge_test.go b/cmd/merge_test.go deleted file mode 100644 index 3d78c949..00000000 --- a/cmd/merge_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package main - -import ( - "net/http" - "testing" - - "github.com/xanzy/go-gitlab" - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" - "go.uber.org/mock/gomock" -) - -var testAcceptMergeRequestPayload = AcceptMergeRequestRequest{ - Squash: false, - SquashMessage: "Squash me!", - DeleteBranch: false, -} - -func TestAcceptAndMergeHandler(t *testing.T) { - t.Run("Accepts and merges a merge request", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().AcceptMergeRequest("", mock_main.MergeId, gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, SuccessResponse{}) - - assert(t, data.Message, "MR merged successfully") - assert(t, data.Status, http.StatusOK) - }) - - t.Run("Disallows non-POST methods", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().AcceptMergeRequest("", mock_main.MergeId, gomock.Any()).Return(&gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPatch, "/mr/merge", testAcceptMergeRequestPayload) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkBadMethod(t, *data, http.MethodPost) - }) - - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().AcceptMergeRequest("", mock_main.MergeId, gomock.Any()).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkErrorFromGitlab(t, *data, "Could not merge MR") - }) - - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().AcceptMergeRequest("", mock_main.MergeId, gomock.Any()).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) - server, _ := CreateRouterAndApi(client) - data := serveRequest(t, server, request, ErrorResponse{}) - - checkNon200(t, *data, "Could not merge MR", "/mr/merge") - }) -} diff --git a/cmd/mocks/fake_client.go b/cmd/mocks/fake_client.go deleted file mode 100644 index 639edd07..00000000 --- a/cmd/mocks/fake_client.go +++ /dev/null @@ -1,686 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: cmd/types.go -// -// Generated by this command: -// -// mockgen -source cmd/types.go -// - -// Package mock_main is a generated GoMock package. -package mock_main - -import ( - bytes "bytes" - io "io" - reflect "reflect" - - gitlab "github.com/xanzy/go-gitlab" - gomock "go.uber.org/mock/gomock" -) - -// MockClientInterface is a mock of ClientInterface interface. -type MockClientInterface struct { - ctrl *gomock.Controller - recorder *MockClientInterfaceMockRecorder -} - -// MockClientInterfaceMockRecorder is the mock recorder for MockClientInterface. -type MockClientInterfaceMockRecorder struct { - mock *MockClientInterface -} - -// NewMockClientInterface creates a new mock instance. -func NewMockClientInterface(ctrl *gomock.Controller) *MockClientInterface { - mock := &MockClientInterface{ctrl: ctrl} - mock.recorder = &MockClientInterfaceMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockClientInterface) EXPECT() *MockClientInterfaceMockRecorder { - return m.recorder -} - -// AcceptMergeRequest mocks base method. -func (m *MockClientInterface) AcceptMergeRequest(pid any, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "AcceptMergeRequest", varargs...) - ret0, _ := ret[0].(*gitlab.MergeRequest) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// AcceptMergeRequest indicates an expected call of AcceptMergeRequest. -func (mr *MockClientInterfaceMockRecorder) AcceptMergeRequest(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcceptMergeRequest", reflect.TypeOf((*MockClientInterface)(nil).AcceptMergeRequest), varargs...) -} - -// AddMergeRequestDiscussionNote mocks base method. -func (m *MockClientInterface) AddMergeRequestDiscussionNote(pid any, mergeRequestIID int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, discussion, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "AddMergeRequestDiscussionNote", varargs...) - ret0, _ := ret[0].(*gitlab.Note) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// AddMergeRequestDiscussionNote indicates an expected call of AddMergeRequestDiscussionNote. -func (mr *MockClientInterfaceMockRecorder) AddMergeRequestDiscussionNote(pid, mergeRequestIID, discussion, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, discussion, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddMergeRequestDiscussionNote", reflect.TypeOf((*MockClientInterface)(nil).AddMergeRequestDiscussionNote), varargs...) -} - -// ApproveMergeRequest mocks base method. -func (m *MockClientInterface) ApproveMergeRequest(pid any, mergeRequestIID int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "ApproveMergeRequest", varargs...) - ret0, _ := ret[0].(*gitlab.MergeRequestApprovals) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// ApproveMergeRequest indicates an expected call of ApproveMergeRequest. -func (mr *MockClientInterfaceMockRecorder) ApproveMergeRequest(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApproveMergeRequest", reflect.TypeOf((*MockClientInterface)(nil).ApproveMergeRequest), varargs...) -} - -// CreateDraftNote mocks base method. -func (m *MockClientInterface) CreateDraftNote(pid any, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "CreateDraftNote", varargs...) - ret0, _ := ret[0].(*gitlab.DraftNote) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// CreateDraftNote indicates an expected call of CreateDraftNote. -func (mr *MockClientInterfaceMockRecorder) CreateDraftNote(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateDraftNote", reflect.TypeOf((*MockClientInterface)(nil).CreateDraftNote), varargs...) -} - -// CreateMergeRequest mocks base method. -func (m *MockClientInterface) CreateMergeRequest(pid any, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "CreateMergeRequest", varargs...) - ret0, _ := ret[0].(*gitlab.MergeRequest) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// CreateMergeRequest indicates an expected call of CreateMergeRequest. -func (mr *MockClientInterfaceMockRecorder) CreateMergeRequest(pid, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMergeRequest", reflect.TypeOf((*MockClientInterface)(nil).CreateMergeRequest), varargs...) -} - -// CreateMergeRequestAwardEmojiOnNote mocks base method. -func (m *MockClientInterface) CreateMergeRequestAwardEmojiOnNote(pid any, mergeRequestIID, noteID int, opt *gitlab.CreateAwardEmojiOptions, options ...gitlab.RequestOptionFunc) (*gitlab.AwardEmoji, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, noteID, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "CreateMergeRequestAwardEmojiOnNote", varargs...) - ret0, _ := ret[0].(*gitlab.AwardEmoji) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// CreateMergeRequestAwardEmojiOnNote indicates an expected call of CreateMergeRequestAwardEmojiOnNote. -func (mr *MockClientInterfaceMockRecorder) CreateMergeRequestAwardEmojiOnNote(pid, mergeRequestIID, noteID, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, noteID, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMergeRequestAwardEmojiOnNote", reflect.TypeOf((*MockClientInterface)(nil).CreateMergeRequestAwardEmojiOnNote), varargs...) -} - -// CreateMergeRequestDiscussion mocks base method. -func (m *MockClientInterface) CreateMergeRequestDiscussion(pid any, mergeRequestIID int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "CreateMergeRequestDiscussion", varargs...) - ret0, _ := ret[0].(*gitlab.Discussion) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// CreateMergeRequestDiscussion indicates an expected call of CreateMergeRequestDiscussion. -func (mr *MockClientInterfaceMockRecorder) CreateMergeRequestDiscussion(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateMergeRequestDiscussion", reflect.TypeOf((*MockClientInterface)(nil).CreateMergeRequestDiscussion), varargs...) -} - -// CurrentUser mocks base method. -func (m *MockClientInterface) CurrentUser(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "CurrentUser", varargs...) - ret0, _ := ret[0].(*gitlab.User) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// CurrentUser indicates an expected call of CurrentUser. -func (mr *MockClientInterfaceMockRecorder) CurrentUser(options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CurrentUser", reflect.TypeOf((*MockClientInterface)(nil).CurrentUser), options...) -} - -// DeleteDraftNote mocks base method. -func (m *MockClientInterface) DeleteDraftNote(pid any, mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequest, note} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "DeleteDraftNote", varargs...) - ret0, _ := ret[0].(*gitlab.Response) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// DeleteDraftNote indicates an expected call of DeleteDraftNote. -func (mr *MockClientInterfaceMockRecorder) DeleteDraftNote(pid, mergeRequest, note any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequest, note}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteDraftNote", reflect.TypeOf((*MockClientInterface)(nil).DeleteDraftNote), varargs...) -} - -// DeleteMergeRequestAwardEmojiOnNote mocks base method. -func (m *MockClientInterface) DeleteMergeRequestAwardEmojiOnNote(pid any, mergeRequestIID, noteID, awardID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, noteID, awardID} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "DeleteMergeRequestAwardEmojiOnNote", varargs...) - ret0, _ := ret[0].(*gitlab.Response) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// DeleteMergeRequestAwardEmojiOnNote indicates an expected call of DeleteMergeRequestAwardEmojiOnNote. -func (mr *MockClientInterfaceMockRecorder) DeleteMergeRequestAwardEmojiOnNote(pid, mergeRequestIID, noteID, awardID any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, noteID, awardID}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMergeRequestAwardEmojiOnNote", reflect.TypeOf((*MockClientInterface)(nil).DeleteMergeRequestAwardEmojiOnNote), varargs...) -} - -// DeleteMergeRequestDiscussionNote mocks base method. -func (m *MockClientInterface) DeleteMergeRequestDiscussionNote(pid any, mergeRequestIID int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, discussion, note} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "DeleteMergeRequestDiscussionNote", varargs...) - ret0, _ := ret[0].(*gitlab.Response) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// DeleteMergeRequestDiscussionNote indicates an expected call of DeleteMergeRequestDiscussionNote. -func (mr *MockClientInterfaceMockRecorder) DeleteMergeRequestDiscussionNote(pid, mergeRequestIID, discussion, note any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, discussion, note}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMergeRequestDiscussionNote", reflect.TypeOf((*MockClientInterface)(nil).DeleteMergeRequestDiscussionNote), varargs...) -} - -// GetMergeRequest mocks base method. -func (m *MockClientInterface) GetMergeRequest(pid any, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "GetMergeRequest", varargs...) - ret0, _ := ret[0].(*gitlab.MergeRequest) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetMergeRequest indicates an expected call of GetMergeRequest. -func (mr *MockClientInterfaceMockRecorder) GetMergeRequest(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMergeRequest", reflect.TypeOf((*MockClientInterface)(nil).GetMergeRequest), varargs...) -} - -// GetMergeRequestDiffVersions mocks base method. -func (m *MockClientInterface) GetMergeRequestDiffVersions(pid any, mergeRequestIID int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "GetMergeRequestDiffVersions", varargs...) - ret0, _ := ret[0].([]*gitlab.MergeRequestDiffVersion) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetMergeRequestDiffVersions indicates an expected call of GetMergeRequestDiffVersions. -func (mr *MockClientInterfaceMockRecorder) GetMergeRequestDiffVersions(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMergeRequestDiffVersions", reflect.TypeOf((*MockClientInterface)(nil).GetMergeRequestDiffVersions), varargs...) -} - -// GetTraceFile mocks base method. -func (m *MockClientInterface) GetTraceFile(pid any, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, jobID} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "GetTraceFile", varargs...) - ret0, _ := ret[0].(*bytes.Reader) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetTraceFile indicates an expected call of GetTraceFile. -func (mr *MockClientInterfaceMockRecorder) GetTraceFile(pid, jobID any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, jobID}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTraceFile", reflect.TypeOf((*MockClientInterface)(nil).GetTraceFile), varargs...) -} - -// ListAllProjectMembers mocks base method. -func (m *MockClientInterface) ListAllProjectMembers(pid any, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "ListAllProjectMembers", varargs...) - ret0, _ := ret[0].([]*gitlab.ProjectMember) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// ListAllProjectMembers indicates an expected call of ListAllProjectMembers. -func (mr *MockClientInterfaceMockRecorder) ListAllProjectMembers(pid, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAllProjectMembers", reflect.TypeOf((*MockClientInterface)(nil).ListAllProjectMembers), varargs...) -} - -// ListDraftNotes mocks base method. -func (m *MockClientInterface) ListDraftNotes(pid any, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequest, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "ListDraftNotes", varargs...) - ret0, _ := ret[0].([]*gitlab.DraftNote) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// ListDraftNotes indicates an expected call of ListDraftNotes. -func (mr *MockClientInterfaceMockRecorder) ListDraftNotes(pid, mergeRequest, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequest, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListDraftNotes", reflect.TypeOf((*MockClientInterface)(nil).ListDraftNotes), varargs...) -} - -// ListLabels mocks base method. -func (m *MockClientInterface) ListLabels(pid any, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "ListLabels", varargs...) - ret0, _ := ret[0].([]*gitlab.Label) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// ListLabels indicates an expected call of ListLabels. -func (mr *MockClientInterfaceMockRecorder) ListLabels(pid, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListLabels", reflect.TypeOf((*MockClientInterface)(nil).ListLabels), varargs...) -} - -// ListMergeRequestAwardEmojiOnNote mocks base method. -func (m *MockClientInterface) ListMergeRequestAwardEmojiOnNote(pid any, mergeRequestIID, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, noteID, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "ListMergeRequestAwardEmojiOnNote", varargs...) - ret0, _ := ret[0].([]*gitlab.AwardEmoji) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// ListMergeRequestAwardEmojiOnNote indicates an expected call of ListMergeRequestAwardEmojiOnNote. -func (mr *MockClientInterfaceMockRecorder) ListMergeRequestAwardEmojiOnNote(pid, mergeRequestIID, noteID, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, noteID, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListMergeRequestAwardEmojiOnNote", reflect.TypeOf((*MockClientInterface)(nil).ListMergeRequestAwardEmojiOnNote), varargs...) -} - -// ListMergeRequestDiscussions mocks base method. -func (m *MockClientInterface) ListMergeRequestDiscussions(pid any, mergeRequestIID int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "ListMergeRequestDiscussions", varargs...) - ret0, _ := ret[0].([]*gitlab.Discussion) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// ListMergeRequestDiscussions indicates an expected call of ListMergeRequestDiscussions. -func (mr *MockClientInterfaceMockRecorder) ListMergeRequestDiscussions(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListMergeRequestDiscussions", reflect.TypeOf((*MockClientInterface)(nil).ListMergeRequestDiscussions), varargs...) -} - -// ListPipelineJobs mocks base method. -func (m *MockClientInterface) ListPipelineJobs(pid any, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, pipelineID, opts} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "ListPipelineJobs", varargs...) - ret0, _ := ret[0].([]*gitlab.Job) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// ListPipelineJobs indicates an expected call of ListPipelineJobs. -func (mr *MockClientInterfaceMockRecorder) ListPipelineJobs(pid, pipelineID, opts any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, pipelineID, opts}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPipelineJobs", reflect.TypeOf((*MockClientInterface)(nil).ListPipelineJobs), varargs...) -} - -// ListProjectMergeRequests mocks base method. -func (m *MockClientInterface) ListProjectMergeRequests(pid any, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "ListProjectMergeRequests", varargs...) - ret0, _ := ret[0].([]*gitlab.MergeRequest) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// ListProjectMergeRequests indicates an expected call of ListProjectMergeRequests. -func (mr *MockClientInterfaceMockRecorder) ListProjectMergeRequests(pid, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProjectMergeRequests", reflect.TypeOf((*MockClientInterface)(nil).ListProjectMergeRequests), varargs...) -} - -// ListProjectPipelines mocks base method. -func (m *MockClientInterface) ListProjectPipelines(pid any, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "ListProjectPipelines", varargs...) - ret0, _ := ret[0].([]*gitlab.PipelineInfo) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// ListProjectPipelines indicates an expected call of ListProjectPipelines. -func (mr *MockClientInterfaceMockRecorder) ListProjectPipelines(pid, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProjectPipelines", reflect.TypeOf((*MockClientInterface)(nil).ListProjectPipelines), varargs...) -} - -// PublishAllDraftNotes mocks base method. -func (m *MockClientInterface) PublishAllDraftNotes(pid any, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequest} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "PublishAllDraftNotes", varargs...) - ret0, _ := ret[0].(*gitlab.Response) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// PublishAllDraftNotes indicates an expected call of PublishAllDraftNotes. -func (mr *MockClientInterfaceMockRecorder) PublishAllDraftNotes(pid, mergeRequest any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequest}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishAllDraftNotes", reflect.TypeOf((*MockClientInterface)(nil).PublishAllDraftNotes), varargs...) -} - -// PublishDraftNote mocks base method. -func (m *MockClientInterface) PublishDraftNote(pid any, mergeRequest, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequest, note} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "PublishDraftNote", varargs...) - ret0, _ := ret[0].(*gitlab.Response) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// PublishDraftNote indicates an expected call of PublishDraftNote. -func (mr *MockClientInterfaceMockRecorder) PublishDraftNote(pid, mergeRequest, note any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequest, note}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishDraftNote", reflect.TypeOf((*MockClientInterface)(nil).PublishDraftNote), varargs...) -} - -// ResolveMergeRequestDiscussion mocks base method. -func (m *MockClientInterface) ResolveMergeRequestDiscussion(pid any, mergeRequestIID int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, discussion, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "ResolveMergeRequestDiscussion", varargs...) - ret0, _ := ret[0].(*gitlab.Discussion) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// ResolveMergeRequestDiscussion indicates an expected call of ResolveMergeRequestDiscussion. -func (mr *MockClientInterfaceMockRecorder) ResolveMergeRequestDiscussion(pid, mergeRequestIID, discussion, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, discussion, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveMergeRequestDiscussion", reflect.TypeOf((*MockClientInterface)(nil).ResolveMergeRequestDiscussion), varargs...) -} - -// RetryPipelineBuild mocks base method. -func (m *MockClientInterface) RetryPipelineBuild(pid any, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, pipeline} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "RetryPipelineBuild", varargs...) - ret0, _ := ret[0].(*gitlab.Pipeline) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// RetryPipelineBuild indicates an expected call of RetryPipelineBuild. -func (mr *MockClientInterfaceMockRecorder) RetryPipelineBuild(pid, pipeline any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, pipeline}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RetryPipelineBuild", reflect.TypeOf((*MockClientInterface)(nil).RetryPipelineBuild), varargs...) -} - -// UnapproveMergeRequest mocks base method. -func (m *MockClientInterface) UnapproveMergeRequest(pid any, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "UnapproveMergeRequest", varargs...) - ret0, _ := ret[0].(*gitlab.Response) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// UnapproveMergeRequest indicates an expected call of UnapproveMergeRequest. -func (mr *MockClientInterfaceMockRecorder) UnapproveMergeRequest(pid, mergeRequestIID any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnapproveMergeRequest", reflect.TypeOf((*MockClientInterface)(nil).UnapproveMergeRequest), varargs...) -} - -// UpdateDraftNote mocks base method. -func (m *MockClientInterface) UpdateDraftNote(pid any, mergeRequest, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequest, note, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "UpdateDraftNote", varargs...) - ret0, _ := ret[0].(*gitlab.DraftNote) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// UpdateDraftNote indicates an expected call of UpdateDraftNote. -func (mr *MockClientInterfaceMockRecorder) UpdateDraftNote(pid, mergeRequest, note, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequest, note, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDraftNote", reflect.TypeOf((*MockClientInterface)(nil).UpdateDraftNote), varargs...) -} - -// UpdateMergeRequest mocks base method. -func (m *MockClientInterface) UpdateMergeRequest(pid any, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "UpdateMergeRequest", varargs...) - ret0, _ := ret[0].(*gitlab.MergeRequest) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// UpdateMergeRequest indicates an expected call of UpdateMergeRequest. -func (mr *MockClientInterfaceMockRecorder) UpdateMergeRequest(pid, mergeRequestIID, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMergeRequest", reflect.TypeOf((*MockClientInterface)(nil).UpdateMergeRequest), varargs...) -} - -// UpdateMergeRequestDiscussionNote mocks base method. -func (m *MockClientInterface) UpdateMergeRequestDiscussionNote(pid any, mergeRequestIID int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, mergeRequestIID, discussion, note, opt} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "UpdateMergeRequestDiscussionNote", varargs...) - ret0, _ := ret[0].(*gitlab.Note) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// UpdateMergeRequestDiscussionNote indicates an expected call of UpdateMergeRequestDiscussionNote. -func (mr *MockClientInterfaceMockRecorder) UpdateMergeRequestDiscussionNote(pid, mergeRequestIID, discussion, note, opt any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, mergeRequestIID, discussion, note, opt}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMergeRequestDiscussionNote", reflect.TypeOf((*MockClientInterface)(nil).UpdateMergeRequestDiscussionNote), varargs...) -} - -// UploadFile mocks base method. -func (m *MockClientInterface) UploadFile(pid any, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) { - m.ctrl.T.Helper() - varargs := []any{pid, content, filename} - for _, a := range options { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "UploadFile", varargs...) - ret0, _ := ret[0].(*gitlab.ProjectFile) - ret1, _ := ret[1].(*gitlab.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// UploadFile indicates an expected call of UploadFile. -func (mr *MockClientInterfaceMockRecorder) UploadFile(pid, content, filename any, options ...any) *gomock.Call { - mr.mock.ctrl.T.Helper() - varargs := append([]any{pid, content, filename}, options...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadFile", reflect.TypeOf((*MockClientInterface)(nil).UploadFile), varargs...) -} diff --git a/cmd/mocks/helpers.go b/cmd/mocks/helpers.go deleted file mode 100644 index 43f6858c..00000000 --- a/cmd/mocks/helpers.go +++ /dev/null @@ -1,60 +0,0 @@ -package mock_main - -import ( - bytes "bytes" - io "io" - "net/http" - "testing" - - gitlab "github.com/xanzy/go-gitlab" - gomock "go.uber.org/mock/gomock" -) - -var MergeId = 3 - -func NewListMrOptions() *gitlab.ListProjectMergeRequestsOptions { - return &gitlab.ListProjectMergeRequestsOptions{ - Scope: gitlab.Ptr("all"), - State: gitlab.Ptr("opened"), - SourceBranch: gitlab.Ptr(""), - } -} - -/* Make response makes a simple response value with the right status code */ -func makeResponse(status int) *gitlab.Response { - return &gitlab.Response{ - Response: &http.Response{ - StatusCode: status, - }, - } -} - -type MockOpts struct { - MergeId int -} - -func NewMockClient(t *testing.T) *MockClientInterface { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - mockObj := NewMockClientInterface(ctrl) - return mockObj -} - -/** Adds a handler to satisfy the withMrs middleware by returning an MR from that endpoint with the given ID */ -func WithMr(t *testing.T, m *MockClientInterface) *MockClientInterface { - options := gitlab.ListProjectMergeRequestsOptions{ - Scope: gitlab.Ptr("all"), - State: gitlab.Ptr("opened"), - SourceBranch: gitlab.Ptr(""), - } - - m.EXPECT().ListProjectMergeRequests("", &options).Return([]*gitlab.MergeRequest{{IID: MergeId}}, makeResponse(http.StatusOK), nil) - - return m -} - -type MockAttachmentReader struct{} - -func (mf MockAttachmentReader) ReadFile(path string) (io.Reader, error) { - return bytes.NewReader([]byte{}), nil -} diff --git a/cmd/pipeline_test.go b/cmd/pipeline_test.go deleted file mode 100644 index 8657f079..00000000 --- a/cmd/pipeline_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "testing" - - "github.com/xanzy/go-gitlab" - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" - "go.uber.org/mock/gomock" -) - -var testPipelineId = 12435 -var testPipelineCommit = "abc123" -var fakeProjectPipelines = []*gitlab.PipelineInfo{{ID: testPipelineId}} - -/* This helps us stub out git interactions that the server would normally run in the project directory */ -func withGitInfo(a *Api) error { - a.gitInfo.GetLatestCommitOnRemote = func(a *Api) (string, error) { - return testPipelineCommit, nil - } - a.gitInfo.BranchName = "some-feature" - return nil -} - -func TestPipelineHandler(t *testing.T) { - t.Run("Gets all pipeline jobs", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().ListProjectPipelines("", gomock.Any()).Return(fakeProjectPipelines, makeResponse(http.StatusOK), nil) - client.EXPECT().ListPipelineJobs("", testPipelineId, &gitlab.ListJobsOptions{}).Return([]*gitlab.Job{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodGet, "/pipeline", nil) - server, _ := CreateRouterAndApi(client, withGitInfo) - data := serveRequest(t, server, request, GetPipelineAndJobsResponse{}) - - assert(t, data.SuccessResponse.Message, "Pipeline retrieved") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - - t.Run("Disallows non-GET, non-POST methods", func(t *testing.T) { - client := mock_main.NewMockClient(t) - request := makeRequest(t, http.MethodPatch, "/pipeline", nil) - server, _ := CreateRouterAndApi(client, withGitInfo) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkBadMethod(t, *data, http.MethodGet, http.MethodPost) - }) - - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().ListProjectPipelines("", gomock.Any()).Return(fakeProjectPipelines, makeResponse(http.StatusOK), nil) - client.EXPECT().ListPipelineJobs("", testPipelineId, &gitlab.ListJobsOptions{}).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodGet, "/pipeline", nil) - server, _ := CreateRouterAndApi(client, withGitInfo) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkErrorFromGitlab(t, *data, "Could not get pipeline jobs") - }) - - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().ListProjectPipelines("", gomock.Any()).Return(fakeProjectPipelines, makeResponse(http.StatusOK), nil) - client.EXPECT().ListPipelineJobs("", testPipelineId, &gitlab.ListJobsOptions{}).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodGet, "/pipeline", nil) - server, _ := CreateRouterAndApi(client, withGitInfo) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkNon200(t, *data, "Could not get pipeline jobs", "/pipeline") - }) - - t.Run("Retriggers pipeline", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().RetryPipelineBuild("", testPipelineId).Return(&gitlab.Pipeline{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, fmt.Sprintf("/pipeline/trigger/%d", testPipelineId), nil) - server, _ := CreateRouterAndApi(client, withGitInfo) - - data := serveRequest(t, server, request, GetPipelineAndJobsResponse{}) - assert(t, data.SuccessResponse.Message, "Pipeline retriggered") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - - t.Run("Handles non-200s from Gitlab client on retrigger", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().RetryPipelineBuild("", testPipelineId).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodPost, fmt.Sprintf("/pipeline/trigger/%d", testPipelineId), nil) - server, _ := CreateRouterAndApi(client, withGitInfo) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkNon200(t, *data, "Could not retrigger pipeline", "/pipeline") - }) - - t.Run("Handles error from Gitlab client on retrigger", func(t *testing.T) { - client := mock_main.NewMockClient(t) - client.EXPECT().RetryPipelineBuild("", testPipelineId).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodPost, fmt.Sprintf("/pipeline/trigger/%d", testPipelineId), nil) - server, _ := CreateRouterAndApi(client, withGitInfo) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkErrorFromGitlab(t, *data, "Could not retrigger pipeline") - }) -} diff --git a/cmd/reply_test.go b/cmd/reply_test.go deleted file mode 100644 index 51d48071..00000000 --- a/cmd/reply_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "net/http" - "testing" - - "github.com/xanzy/go-gitlab" - mock_main "gitlab.com/harrisoncramer/gitlab.nvim/cmd/mocks" - "go.uber.org/mock/gomock" -) - -var testReplyRequest = ReplyRequest{ - DiscussionId: "abc123", - Reply: "Some Reply", - IsDraft: false, -} - -func TestReplyHandler(t *testing.T) { - t.Run("Sends a reply", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().AddMergeRequestDiscussionNote( - "", - mock_main.MergeId, - testReplyRequest.DiscussionId, - gomock.Any(), - ).Return(&gitlab.Note{}, makeResponse(http.StatusOK), nil) - - request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) - server, _ := CreateRouterAndApi(client) - - data := serveRequest(t, server, request, ReplyResponse{}) - assert(t, data.SuccessResponse.Message, "Replied to comment") - assert(t, data.SuccessResponse.Status, http.StatusOK) - }) - - t.Run("Disallows non-POST methods", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - - request := makeRequest(t, http.MethodPut, "/mr/reply", testReplyRequest) - server, _ := CreateRouterAndApi(client) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkBadMethod(t, *data, http.MethodPost) - }) - - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().AddMergeRequestDiscussionNote( - "", - mock_main.MergeId, - testReplyRequest.DiscussionId, - gomock.Any(), - ).Return(nil, nil, errorFromGitlab) - - request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) - server, _ := CreateRouterAndApi(client) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkErrorFromGitlab(t, *data, "Could not leave reply") - }) - - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - client := mock_main.NewMockClient(t) - mock_main.WithMr(t, client) - client.EXPECT().AddMergeRequestDiscussionNote( - "", - mock_main.MergeId, - testReplyRequest.DiscussionId, - gomock.Any(), - ).Return(nil, makeResponse(http.StatusSeeOther), nil) - - request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) - server, _ := CreateRouterAndApi(client) - - data := serveRequest(t, server, request, ErrorResponse{}) - checkNon200(t, *data, "Could not leave reply", "/mr/reply") - }) -} diff --git a/cmd/server.go b/cmd/server.go deleted file mode 100644 index 733768a5..00000000 --- a/cmd/server.go +++ /dev/null @@ -1,222 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "net" - "net/http" - "os" - "strconv" - "time" - - "github.com/xanzy/go-gitlab" -) - -/* -startSever starts the server and runs concurrent goroutines -to handle potential shutdown requests and incoming HTTP requests. -*/ -func startServer(client *Client, projectInfo *ProjectInfo, gitInfo GitProjectInfo) { - - m, a := CreateRouterAndApi(client, - func(a *Api) error { - a.projectInfo = projectInfo - return nil - }, - func(a *Api) error { - a.fileReader = attachmentReader{} - return nil - }, - func(a *Api) error { - a.gitInfo = &gitInfo - return nil - }, - func(a *Api) error { - err := attachEmojisToApi(a) - return err - }, - func(a *Api) error { - a.gitInfo.GetLatestCommitOnRemote = GetLatestCommitOnRemote - return nil - }) - l := createListener() - server := &http.Server{Handler: m} - - /* Starts the Go server */ - go func() { - err := server.Serve(l) - if err != nil { - if errors.Is(err, http.ErrServerClosed) { - os.Exit(0) - } else { - fmt.Fprintf(os.Stderr, "Server did not respond: %s\n", err) - os.Exit(1) - } - } - }() - - port := l.Addr().(*net.TCPAddr).Port - err := checkServer(port) - if err != nil { - fmt.Fprintf(os.Stderr, "Server did not respond: %s\n", err) - os.Exit(1) - } - - /* This print is detected by the Lua code */ - fmt.Println("Server started on port: ", port) - - /* Handles shutdown requests */ - <-a.sigCh - err = server.Shutdown(context.Background()) - if err != nil { - fmt.Fprintf(os.Stderr, "Server could not shut down gracefully: %s\n", err) - os.Exit(1) - } else { - os.Exit(0) - } -} - -/* -The Api struct contains common configuration that's accessible to all handlers, such as the gitlab -client, the project information, and the channels for signaling error or shutdown requests - -The handlers for different Gitlab operations are are all methods on the Api struct and interact -with the client value, which is a go-gitlab client. -*/ -type Api struct { - client ClientInterface - projectInfo *ProjectInfo - gitInfo *GitProjectInfo - fileReader FileReader - emojiMap EmojiMap - sigCh chan os.Signal -} - -type optFunc func(a *Api) error - -/* -CreateRouterAndApi wires up the router and attaches all handlers to their respective routes. It also -iterates over all option functions to configure API fields such as the project information and default -file reader functionality -*/ - -func CreateRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.ServeMux, Api) { - m := http.NewServeMux() - a := Api{ - client: client, - projectInfo: &ProjectInfo{}, - gitInfo: &GitProjectInfo{}, - fileReader: nil, - emojiMap: EmojiMap{}, - sigCh: make(chan os.Signal, 1), - } - - /* Mutates the API struct as necessary with configuration functions */ - for _, optFunc := range optFuncs { - err := optFunc(&a) - if err != nil { - panic(err) - } - } - - m.HandleFunc("/mr/approve", a.withMr(a.approveHandler)) - m.HandleFunc("/mr/comment", a.withMr(a.commentHandler)) - m.HandleFunc("/mr/merge", a.withMr(a.acceptAndMergeHandler)) - m.HandleFunc("/mr/discussions/list", a.withMr(a.listDiscussionsHandler)) - m.HandleFunc("/mr/discussions/resolve", a.withMr(a.discussionsResolveHandler)) - m.HandleFunc("/mr/info", a.withMr(a.infoHandler)) - m.HandleFunc("/mr/assignee", a.withMr(a.assigneesHandler)) - m.HandleFunc("/mr/summary", a.withMr(a.summaryHandler)) - m.HandleFunc("/mr/reviewer", a.withMr(a.reviewersHandler)) - m.HandleFunc("/mr/revisions", a.withMr(a.revisionsHandler)) - m.HandleFunc("/mr/reply", a.withMr(a.replyHandler)) - m.HandleFunc("/mr/label", a.withMr(a.labelHandler)) - m.HandleFunc("/mr/revoke", a.withMr(a.revokeHandler)) - m.HandleFunc("/mr/awardable/note/", a.withMr(a.emojiNoteHandler)) - m.HandleFunc("/mr/draft_notes/", a.withMr(a.draftNoteHandler)) - m.HandleFunc("/mr/draft_notes/publish", a.withMr(a.draftNotePublisher)) - - m.HandleFunc("/pipeline", a.pipelineHandler) - m.HandleFunc("/pipeline/trigger/", a.pipelineHandler) - m.HandleFunc("/users/me", a.meHandler) - m.HandleFunc("/attachment", a.attachmentHandler) - m.HandleFunc("/create_mr", a.createMr) - m.HandleFunc("/job", a.jobHandler) - m.HandleFunc("/project/members", a.projectMembersHandler) - m.HandleFunc("/shutdown", a.shutdownHandler) - m.HandleFunc("/merge_requests", a.mergeRequestsHandler) - - m.Handle("/ping", http.HandlerFunc(pingHandler)) - - return m, a -} - -/* Used to check whether the server has started yet */ -func pingHandler(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fmt.Fprintln(w, "pong") -} - -/* checkServer pings the server repeatedly for 1 full second after startup in order to notify the plugin that the server is ready */ -func checkServer(port int) error { - for i := 0; i < 10; i++ { - resp, err := http.Get("http://localhost:" + fmt.Sprintf("%d", port) + "/ping") - if resp.StatusCode == 200 && err == nil { - return nil - } - time.Sleep(100 * time.Microsecond) - } - - return errors.New("Could not start server!") -} - -/* Creates a TCP listener on the port specified by the user or a random port */ -func createListener() (l net.Listener) { - addr := fmt.Sprintf("localhost:%d", pluginOptions.Port) - l, err := net.Listen("tcp", addr) - if err != nil { - fmt.Fprintf(os.Stderr, "Error starting server: %s\n", err) - os.Exit(1) - } - - return l -} - -/* withMr is a Middlware that gets the current merge request ID and attaches it to the projectInfo */ -func (a *Api) withMr(f func(w http.ResponseWriter, r *http.Request)) func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - - if a.projectInfo.MergeId != 0 { - f(w, r) - return - } - - options := gitlab.ListProjectMergeRequestsOptions{ - Scope: gitlab.Ptr("all"), - State: gitlab.Ptr("opened"), - SourceBranch: &a.gitInfo.BranchName, - } - - mergeRequests, _, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, &options) - if err != nil { - handleError(w, fmt.Errorf("Failed to list merge requests: %w", err), "Failed to list merge requests", http.StatusInternalServerError) - return - } - - if len(mergeRequests) == 0 { - handleError(w, fmt.Errorf("No merge requests found for branch '%s'", a.gitInfo.BranchName), "No merge requests found", http.StatusBadRequest) - return - } - - mergeId := strconv.Itoa(mergeRequests[0].IID) - mergeIdInt, err := strconv.Atoi(mergeId) - if err != nil { - handleError(w, err, "Could not convert merge ID to integer", http.StatusBadRequest) - return - } - - a.projectInfo.MergeId = mergeIdInt - f(w, r) - } -} diff --git a/cmd/types.go b/cmd/types.go deleted file mode 100644 index 25bd4088..00000000 --- a/cmd/types.go +++ /dev/null @@ -1,69 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "io" - - "github.com/xanzy/go-gitlab" -) - -type ErrorResponse struct { - Message string `json:"message"` - Details string `json:"details"` - Status int `json:"status"` -} - -type SuccessResponse struct { - Message string `json:"message"` - Status int `json:"status"` -} - -type GenericError struct { - endpoint string -} - -func (e GenericError) Error() string { - return fmt.Sprintf("An error occurred on the %s endpoint", e.endpoint) -} - -type InvalidRequestError struct{} - -func (e InvalidRequestError) Error() string { - return "Invalid request type" -} - -/* The ClientInterface interface implements all the methods that our handlers need */ -type ClientInterface interface { - CreateMergeRequest(pid interface{}, opt *gitlab.CreateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) - GetMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - AcceptMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - UpdateMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) - UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) - GetMergeRequestDiffVersions(pid interface{}, mergeRequestIID int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) - ApproveMergeRequest(pid interface{}, mergeRequestIID int, opt *gitlab.ApproveMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequestApprovals, *gitlab.Response, error) - UnapproveMergeRequest(pid interface{}, mergeRequestIID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) - ListMergeRequestDiscussions(pid interface{}, mergeRequestIID int, opt *gitlab.ListMergeRequestDiscussionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) - ResolveMergeRequestDiscussion(pid interface{}, mergeRequestIID int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) - CreateMergeRequestDiscussion(pid interface{}, mergeRequestIID int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) - UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) - DeleteMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) - CreateDraftNote(pid interface{}, mergeRequestIID int, opt *gitlab.CreateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) - ListDraftNotes(pid interface{}, mergeRequest int, opt *gitlab.ListDraftNotesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.DraftNote, *gitlab.Response, error) - DeleteDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) - UpdateDraftNote(pid interface{}, mergeRequest int, note int, opt *gitlab.UpdateDraftNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.DraftNote, *gitlab.Response, error) - PublishDraftNote(pid interface{}, mergeRequest int, note int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) - PublishAllDraftNotes(pid interface{}, mergeRequest int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) - AddMergeRequestDiscussionNote(pid interface{}, mergeRequestIID int, discussion string, opt *gitlab.AddMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) - ListAllProjectMembers(pid interface{}, opt *gitlab.ListProjectMembersOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.ProjectMember, *gitlab.Response, error) - RetryPipelineBuild(pid interface{}, pipeline int, options ...gitlab.RequestOptionFunc) (*gitlab.Pipeline, *gitlab.Response, error) - ListPipelineJobs(pid interface{}, pipelineID int, opts *gitlab.ListJobsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Job, *gitlab.Response, error) - ListProjectPipelines(pid interface{}, opt *gitlab.ListProjectPipelinesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.PipelineInfo, *gitlab.Response, error) - GetTraceFile(pid interface{}, jobID int, options ...gitlab.RequestOptionFunc) (*bytes.Reader, *gitlab.Response, error) - ListLabels(pid interface{}, opt *gitlab.ListLabelsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Label, *gitlab.Response, error) - ListMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.ListAwardEmojiOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.AwardEmoji, *gitlab.Response, error) - CreateMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID int, noteID int, opt *gitlab.CreateAwardEmojiOptions, options ...gitlab.RequestOptionFunc) (*gitlab.AwardEmoji, *gitlab.Response, error) - DeleteMergeRequestAwardEmojiOnNote(pid interface{}, mergeRequestIID, noteID, awardID int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) - CurrentUser(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) -} diff --git a/cmd/utils.go b/cmd/utils.go deleted file mode 100644 index 00251b8a..00000000 --- a/cmd/utils.go +++ /dev/null @@ -1,10 +0,0 @@ -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/go.mod b/go.mod index 6167caf3..6410a147 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,19 @@ -module gitlab.com/harrisoncramer/gitlab.nvim +module github.com/harrisoncramer/gitlab.nvim go 1.19 require ( - github.com/hashicorp/go-retryablehttp v0.7.2 - github.com/xanzy/go-gitlab v0.102.0 + github.com/hashicorp/go-retryablehttp v0.7.7 + github.com/xanzy/go-gitlab v0.108.0 ) require ( github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - go.uber.org/mock v0.4.0 // indirect - golang.org/x/mod v0.11.0 // indirect golang.org/x/net v0.8.0 // indirect golang.org/x/oauth2 v0.6.0 // indirect - golang.org/x/sys v0.6.0 // indirect golang.org/x/time v0.3.0 // indirect - golang.org/x/tools v0.2.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.29.1 // indirect ) diff --git a/go.sum b/go.sum index d1992dc2..f00dd864 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= @@ -11,36 +11,28 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= -github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= -github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0= -github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/xanzy/go-gitlab v0.102.0 h1:ExHuJ1OTQ2yt25zBMMj0G96ChBirGYv8U7HyUiYkZ+4= -github.com/xanzy/go-gitlab v0.102.0/go.mod h1:ETg8tcj4OhrB84UEgeE8dSuV/0h4BBL1uOV/qK0vlyI= -go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= -go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +github.com/xanzy/go-gitlab v0.108.0 h1:IEvEUWFR5G1seslRhJ8gC//INiIUqYXuSUoBd7/gFKE= +github.com/xanzy/go-gitlab v0.108.0/go.mod h1:wKNKh3GkYDMOsGmnfuX+ITCmDuSDWFO0G+C4AygL9RY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= -golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.2.0 h1:G6AHpWxTMGY1KyEYoAQ5WTtIekUUvDNjan3ugu60JvE= -golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= diff --git a/makefile b/makefile index 626ad683..83e9c65f 100644 --- a/makefile +++ b/makefile @@ -7,7 +7,7 @@ compile: @cd cmd && go build -o bin && mv bin ../bin ## test: run golang project tests test: - @cd cmd && go test + @cd cmd/app && go test .PHONY: help all: help From a02de360a3678e0ed5af26c347ed64d1a5c422e8 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sat, 7 Sep 2024 13:15:57 -0400 Subject: [PATCH 86/97] fix: Fixes MR selection if there are multiple targets (#357) fix: If there are multiple targets from a single branch, warns the user and requires selection of a specific one --- cmd/app/config.go | 1 + cmd/app/server.go | 7 +++++++ lua/gitlab/actions/merge_requests.lua | 1 + lua/gitlab/server.lua | 5 ++++- lua/gitlab/state.lua | 4 ++++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cmd/app/config.go b/cmd/app/config.go index 5b35b9e8..166ac391 100644 --- a/cmd/app/config.go +++ b/cmd/app/config.go @@ -9,6 +9,7 @@ type PluginOptions struct { Request bool `json:"go_request"` Response bool `json:"go_response"` } `json:"debug"` + ChosenTargetBranch *string `json:"chosen_target_branch,omitempty"` ConnectionSettings struct { Insecure bool `json:"insecure"` Remote string `json:"remote"` diff --git a/cmd/app/server.go b/cmd/app/server.go index 73df0c3c..82106748 100644 --- a/cmd/app/server.go +++ b/cmd/app/server.go @@ -168,6 +168,7 @@ func withMr(svc ServiceWithHandler, c data, client MergeRequestLister) http.Hand Scope: gitlab.Ptr("all"), State: gitlab.Ptr("opened"), SourceBranch: &c.gitInfo.BranchName, + TargetBranch: pluginOptions.ChosenTargetBranch, } mergeRequests, _, err := client.ListProjectMergeRequests(c.projectInfo.ProjectId, &options) @@ -182,6 +183,12 @@ func withMr(svc ServiceWithHandler, c data, client MergeRequestLister) http.Hand return } + if len(mergeRequests) > 1 { + err := errors.New("Please call gitlab.choose_merge_request()") + handleError(w, err, "Multiple MRs found", http.StatusBadRequest) + return + } + mergeIdInt := mergeRequests[0].IID c.projectInfo.MergeId = mergeIdInt } diff --git a/lua/gitlab/actions/merge_requests.lua b/lua/gitlab/actions/merge_requests.lua index 85ea15fa..b42b94b6 100644 --- a/lua/gitlab/actions/merge_requests.lua +++ b/lua/gitlab/actions/merge_requests.lua @@ -45,6 +45,7 @@ M.choose_merge_request = function(opts) end vim.schedule(function() + state.chosen_target_branch = choice.target_branch require("gitlab.server").restart(function() if opts.open_reviewer then require("gitlab").review() diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua index 7f1ad6a9..8efb338a 100644 --- a/lua/gitlab/server.lua +++ b/lua/gitlab/server.lua @@ -20,8 +20,11 @@ M.start = function(callback) debug = state.settings.debug, log_path = state.settings.log_path, connection_settings = state.settings.connection_settings, + chosen_target_branch = state.chosen_target_branch, } + state.chosen_target_branch = nil -- Do not let this interfere with subsequent reviewer.open() calls + local settings = vim.json.encode(go_server_settings) local command = string.format("%s '%s'", state.settings.bin, settings) @@ -117,7 +120,7 @@ M.shutdown = function(cb) end) end --- Restarts the Go server and clears out all gitlab.nvim state +---Restarts the Go server and clears out all gitlab.nvim state M.restart = function(cb) if not state.go_server_running then vim.notify("The gitlab.nvim server is not running", vim.log.levels.ERROR) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index a5b8f583..f4e04d4d 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -247,6 +247,10 @@ M.unlinked_discussion_tree = { unresolved_expanded = false, } +-- Used to set a specific target when choosing a merge request, due to the fact +-- that it's technically possible to have multiple target branches +M.chosen_target_branch = nil + -- These keymaps are set globally when the plugin is initialized M.set_global_keymaps = function() local keymaps = M.settings.keymaps From b3c5dfda5114af8b7157a2db34dfad848e1ad984 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sat, 7 Sep 2024 15:00:30 -0400 Subject: [PATCH 87/97] feat: Improve MR selection (#359) feat: Allows for the passing of Gitlab's filter options when choosing an MR, improves MR selection --- cmd/app/label.go | 2 +- cmd/app/merge_requests.go | 20 ++++++++------------ cmd/app/merge_requests_test.go | 2 +- cmd/app/server.go | 1 - doc/gitlab.nvim.txt | 15 ++++++++------- lua/gitlab/reviewer/init.lua | 8 ++++++++ lua/gitlab/state.lua | 15 +++++++-------- 7 files changed, 33 insertions(+), 30 deletions(-) diff --git a/cmd/app/label.go b/cmd/app/label.go index af09088e..d2c7b998 100644 --- a/cmd/app/label.go +++ b/cmd/app/label.go @@ -52,7 +52,7 @@ func (a labelService) handler(w http.ResponseWriter, r *http.Request) { } } -func (a labelService) getLabels(w http.ResponseWriter, r *http.Request) { +func (a labelService) getLabels(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") labels, res, err := a.client.ListLabels(a.projectInfo.ProjectId, &gitlab.ListLabelsOptions{}) diff --git a/cmd/app/merge_requests.go b/cmd/app/merge_requests.go index 0e1af5c1..bc7d1ab1 100644 --- a/cmd/app/merge_requests.go +++ b/cmd/app/merge_requests.go @@ -9,11 +9,6 @@ import ( "github.com/xanzy/go-gitlab" ) -type ListMergeRequestRequest struct { - Label []string `json:"label"` - NotLabel []string `json:"notlabel"` -} - type ListMergeRequestResponse struct { SuccessResponse MergeRequests []*gitlab.MergeRequest `json:"merge_requests"` @@ -43,21 +38,22 @@ func (a mergeRequestListerService) handler(w http.ResponseWriter, r *http.Reques } defer r.Body.Close() - var listMergeRequestRequest ListMergeRequestRequest + var listMergeRequestRequest gitlab.ListProjectMergeRequestsOptions err = json.Unmarshal(body, &listMergeRequestRequest) if err != nil { handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) return } - options := gitlab.ListProjectMergeRequestsOptions{ - Scope: gitlab.Ptr("all"), - State: gitlab.Ptr("opened"), - Labels: (*gitlab.LabelOptions)(&listMergeRequestRequest.Label), - NotLabels: (*gitlab.LabelOptions)(&listMergeRequestRequest.NotLabel), + if listMergeRequestRequest.State == nil { + listMergeRequestRequest.State = gitlab.Ptr("opened") + } + + if listMergeRequestRequest.Scope == nil { + listMergeRequestRequest.Scope = gitlab.Ptr("all") } - mergeRequests, res, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, &options) + mergeRequests, res, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, &listMergeRequestRequest) if err != nil { handleError(w, err, "Failed to list merge requests", http.StatusInternalServerError) diff --git a/cmd/app/merge_requests_test.go b/cmd/app/merge_requests_test.go index 1439da98..79020ce8 100644 --- a/cmd/app/merge_requests_test.go +++ b/cmd/app/merge_requests_test.go @@ -26,7 +26,7 @@ func (f fakeMergeRequestLister) ListProjectMergeRequests(pid interface{}, opt *g } func TestMergeRequestHandler(t *testing.T) { - var testListMergeRequestsRequest = ListMergeRequestRequest{Label: []string{}, NotLabel: []string{}} + var testListMergeRequestsRequest = gitlab.ListProjectMergeRequestsOptions{} t.Run("Should fetch merge requests", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{}} diff --git a/cmd/app/server.go b/cmd/app/server.go index 82106748..200ea30a 100644 --- a/cmd/app/server.go +++ b/cmd/app/server.go @@ -166,7 +166,6 @@ func withMr(svc ServiceWithHandler, c data, client MergeRequestLister) http.Hand if c.projectInfo.MergeId == 0 { options := gitlab.ListProjectMergeRequestsOptions{ Scope: gitlab.Ptr("all"), - State: gitlab.Ptr("opened"), SourceBranch: &c.gitInfo.BranchName, TargetBranch: pluginOptions.ChosenTargetBranch, } diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index d43afa2c..eb79b472 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -739,22 +739,23 @@ Choose a merge request from a list of those open in your current project to review. This command will automatically check out the feature branch locally and open the reviewer pane (this can be overridden with the `open_reviewer` parameter. + You can also filter merge requests by specifying `label` and `notlabel` -parameters. +parameters, or any other parameter included in list MRs API. + +By default, the endpoint will return all open merge requests. >lua require("gitlab").choose_merge_request() require("gitlab").choose_merge_request({ open_reviewer = false }) - require("gitlab").choose_merge_request({ label = {"include_mrs_with_label"} }) - require("gitlab").choose_merge_request({ notlabel = {"exclude_mrs_with_label"} }) + require("gitlab").choose_merge_request({ labels = {"include_mrs_with_label"} }) + require("gitlab").choose_merge_request({ ["[not]labels"] = {"exclude_mrs_with_label"} }) < Parameters: ~ • {opts}: (table|nil) Keyword arguments to configure the checkout. • {open_reviewer}: (boolean) Whether to open the reviewer after switching branches. True by default. - • {label}: (table) Return merge requests with *including* matching labels - • {notlabel}: (table) Return merge requests *excluding* - matching label -< + • {labels}: (table) Return merge requests with *including* matching labels + • Etc, see: https://docs.gitlab.com/ee/api/merge_requests.html#list-merge-requests *gitlab.nvim.review* gitlab.review() ~ diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index ed0cce95..e561162b 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -66,6 +66,14 @@ M.open = function() u.notify("This merge request has conflicts!", vim.log.levels.WARN) end + if state.INFO.state == "closed" then + u.notify(string.format("This MR was closed on %s", u.format_date(state.INFO.closed_at)), vim.log.levels.WARN) + end + + if state.INFO.state == "merged" then + u.notify(string.format("This MR was merged on %s", u.format_date(state.INFO.merged_at)), vim.log.levels.WARN) + end + if state.settings.discussion_diagnostic ~= nil or state.settings.discussion_sign ~= nil then u.notify( "Diagnostics are now configured as settings.discussion_signs, see :h gitlab.nvim.signs-and-diagnostics", diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index f4e04d4d..bb9cc19c 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -565,18 +565,17 @@ M.dependencies = { refresh = true, method = "POST", body = function(opts) - local listArgs = { - label = opts and opts.label or {}, - notlabel = opts and opts.notlabel or {}, - } - for k, v in pairs(listArgs) do - listArgs[k] = v + if opts then + opts.open_reviewer_field = nil + end + if opts.notlabel then -- Legacy: Migrate use of notlabel to not[label], per API + opts["not[label]"] = opts.notlabel + opts.notlabel = nil end - return listArgs + return opts or vim.json.decode("{}") end, }, discussion_data = { - -- key is missing here... endpoint = "/mr/discussions/list", state = "DISCUSSION_DATA", refresh = false, From 48d9112db5d3ecce21689f495774449b55135d3a Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sun, 8 Sep 2024 16:41:47 -0400 Subject: [PATCH 88/97] feat: select MR by username (#360) feat: choose an MR from a list of MRs pre-filtered by the provided username's involvement as an assignee, reviewer, or author --- cmd/app/merge_requests_by_username.go | 162 +++++++++++++++++++++ cmd/app/merge_requests_by_username_test.go | 87 +++++++++++ cmd/app/server.go | 1 + cmd/app/test_helpers.go | 1 + doc/gitlab.nvim.txt | 18 ++- lua/gitlab/init.lua | 5 + lua/gitlab/state.lua | 23 ++- 7 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 cmd/app/merge_requests_by_username.go create mode 100644 cmd/app/merge_requests_by_username_test.go diff --git a/cmd/app/merge_requests_by_username.go b/cmd/app/merge_requests_by_username.go new file mode 100644 index 00000000..4f64448a --- /dev/null +++ b/cmd/app/merge_requests_by_username.go @@ -0,0 +1,162 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + + "github.com/xanzy/go-gitlab" +) + +type MergeRequestListerByUsername interface { + ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) +} + +type mergeRequestListerByUsernameService struct { + data + client MergeRequestListerByUsername +} + +type MergeRequestByUsernameRequest struct { + UserId int `json:"user_id"` + Username string `json:"username"` + State string `json:"state,omitempty"` +} + +func (a mergeRequestListerByUsernameService) handler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) + handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + defer r.Body.Close() + var request MergeRequestByUsernameRequest + err = json.Unmarshal(body, &request) + if err != nil { + handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + return + } + + if request.Username == "" { + handleError(w, errors.New("username is a required payload field"), "username is required", http.StatusBadRequest) + return + } + + if request.UserId == 0 { + handleError(w, errors.New("user_id is a required payload field"), "user_id is required", http.StatusBadRequest) + return + } + + if request.State == "" { + request.State = "opened" + } + + payloads := []gitlab.ListProjectMergeRequestsOptions{ + { + AuthorUsername: gitlab.Ptr(request.Username), + State: gitlab.Ptr(request.State), + Scope: gitlab.Ptr("all"), + }, + { + ReviewerUsername: gitlab.Ptr(request.Username), + State: gitlab.Ptr(request.State), + Scope: gitlab.Ptr("all"), + }, + { + AssigneeID: gitlab.AssigneeID(request.UserId), + State: gitlab.Ptr(request.State), + Scope: gitlab.Ptr("all"), + }, + } + + type apiResponse struct { + mrs []*gitlab.MergeRequest + err error + } + + mrChan := make(chan apiResponse, len(payloads)) + wg := sync.WaitGroup{} + go func() { + wg.Wait() + close(mrChan) + }() + + for _, payload := range payloads { + wg.Add(1) + go func(p gitlab.ListProjectMergeRequestsOptions) { + defer wg.Done() + mrs, err := a.getMrs(&p) + mrChan <- apiResponse{mrs, err} + }(payload) + } + + var mergeRequests []*gitlab.MergeRequest + existingIds := make(map[int]bool) + var errs []error + for res := range mrChan { + if res.err != nil { + errs = append(errs, res.err) + } else { + for _, mr := range res.mrs { + if !existingIds[mr.ID] { + mergeRequests = append(mergeRequests, mr) + existingIds[mr.ID] = true + } + } + } + } + + if len(errs) > 0 { + combinedErr := "" + for _, err := range errs { + combinedErr += err.Error() + "; " + } + handleError(w, errors.New(combinedErr), "An error occurred", http.StatusInternalServerError) + return + } + + if len(mergeRequests) == 0 { + handleError(w, fmt.Errorf("%s did not have any MRs", request.Username), "No MRs found", http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusOK) + response := ListMergeRequestResponse{ + SuccessResponse: SuccessResponse{ + Message: fmt.Sprintf("Merge requests fetched for %s", request.Username), + Status: http.StatusOK, + }, + MergeRequests: mergeRequests, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} + +func (a mergeRequestListerByUsernameService) getMrs(payload *gitlab.ListProjectMergeRequestsOptions) ([]*gitlab.MergeRequest, error) { + mrs, res, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, payload) + if err != nil { + return []*gitlab.MergeRequest{}, err + } + + if res.StatusCode >= 300 { + return []*gitlab.MergeRequest{}, GenericError{endpoint: "/merge_requests_by_username"} + } + + defer res.Body.Close() + + return mrs, err +} diff --git a/cmd/app/merge_requests_by_username_test.go b/cmd/app/merge_requests_by_username_test.go new file mode 100644 index 00000000..34631a0b --- /dev/null +++ b/cmd/app/merge_requests_by_username_test.go @@ -0,0 +1,87 @@ +package app + +import ( + "net/http" + "strings" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeMergeRequestListerByUsername struct { + testBase + emptyResponse bool +} + +func (f fakeMergeRequestListerByUsername) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + + if f.emptyResponse { + return []*gitlab.MergeRequest{}, resp, err + } + + return []*gitlab.MergeRequest{{IID: 10}}, resp, err +} + +func TestListMergeRequestByUsername(t *testing.T) { + var testListMrsByUsernamePayload = MergeRequestByUsernameRequest{Username: "hcramer", UserId: 1234, State: "opened"} + t.Run("Gets merge requests by username", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload) + svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}} + data := getSuccessData(t, svc, request) + assert(t, data.Message, "Merge requests fetched for hcramer") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Should handle no merge requests", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload) + svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{emptyResponse: true}} + data := getFailData(t, svc, request) + assert(t, data.Message, "No MRs found") + assert(t, data.Details, "hcramer did not have any MRs") + assert(t, data.Status, http.StatusNotFound) + }) + + t.Run("Should require username", func(t *testing.T) { + missingUsernamePayload := testListMrsByUsernamePayload + missingUsernamePayload.Username = "" + request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", missingUsernamePayload) + svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}} + data := getFailData(t, svc, request) + assert(t, data.Message, "username is required") + assert(t, data.Details, "username is a required payload field") + assert(t, data.Status, http.StatusBadRequest) + }) + + t.Run("Should require User ID for assignee call", func(t *testing.T) { + missingUsernamePayload := testListMrsByUsernamePayload + missingUsernamePayload.UserId = 0 + request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", missingUsernamePayload) + svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}} + data := getFailData(t, svc, request) + assert(t, data.Message, "user_id is required") + assert(t, data.Details, "user_id is a required payload field") + assert(t, data.Status, http.StatusBadRequest) + }) + + t.Run("Should handle error from Gitlab", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload) + svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{errFromGitlab: true}}} + data := getFailData(t, svc, request) + assert(t, data.Message, "An error occurred") + assert(t, data.Details, strings.Repeat("Some error from Gitlab; ", 3)) + assert(t, data.Status, http.StatusInternalServerError) + }) + + t.Run("Handles non-200 from Gitlab", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload) + svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{status: http.StatusSeeOther}}} + data := getFailData(t, svc, request) + assert(t, data.Message, "An error occurred") + assert(t, data.Details, strings.Repeat("An error occurred on the /merge_requests_by_username endpoint; ", 3)) + assert(t, data.Status, http.StatusInternalServerError) + }) +} diff --git a/cmd/app/server.go b/cmd/app/server.go index 200ea30a..7357a1bc 100644 --- a/cmd/app/server.go +++ b/cmd/app/server.go @@ -117,6 +117,7 @@ func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s ShutdownHand m.HandleFunc("/job", traceFileService{d, gitlabClient}.handler) m.HandleFunc("/project/members", projectMemberService{d, gitlabClient}.handler) m.HandleFunc("/merge_requests", mergeRequestListerService{d, gitlabClient}.handler) + m.HandleFunc("/merge_requests_by_username", mergeRequestListerByUsernameService{d, gitlabClient}.handler) m.HandleFunc("/shutdown", s.shutdownHandler) m.Handle("/ping", http.HandlerFunc(pingHandler)) diff --git a/cmd/app/test_helpers.go b/cmd/app/test_helpers.go index 6078029f..29b5903d 100644 --- a/cmd/app/test_helpers.go +++ b/cmd/app/test_helpers.go @@ -51,6 +51,7 @@ func makeResponse(status int) *gitlab.Response { return &gitlab.Response{ Response: &http.Response{ StatusCode: status, + Body: http.NoBody, }, } } diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index eb79b472..09a693fc 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -751,11 +751,27 @@ By default, the endpoint will return all open merge requests. require("gitlab").choose_merge_request({ ["[not]labels"] = {"exclude_mrs_with_label"} }) < Parameters: ~ - • {opts}: (table|nil) Keyword arguments to configure the checkout. + • {opts}: (table|nil) • {open_reviewer}: (boolean) Whether to open the reviewer after switching branches. True by default. • {labels}: (table) Return merge requests with *including* matching labels • Etc, see: https://docs.gitlab.com/ee/api/merge_requests.html#list-merge-requests + + *gitlab.nvim.choose_merge_request* +gitlab.choose_merge_request_by_username({opts}) ~ + +Choose a merge request based on the username provided. Like the `choose_merge_request` +action, this will automatically check out the branch locally and open the +the reviewer pane when the MR is chosen. +>lua + require("gitlab").choose_merge_request_by_username({ username = "hcramer" }) +< + Parameters: ~ + • {opts}: (table|nil) + • {username}: (string) The username of the Gitlab user, must be a + member of the current project. + • {state} [optional]: (string) The status of the MR, e.g. "opened" or "all" + *gitlab.nvim.review* gitlab.review() ~ diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 278bb4b6..25378a74 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -26,6 +26,7 @@ local project_members = state.dependencies.project_members local latest_pipeline = state.dependencies.latest_pipeline local revisions = state.dependencies.revisions local merge_requests_dep = state.dependencies.merge_requests +local merge_requests_by_username_dep = state.dependencies.merge_requests_by_username local draft_notes_dep = state.dependencies.draft_notes local discussion_data = state.dependencies.discussion_data @@ -102,6 +103,10 @@ return { data = data.data, print_settings = state.print_settings, choose_merge_request = async.sequence({ merge_requests_dep }, merge_requests.choose_merge_request), + choose_merge_request_by_username = async.sequence( + { project_members, merge_requests_by_username_dep }, + merge_requests.choose_merge_request + ), open_in_browser = async.sequence({ info }, function() local web_url = u.get_web_url() if web_url ~= nil then diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index bb9cc19c..2007fe32 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -5,6 +5,7 @@ local git = require("gitlab.git") local u = require("gitlab.utils") +local List = require("gitlab.utils.list") local M = {} M.emoji_map = nil @@ -43,6 +44,7 @@ end --- These are the default settings for the plugin M.settings = { auth_provider = M.default_auth_provider, + file_separator = u.path_separator, port = nil, -- choose random port debug = { go_request = false, @@ -385,7 +387,6 @@ end ---@return Settings M.merge_settings = function(args) M.settings = u.merge(M.settings, args) - M.settings.file_separator = (u.is_windows() and "\\" or "/") return M.settings end @@ -568,13 +569,31 @@ M.dependencies = { if opts then opts.open_reviewer_field = nil end - if opts.notlabel then -- Legacy: Migrate use of notlabel to not[label], per API + if opts and opts.notlabel then -- Legacy: Migrate use of notlabel to not[label], per API opts["not[label]"] = opts.notlabel opts.notlabel = nil end return opts or vim.json.decode("{}") end, }, + merge_requests_by_username = { + endpoint = "/merge_requests_by_username", + key = "merge_requests", + state = "MERGE_REQUESTS", + refresh = true, + method = "POST", + body = function(opts) + local members = List.new(M.PROJECT_MEMBERS) + local user = members:find(function(usr) + return usr.username == opts.username + end) + if user == nil then + error("Invalid payload, user could not be found!") + end + opts.user_id = user.id + return opts + end, + }, discussion_data = { endpoint = "/mr/discussions/list", state = "DISCUSSION_DATA", From 9f963d5cb043da64f4f74f75d73febd7f811c727 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Thu, 12 Sep 2024 14:53:08 -0400 Subject: [PATCH 89/97] Refactor: Add Payload Validators + Middleware In Go Code (#366) refactor: Refactors the Go code to introduce a middleware pattern, and adds more robust logging options. --- cmd/app/approve.go | 16 +- cmd/app/approve_test.go | 31 +-- cmd/app/assignee.go | 40 +--- cmd/app/assignee_test.go | 39 ++-- cmd/app/attachment.go | 50 ++--- cmd/app/attachment_test.go | 29 +-- cmd/app/client.go | 70 ++----- cmd/app/comment.go | 144 +++++--------- cmd/app/comment_test.go | 100 ++++++---- cmd/app/config.go | 6 +- cmd/app/create_mr.go | 46 +---- cmd/app/create_mr_test.go | 56 +++--- cmd/app/draft_note_publisher.go | 46 ++--- cmd/app/draft_note_publisher_test.go | 59 +++--- cmd/app/draft_notes.go | 128 ++++--------- cmd/app/draft_notes_test.go | 155 +++++++++------ cmd/app/emoji.go | 21 +-- cmd/app/info.go | 18 +- cmd/app/info_test.go | 26 +-- cmd/app/job.go | 36 +--- cmd/app/job_test.go | 44 ++--- cmd/app/label.go | 27 +-- cmd/app/list_discussions.go | 34 +--- cmd/app/list_discussions_test.go | 61 +++--- cmd/app/logging.go | 96 ++++++++++ cmd/app/members.go | 17 +- cmd/app/members_test.go | 26 +-- cmd/app/merge_mr.go | 42 +---- cmd/app/merge_mr_test.go | 38 ++-- cmd/app/merge_requests.go | 43 ++--- cmd/app/merge_requests_by_username.go | 47 +---- cmd/app/merge_requests_by_username_test.go | 65 ++++--- cmd/app/merge_requests_test.go | 42 +++-- cmd/app/middleware.go | 173 +++++++++++++++++ cmd/app/middleware_test.go | 114 +++++++++++ cmd/app/pipeline.go | 24 +-- cmd/app/pipeline_test.go | 48 +++-- cmd/app/reply.go | 38 +--- cmd/app/reply_test.go | 26 ++- cmd/app/resolve_discussion.go | 49 ++--- cmd/app/resolve_discussion_test.go | 84 +++++++++ cmd/app/response_types.go | 6 +- cmd/app/reviewer.go | 38 +--- cmd/app/revisions.go | 17 +- cmd/app/revoke.go | 15 +- cmd/app/server.go | 210 ++++++++++++++------- cmd/app/shutdown.go | 5 +- cmd/app/summary.go | 40 +--- cmd/app/test_helpers.go | 21 +-- cmd/app/user.go | 15 +- doc/gitlab.nvim.txt | 7 +- go.mod | 10 +- go.sum | 22 ++- lua/gitlab/actions/draft_notes/init.lua | 4 +- lua/gitlab/annotations.lua | 2 + lua/gitlab/job.lua | 21 ++- lua/gitlab/state.lua | 6 +- 57 files changed, 1444 insertions(+), 1249 deletions(-) create mode 100644 cmd/app/logging.go create mode 100644 cmd/app/middleware.go create mode 100644 cmd/app/middleware_test.go create mode 100644 cmd/app/resolve_discussion_test.go diff --git a/cmd/app/approve.go b/cmd/app/approve.go index 1426b7b6..3d7a034d 100644 --- a/cmd/app/approve.go +++ b/cmd/app/approve.go @@ -17,14 +17,7 @@ type mergeRequestApproverService struct { } /* approveHandler approves a merge request. */ -func (a mergeRequestApproverService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost { - w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) - handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) - return - } - +func (a mergeRequestApproverService) ServeHTTP(w http.ResponseWriter, r *http.Request) { _, res, err := a.client.ApproveMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, nil, nil) if err != nil { @@ -33,15 +26,12 @@ func (a mergeRequestApproverService) handler(w http.ResponseWriter, r *http.Requ } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/approve"}, "Could not approve merge request", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not approve merge request", res.StatusCode) return } w.WriteHeader(http.StatusOK) - response := SuccessResponse{ - Message: "Approved MR", - Status: http.StatusOK, - } + response := SuccessResponse{Message: "Approved MR"} err = json.NewEncoder(w).Encode(response) if err != nil { diff --git a/cmd/app/approve_test.go b/cmd/app/approve_test.go index 5baf1c8a..b450169c 100644 --- a/cmd/app/approve_test.go +++ b/cmd/app/approve_test.go @@ -23,33 +23,36 @@ func TestApproveHandler(t *testing.T) { t.Run("Approves merge request", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/approve", nil) client := fakeApproverClient{} - svc := mergeRequestApproverService{testProjectData, client} + svc := middleware( + mergeRequestApproverService{testProjectData, client}, + withMr(testProjectData, fakeMergeRequestLister{}), + withMethodCheck(http.MethodPost), + ) data := getSuccessData(t, svc, request) assert(t, data.Message, "Approved MR") - assert(t, data.Status, http.StatusOK) - }) - - t.Run("Disallows non-POST method", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/mr/approve", nil) - client := fakeApproverClient{} - svc := mergeRequestApproverService{testProjectData, client} - data := getFailData(t, svc, request) - checkBadMethod(t, data, http.MethodPost) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/approve", nil) client := fakeApproverClient{testBase{errFromGitlab: true}} - svc := mergeRequestApproverService{testProjectData, client} - data := getFailData(t, svc, request) + svc := middleware( + mergeRequestApproverService{testProjectData, client}, + withMr(testProjectData, fakeMergeRequestLister{}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not approve merge request") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/approve", nil) client := fakeApproverClient{testBase{status: http.StatusSeeOther}} - svc := mergeRequestApproverService{testProjectData, client} - data := getFailData(t, svc, request) + svc := middleware( + mergeRequestApproverService{testProjectData, client}, + withMr(testProjectData, fakeMergeRequestLister{}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkNon200(t, data, "Could not approve merge request", "/mr/approve") }) } diff --git a/cmd/app/assignee.go b/cmd/app/assignee.go index cf419e19..b7be1670 100644 --- a/cmd/app/assignee.go +++ b/cmd/app/assignee.go @@ -2,14 +2,14 @@ package app import ( "encoding/json" - "io" + "errors" "net/http" "github.com/xanzy/go-gitlab" ) type AssigneeUpdateRequest struct { - Ids []int `json:"ids"` + Ids []int `json:"ids" validate:"required"` } type AssigneeUpdateResponse struct { @@ -17,37 +17,18 @@ type AssigneeUpdateResponse struct { Assignees []*gitlab.BasicUser `json:"assignees"` } -type AssigneesRequestResponse struct { - SuccessResponse - Assignees []int `json:"assignees"` -} - type assigneesService struct { data client MergeRequestUpdater } /* assigneesHandler adds or removes assignees from a merge request. */ -func (a assigneesService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPut { - w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) - handleError(w, InvalidRequestError{}, "Expected PUT", http.StatusMethodNotAllowed) - return - } +func (a assigneesService) ServeHTTP(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } + assigneeUpdateRequest, ok := r.Context().Value(payload("payload")).(*AssigneeUpdateRequest) - defer r.Body.Close() - var assigneeUpdateRequest AssigneeUpdateRequest - err = json.Unmarshal(body, &assigneeUpdateRequest) - - if err != nil { - handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) + if !ok { + handleError(w, errors.New("Could not get payload from context"), "Bad payload", http.StatusInternalServerError) return } @@ -61,17 +42,14 @@ func (a assigneesService) handler(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/assignee"}, "Could not modify merge request assignees", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not modify merge request assignees", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := AssigneeUpdateResponse{ - SuccessResponse: SuccessResponse{ - Message: "Assignees updated", - Status: http.StatusOK, - }, - Assignees: mr.Assignees, + SuccessResponse: SuccessResponse{Message: "Assignees updated"}, + Assignees: mr.Assignees, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/assignee_test.go b/cmd/app/assignee_test.go index 24e7e60c..8cdde646 100644 --- a/cmd/app/assignee_test.go +++ b/cmd/app/assignee_test.go @@ -24,34 +24,39 @@ func TestAssigneeHandler(t *testing.T) { t.Run("Updates assignees", func(t *testing.T) { request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload) - client := fakeAssigneeClient{} - svc := assigneesService{testProjectData, client} + svc := middleware( + assigneesService{testProjectData, fakeAssigneeClient{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPut: &AssigneeUpdateRequest{}}), + withMethodCheck(http.MethodPut), + ) data := getSuccessData(t, svc, request) assert(t, data.Message, "Assignees updated") - assert(t, data.Status, http.StatusOK) - }) - - t.Run("Disallows non-PUT method", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/mr/assignee", nil) - client := fakeAssigneeClient{} - svc := assigneesService{testProjectData, client} - data := getFailData(t, svc, request) - checkBadMethod(t, data, http.MethodPut) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPut, "/mr/approve", updatePayload) + request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload) client := fakeAssigneeClient{testBase{errFromGitlab: true}} - svc := assigneesService{testProjectData, client} - data := getFailData(t, svc, request) + svc := middleware( + assigneesService{testProjectData, client}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPut: &AssigneeUpdateRequest{}}), + withMethodCheck(http.MethodPut), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not modify merge request assignees") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPut, "/mr/approve", updatePayload) + request := makeRequest(t, http.MethodPut, "/mr/assignee", updatePayload) client := fakeAssigneeClient{testBase{status: http.StatusSeeOther}} - svc := assigneesService{testProjectData, client} - data := getFailData(t, svc, request) + svc := middleware( + assigneesService{testProjectData, client}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPut: &AssigneeUpdateRequest{}}), + withMethodCheck(http.MethodPut), + ) + data, _ := getFailData(t, svc, request) checkNon200(t, data, "Could not modify merge request assignees", "/mr/assignee") }) } diff --git a/cmd/app/attachment.go b/cmd/app/attachment.go index 10ee33dd..b34ea23d 100644 --- a/cmd/app/attachment.go +++ b/cmd/app/attachment.go @@ -16,8 +16,8 @@ type FileReader interface { } type AttachmentRequest struct { - FilePath string `json:"file_path"` - FileName string `json:"file_name"` + FilePath string `json:"file_path" validate:"required"` + FileName string `json:"file_name" validate:"required"` } type AttachmentResponse struct { @@ -58,55 +58,31 @@ type attachmentService struct { } /* attachmentHandler uploads an attachment (file, image, etc) to Gitlab and returns metadata about the upload. */ -func (a attachmentService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost { - w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) - handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) - return - } - - var attachmentRequest AttachmentRequest - - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - defer r.Body.Close() - - err = json.Unmarshal(body, &attachmentRequest) - if err != nil { - handleError(w, err, "Could not unmarshal JSON", http.StatusBadRequest) - return - } +func (a attachmentService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + payload := r.Context().Value(payload("payload")).(*AttachmentRequest) - file, err := a.fileReader.ReadFile(attachmentRequest.FilePath) + file, err := a.fileReader.ReadFile(payload.FilePath) if err != nil || file == nil { - handleError(w, err, fmt.Sprintf("Could not read %s file", attachmentRequest.FileName), http.StatusInternalServerError) + handleError(w, err, fmt.Sprintf("Could not read %s file", payload.FileName), http.StatusInternalServerError) return } - projectFile, res, err := a.client.UploadFile(a.projectInfo.ProjectId, file, attachmentRequest.FileName) + projectFile, res, err := a.client.UploadFile(a.projectInfo.ProjectId, file, payload.FileName) if err != nil { - handleError(w, err, fmt.Sprintf("Could not upload %s to Gitlab", attachmentRequest.FileName), http.StatusInternalServerError) + handleError(w, err, fmt.Sprintf("Could not upload %s to Gitlab", payload.FileName), http.StatusInternalServerError) return } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/attachment"}, fmt.Sprintf("Could not upload %s to Gitlab", attachmentRequest.FileName), res.StatusCode) + handleError(w, GenericError{r.URL.Path}, fmt.Sprintf("Could not upload %s to Gitlab", payload.FileName), res.StatusCode) return } response := AttachmentResponse{ - SuccessResponse: SuccessResponse{ - Status: http.StatusOK, - Message: "File uploaded successfully", - }, - Markdown: projectFile.Markdown, - Alt: projectFile.Alt, - Url: projectFile.URL, + SuccessResponse: SuccessResponse{Message: "File uploaded successfully"}, + Markdown: projectFile.Markdown, + Alt: projectFile.Alt, + Url: projectFile.URL, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/attachment_test.go b/cmd/app/attachment_test.go index 8fefaf0b..38a1a0d5 100644 --- a/cmd/app/attachment_test.go +++ b/cmd/app/attachment_test.go @@ -36,29 +36,34 @@ func TestAttachmentHandler(t *testing.T) { t.Run("Returns 200-status response after upload", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) - svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{}} + svc := middleware( + attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{}}, + withPayloadValidation(methodToPayload{http.MethodPost: &AttachmentRequest{}}), + withMethodCheck(http.MethodPost), + ) data := getSuccessData(t, svc, request) - assert(t, data.Status, http.StatusOK) assert(t, data.Message, "File uploaded successfully") }) - t.Run("Disallows non-POST method", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/attachment", nil) - svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{}} - data := getFailData(t, svc, request) - checkBadMethod(t, data, http.MethodPost) - }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) - svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) + svc := middleware( + attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{errFromGitlab: true}}}, + withPayloadValidation(methodToPayload{http.MethodPost: &AttachmentRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not upload some_file_name to Gitlab") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/attachment", attachmentTestRequestData) - svc := attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) + svc := middleware( + attachmentService{testProjectData, fakeFileReader{}, fakeFileUploaderClient{testBase{status: http.StatusSeeOther}}}, + withPayloadValidation(methodToPayload{http.MethodPost: &AttachmentRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkNon200(t, data, "Could not upload some_file_name to Gitlab", "/attachment") }) } diff --git a/cmd/app/client.go b/cmd/app/client.go index c9e8ddb2..756371f7 100644 --- a/cmd/app/client.go +++ b/cmd/app/client.go @@ -5,10 +5,7 @@ import ( "encoding/json" "errors" "fmt" - "log" "net/http" - "net/http/httputil" - "os" "github.com/harrisoncramer/gitlab.nvim/cmd/app/git" "github.com/hashicorp/go-retryablehttp" @@ -48,12 +45,19 @@ func NewClient() (error, *Client) { gitlab.WithBaseURL(apiCustUrl), } - if pluginOptions.Debug.Request { - gitlabOptions = append(gitlabOptions, gitlab.WithRequestLogHook(requestLogger)) + if pluginOptions.Debug.GitlabRequest { + gitlabOptions = append(gitlabOptions, gitlab.WithRequestLogHook( + func(l retryablehttp.Logger, r *http.Request, i int) { + logRequest("REQUEST TO GITLAB", r) + }, + )) } - if pluginOptions.Debug.Response { - gitlabOptions = append(gitlabOptions, gitlab.WithResponseLogHook(responseLogger)) + if pluginOptions.Debug.GitlabResponse { + gitlabOptions = append(gitlabOptions, gitlab.WithResponseLogHook(func(l retryablehttp.Logger, response *http.Response) { + logResponse("RESPONSE FROM GITLAB", response) + }, + )) } tr := &http.Transport{ @@ -106,7 +110,6 @@ func InitProjectSettings(c *Client, gitInfo git.GitData) (error, *ProjectInfo) { return nil, &ProjectInfo{ ProjectId: projectId, } - } /* handleError is a utililty handler that returns errors to the client along with their statuses and messages */ @@ -115,7 +118,6 @@ func handleError(w http.ResponseWriter, err error, message string, status int) { response := ErrorResponse{ Message: message, Details: err.Error(), - Status: status, } err = json.NewEncoder(w).Encode(response) @@ -123,53 +125,3 @@ func handleError(w http.ResponseWriter, err error, message string, status int) { handleError(w, err, "Could not encode error response", http.StatusInternalServerError) } } - -var requestLogger retryablehttp.RequestLogHook = func(l retryablehttp.Logger, r *http.Request, i int) { - file := openLogFile() - defer file.Close() - - token := r.Header.Get("Private-Token") - r.Header.Set("Private-Token", "REDACTED") - res, err := httputil.DumpRequest(r, true) - if err != nil { - log.Fatalf("Error dumping request: %v", err) - os.Exit(1) - } - r.Header.Set("Private-Token", token) - - _, err = file.Write([]byte("\n-- REQUEST --\n")) //nolint:all - _, err = file.Write(res) //nolint:all - _, err = file.Write([]byte("\n")) //nolint:all -} - -var responseLogger retryablehttp.ResponseLogHook = func(l retryablehttp.Logger, response *http.Response) { - file := openLogFile() - defer file.Close() - - res, err := httputil.DumpResponse(response, true) - if err != nil { - log.Fatalf("Error dumping response: %v", err) - os.Exit(1) - } - - _, err = file.Write([]byte("\n-- RESPONSE --\n")) //nolint:all - _, err = file.Write(res) //nolint:all - _, err = file.Write([]byte("\n")) //nolint:all -} - -func openLogFile() *os.File { - file, err := os.OpenFile(pluginOptions.LogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - if os.IsNotExist(err) { - log.Printf("Log file %s does not exist", pluginOptions.LogPath) - } else if os.IsPermission(err) { - log.Printf("Permission denied for log file %s", pluginOptions.LogPath) - } else { - log.Printf("Error opening log file %s: %v", pluginOptions.LogPath, err) - } - - os.Exit(1) - } - - return file -} diff --git a/cmd/app/comment.go b/cmd/app/comment.go index 6ccc14f3..ebc4a412 100644 --- a/cmd/app/comment.go +++ b/cmd/app/comment.go @@ -2,45 +2,17 @@ package app import ( "encoding/json" - "fmt" - "io" "net/http" "github.com/xanzy/go-gitlab" ) -type PostCommentRequest struct { - Comment string `json:"comment"` - PositionData -} - -type DeleteCommentRequest struct { - NoteId int `json:"note_id"` - DiscussionId string `json:"discussion_id"` -} - -type EditCommentRequest struct { - Comment string `json:"comment"` - NoteId int `json:"note_id"` - DiscussionId string `json:"discussion_id"` - Resolved bool `json:"resolved"` -} - type CommentResponse struct { SuccessResponse Comment *gitlab.Note `json:"note"` Discussion *gitlab.Discussion `json:"discussion"` } -/* CommentWithPosition is a comment with an (optional) position data value embedded in it. The position data will be non-nil for range-based comments. */ -type CommentWithPosition struct { - PositionData PositionData -} - -func (comment CommentWithPosition) GetPositionData() PositionData { - return comment.PositionData -} - type CommentManager interface { CreateMergeRequestDiscussion(pid interface{}, mergeRequest int, opt *gitlab.CreateMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) UpdateMergeRequestDiscussionNote(pid interface{}, mergeRequest int, discussion string, note int, opt *gitlab.UpdateMergeRequestDiscussionNoteOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Note, *gitlab.Response, error) @@ -53,7 +25,7 @@ type commentService struct { } /* commentHandler creates, edits, and deletes discussions (comments, multi-line comments) */ -func (a commentService) handler(w http.ResponseWriter, r *http.Request) { +func (a commentService) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodPost: @@ -62,30 +34,19 @@ func (a commentService) handler(w http.ResponseWriter, r *http.Request) { a.editComment(w, r) case http.MethodDelete: a.deleteComment(w, r) - default: - w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s, %s", http.MethodDelete, http.MethodPost, http.MethodPatch)) - handleError(w, InvalidRequestError{}, "Expected DELETE, POST or PATCH", http.StatusMethodNotAllowed) } } +type DeleteCommentRequest struct { + NoteId int `json:"note_id" validate:"required"` + DiscussionId string `json:"discussion_id" validate:"required"` +} + /* deleteComment deletes a note, multiline comment, or comment, which are all considered discussion notes. */ func (a commentService) deleteComment(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - defer r.Body.Close() - - var deleteCommentRequest DeleteCommentRequest - err = json.Unmarshal(body, &deleteCommentRequest) - if err != nil { - handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) - return - } + payload := r.Context().Value(payload("payload")).(*DeleteCommentRequest) - res, err := a.client.DeleteMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, deleteCommentRequest.DiscussionId, deleteCommentRequest.NoteId) + res, err := a.client.DeleteMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, payload.DiscussionId, payload.NoteId) if err != nil { handleError(w, err, "Could not delete comment", http.StatusInternalServerError) @@ -93,15 +54,12 @@ func (a commentService) deleteComment(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not delete comment", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not delete comment", res.StatusCode) return } w.WriteHeader(http.StatusOK) - response := SuccessResponse{ - Message: "Comment deleted successfully", - Status: http.StatusOK, - } + response := SuccessResponse{Message: "Comment deleted successfully"} err = json.NewEncoder(w).Encode(response) if err != nil { @@ -109,32 +67,33 @@ func (a commentService) deleteComment(w http.ResponseWriter, r *http.Request) { } } -/* postComment creates a note, multiline comment, or comment. */ -func (a commentService) postComment(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } +type PostCommentRequest struct { + Comment string `json:"comment" validate:"required"` + PositionData +} - defer r.Body.Close() +/* CommentWithPosition is a comment with an (optional) position data value embedded in it. The position data will be non-nil for range-based comments. */ +type CommentWithPosition struct { + PositionData PositionData +} - var postCommentRequest PostCommentRequest - err = json.Unmarshal(body, &postCommentRequest) - if err != nil { - handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) - return - } +func (comment CommentWithPosition) GetPositionData() PositionData { + return comment.PositionData +} + +/* postComment creates a note, multiline comment, or comment. */ +func (a commentService) postComment(w http.ResponseWriter, r *http.Request) { + payload := r.Context().Value(payload("payload")).(*PostCommentRequest) opt := gitlab.CreateMergeRequestDiscussionOptions{ - Body: &postCommentRequest.Comment, + Body: &payload.Comment, } /* If we are leaving a comment on a line, leave position. Otherwise, we are leaving a note (unlinked comment) */ - if postCommentRequest.FileName != "" { - commentWithPositionData := CommentWithPosition{postCommentRequest.PositionData} + if payload.FileName != "" { + commentWithPositionData := CommentWithPosition{payload.PositionData} opt.Position = buildCommentPosition(commentWithPositionData) } @@ -146,18 +105,15 @@ func (a commentService) postComment(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not create discussion", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not create discussion", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := CommentResponse{ - SuccessResponse: SuccessResponse{ - Message: "Comment created successfully", - Status: http.StatusOK, - }, - Comment: discussion.Notes[0], - Discussion: discussion, + SuccessResponse: SuccessResponse{Message: "Comment created successfully"}, + Comment: discussion.Notes[0], + Discussion: discussion, } err = json.NewEncoder(w).Encode(response) @@ -166,28 +122,23 @@ func (a commentService) postComment(w http.ResponseWriter, r *http.Request) { } } +type EditCommentRequest struct { + Comment string `json:"comment" validate:"required"` + NoteId int `json:"note_id" validate:"required"` + DiscussionId string `json:"discussion_id" validate:"required"` + Resolved bool `json:"resolved"` +} + /* editComment changes the text of a comment or changes it's resolved status. */ func (a commentService) editComment(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - defer r.Body.Close() + payload := r.Context().Value(payload("payload")).(*EditCommentRequest) - var editCommentRequest EditCommentRequest - err = json.Unmarshal(body, &editCommentRequest) - if err != nil { - handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) - return + options := gitlab.UpdateMergeRequestDiscussionNoteOptions{ + Body: gitlab.Ptr(payload.Comment), } - options := gitlab.UpdateMergeRequestDiscussionNoteOptions{} - options.Body = gitlab.Ptr(editCommentRequest.Comment) - - note, res, err := a.client.UpdateMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, editCommentRequest.DiscussionId, editCommentRequest.NoteId, &options) + note, res, err := a.client.UpdateMergeRequestDiscussionNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, payload.DiscussionId, payload.NoteId, &options) if err != nil { handleError(w, err, "Could not update comment", http.StatusInternalServerError) @@ -195,17 +146,14 @@ func (a commentService) editComment(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/comment"}, "Could not update comment", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not update comment", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := CommentResponse{ - SuccessResponse: SuccessResponse{ - Message: "Comment updated successfully", - Status: http.StatusOK, - }, - Comment: note, + SuccessResponse: SuccessResponse{Message: "Comment updated successfully"}, + Comment: note, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/comment_test.go b/cmd/app/comment_test.go index 03ed716e..f86902f4 100644 --- a/cmd/app/comment_test.go +++ b/cmd/app/comment_test.go @@ -40,10 +40,18 @@ func TestPostComment(t *testing.T) { var testCommentCreationData = PostCommentRequest{Comment: "Some comment"} t.Run("Creates a new note (unlinked comment)", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) - svc := commentService{testProjectData, fakeCommentClient{}} + svc := middleware( + commentService{testProjectData, fakeCommentClient{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostCommentRequest{}, + http.MethodDelete: &DeleteCommentRequest{}, + http.MethodPatch: &EditCommentRequest{}, + }), + withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch), + ) data := getSuccessData(t, svc, request) assert(t, data.Message, "Comment created successfully") - assert(t, data.Status, http.StatusOK) }) t.Run("Creates a new comment", func(t *testing.T) { @@ -54,23 +62,49 @@ func TestPostComment(t *testing.T) { }, } request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) - svc := commentService{testProjectData, fakeCommentClient{}} + svc := middleware( + commentService{testProjectData, fakeCommentClient{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostCommentRequest{}, + http.MethodDelete: &DeleteCommentRequest{}, + http.MethodPatch: &EditCommentRequest{}, + }), + withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch), + ) data := getSuccessData(t, svc, request) assert(t, data.Message, "Comment created successfully") - assert(t, data.Status, http.StatusOK) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) - svc := commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) + svc := middleware( + commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostCommentRequest{}, + http.MethodDelete: &DeleteCommentRequest{}, + http.MethodPatch: &EditCommentRequest{}, + }), + withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not create discussion") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/comment", testCommentCreationData) - svc := commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) + svc := middleware( + commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostCommentRequest{}, + http.MethodDelete: &DeleteCommentRequest{}, + http.MethodPatch: &EditCommentRequest{}, + }), + withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch), + ) + data, _ := getFailData(t, svc, request) checkNon200(t, data, "Could not create discussion", "/mr/comment") }) } @@ -79,23 +113,18 @@ func TestDeleteComment(t *testing.T) { var testCommentDeletionData = DeleteCommentRequest{NoteId: 3, DiscussionId: "abc123"} t.Run("Deletes a comment", func(t *testing.T) { request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData) - svc := commentService{testProjectData, fakeCommentClient{}} + svc := middleware( + commentService{testProjectData, fakeCommentClient{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostCommentRequest{}, + http.MethodDelete: &DeleteCommentRequest{}, + http.MethodPatch: &EditCommentRequest{}, + }), + withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch), + ) data := getSuccessData(t, svc, request) assert(t, data.Message, "Comment deleted successfully") - assert(t, data.Status, http.StatusOK) - }) - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData) - svc := commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) - checkErrorFromGitlab(t, data, "Could not delete comment") - }) - - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodDelete, "/mr/comment", testCommentDeletionData) - svc := commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) - checkNon200(t, data, "Could not delete comment", "/mr/comment") }) } @@ -103,22 +132,17 @@ func TestEditComment(t *testing.T) { var testEditCommentData = EditCommentRequest{Comment: "Some comment", NoteId: 3, DiscussionId: "abc123"} t.Run("Edits a comment", func(t *testing.T) { request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData) - svc := commentService{testProjectData, fakeCommentClient{}} + svc := middleware( + commentService{testProjectData, fakeCommentClient{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostCommentRequest{}, + http.MethodDelete: &DeleteCommentRequest{}, + http.MethodPatch: &EditCommentRequest{}, + }), + withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch), + ) data := getSuccessData(t, svc, request) assert(t, data.Message, "Comment updated successfully") - assert(t, data.Status, http.StatusOK) - }) - t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData) - svc := commentService{testProjectData, fakeCommentClient{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) - checkErrorFromGitlab(t, data, "Could not update comment") - }) - - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/mr/comment", testEditCommentData) - svc := commentService{testProjectData, fakeCommentClient{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) - checkNon200(t, data, "Could not update comment", "/mr/comment") }) } diff --git a/cmd/app/config.go b/cmd/app/config.go index 166ac391..5861b4b3 100644 --- a/cmd/app/config.go +++ b/cmd/app/config.go @@ -6,8 +6,10 @@ type PluginOptions struct { AuthToken string `json:"auth_token"` LogPath string `json:"log_path"` Debug struct { - Request bool `json:"go_request"` - Response bool `json:"go_response"` + Request bool `json:"request"` + Response bool `json:"response"` + GitlabRequest bool `json:"gitlab_request"` + GitlabResponse bool `json:"gitlab_response"` } `json:"debug"` ChosenTargetBranch *string `json:"chosen_target_branch,omitempty"` ConnectionSettings struct { diff --git a/cmd/app/create_mr.go b/cmd/app/create_mr.go index b8a318d7..76985be9 100644 --- a/cmd/app/create_mr.go +++ b/cmd/app/create_mr.go @@ -2,21 +2,19 @@ package app import ( "encoding/json" - "errors" "fmt" - "io" "net/http" "github.com/xanzy/go-gitlab" ) type CreateMrRequest struct { - Title string `json:"title"` + Title string `json:"title" validate:"required"` + TargetBranch string `json:"target_branch" validate:"required"` Description string `json:"description"` - TargetBranch string `json:"target_branch"` + TargetProjectID int `json:"forked_project_id,omitempty"` DeleteBranch bool `json:"delete_branch"` Squash bool `json:"squash"` - TargetProjectID int `json:"forked_project_id,omitempty"` } type MergeRequestCreator interface { @@ -29,36 +27,9 @@ type mergeRequestCreatorService struct { } /* createMr creates a merge request */ -func (a mergeRequestCreatorService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) - if r.Method != http.MethodPost { - handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - var createMrRequest CreateMrRequest - err = json.Unmarshal(body, &createMrRequest) - if err != nil { - handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest) - return - } +func (a mergeRequestCreatorService) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if createMrRequest.Title == "" { - handleError(w, errors.New("Title cannot be empty"), "Could not create MR", http.StatusBadRequest) - return - } - - if createMrRequest.TargetBranch == "" { - handleError(w, errors.New("Target branch cannot be empty"), "Could not create MR", http.StatusBadRequest) - return - } + createMrRequest := r.Context().Value(payload("payload")).(*CreateMrRequest) opts := gitlab.CreateMergeRequestOptions{ Title: &createMrRequest.Title, @@ -81,14 +52,11 @@ func (a mergeRequestCreatorService) handler(w http.ResponseWriter, r *http.Reque } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/create_mr"}, "Could not create MR", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not create MR", res.StatusCode) return } - response := SuccessResponse{ - Status: http.StatusOK, - Message: fmt.Sprintf("MR '%s' created", createMrRequest.Title), - } + response := SuccessResponse{Message: fmt.Sprintf("MR '%s' created", createMrRequest.Title)} w.WriteHeader(http.StatusOK) diff --git a/cmd/app/create_mr_test.go b/cmd/app/create_mr_test.go index 9e5a78de..e10bd7a7 100644 --- a/cmd/app/create_mr_test.go +++ b/cmd/app/create_mr_test.go @@ -29,30 +29,34 @@ func TestCreateMr(t *testing.T) { } t.Run("Creates an MR", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) - svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}} + svc := middleware( + mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}}, + withPayloadValidation(methodToPayload{http.MethodPost: &CreateMrRequest{}}), + withMethodCheck(http.MethodPost), + ) data := getSuccessData(t, svc, request) assert(t, data.Message, "MR 'Some title' created") - assert(t, data.Status, http.StatusOK) - }) - - t.Run("Disallows non-POST methods", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/create_mr", testCreateMrRequestData) - svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}} - data := getFailData(t, svc, request) - checkBadMethod(t, data, http.MethodPost) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) - svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) + svc := middleware( + mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{errFromGitlab: true}}}, + withPayloadValidation(methodToPayload{http.MethodPost: &CreateMrRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not create MR") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/create_mr", testCreateMrRequestData) - svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) + svc := middleware( + mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{testBase{status: http.StatusSeeOther}}}, + withPayloadValidation(methodToPayload{http.MethodPost: &CreateMrRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkNon200(t, data, "Could not create MR", "/create_mr") }) @@ -60,21 +64,27 @@ func TestCreateMr(t *testing.T) { reqData := testCreateMrRequestData reqData.Title = "" request := makeRequest(t, http.MethodPost, "/create_mr", reqData) - svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}} - data := getFailData(t, svc, request) - assert(t, data.Status, http.StatusBadRequest) - assert(t, data.Message, "Could not create MR") - assert(t, data.Details, "Title cannot be empty") + svc := middleware( + mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}}, + withPayloadValidation(methodToPayload{http.MethodPost: &CreateMrRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) + assert(t, data.Message, "Invalid payload") + assert(t, data.Details, "Title is required") }) t.Run("Handles missing target branch", func(t *testing.T) { reqData := testCreateMrRequestData reqData.TargetBranch = "" request := makeRequest(t, http.MethodPost, "/create_mr", reqData) - svc := mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}} - data := getFailData(t, svc, request) - assert(t, data.Status, http.StatusBadRequest) - assert(t, data.Message, "Could not create MR") - assert(t, data.Details, "Target branch cannot be empty") + svc := middleware( + mergeRequestCreatorService{testProjectData, fakeMergeCreatorClient{}}, + withPayloadValidation(methodToPayload{http.MethodPost: &CreateMrRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) + assert(t, data.Message, "Invalid payload") + assert(t, data.Details, "TargetBranch is required") }) } diff --git a/cmd/app/draft_note_publisher.go b/cmd/app/draft_note_publisher.go index 57cfa033..548c5ca2 100644 --- a/cmd/app/draft_note_publisher.go +++ b/cmd/app/draft_note_publisher.go @@ -2,8 +2,6 @@ package app import ( "encoding/json" - "errors" - "io" "net/http" "github.com/xanzy/go-gitlab" @@ -19,38 +17,19 @@ type draftNotePublisherService struct { client DraftNotePublisher } -func (a draftNotePublisherService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost { - w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) - handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - defer r.Body.Close() - var draftNotePublishRequest DraftNotePublishRequest - err = json.Unmarshal(body, &draftNotePublishRequest) +type DraftNotePublishRequest struct { + Note int `json:"note,omitempty"` +} - if err != nil { - handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) - return - } +func (a draftNotePublisherService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + payload := r.Context().Value(payload("payload")).(*DraftNotePublishRequest) var res *gitlab.Response - if draftNotePublishRequest.PublishAll { - res, err = a.client.PublishAllDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId) + var err error + if payload.Note != 0 { + res, err = a.client.PublishDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, payload.Note) } else { - if draftNotePublishRequest.Note == 0 { - handleError(w, errors.New("No ID provided"), "Must provide Note ID", http.StatusBadRequest) - return - } - res, err = a.client.PublishDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, draftNotePublishRequest.Note) + res, err = a.client.PublishAllDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId) } if err != nil { @@ -59,15 +38,12 @@ func (a draftNotePublisherService) handler(w http.ResponseWriter, r *http.Reques } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/draft_notes/publish"}, "Could not publish dfaft note", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not publish dfaft note", res.StatusCode) return } w.WriteHeader(http.StatusOK) - response := SuccessResponse{ - Message: "Draft note(s) published", - Status: http.StatusOK, - } + response := SuccessResponse{Message: "Draft note(s) published"} err = json.NewEncoder(w).Encode(response) if err != nil { diff --git a/cmd/app/draft_note_publisher_test.go b/cmd/app/draft_note_publisher_test.go index b53dda1f..36e3e4f0 100644 --- a/cmd/app/draft_note_publisher_test.go +++ b/cmd/app/draft_note_publisher_test.go @@ -19,56 +19,53 @@ func (f fakeDraftNotePublisher) PublishDraftNote(pid interface{}, mergeRequest i } func TestPublishDraftNote(t *testing.T) { - var testDraftNotePublishRequest = DraftNotePublishRequest{Note: 3, PublishAll: false} + var testDraftNotePublishRequest = DraftNotePublishRequest{Note: 3} t.Run("Publishes draft note", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) - svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}} + svc := middleware( + draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPost: &DraftNotePublishRequest{}}), + withMethodCheck(http.MethodPost), + ) data := getSuccessData(t, svc, request) - assert(t, data.Status, http.StatusOK) assert(t, data.Message, "Draft note(s) published") }) - t.Run("Disallows non-POST method", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/mr/draft_notes/publish", testDraftNotePublishRequest) - svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}} - data := getFailData(t, svc, request) - checkBadMethod(t, data, http.MethodPost) - }) - t.Run("Handles bad ID", func(t *testing.T) { - badData := testDraftNotePublishRequest - badData.Note = 0 - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", badData) - svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}} - data := getFailData(t, svc, request) - assert(t, data.Status, http.StatusBadRequest) - assert(t, data.Message, "Must provide Note ID") - }) t.Run("Handles error from Gitlab", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) - svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) + svc := middleware( + draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase: testBase{errFromGitlab: true}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPost: &DraftNotePublishRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not publish draft note(s)") }) } func TestPublishAllDraftNotes(t *testing.T) { - var testDraftNotePublishRequest = DraftNotePublishRequest{PublishAll: true} + var testDraftNotePublishRequest = DraftNotePublishRequest{} t.Run("Should publish all draft notes", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) - svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}} + svc := middleware( + draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPost: &DraftNotePublishRequest{}}), + withMethodCheck(http.MethodPost), + ) data := getSuccessData(t, svc, request) - assert(t, data.Status, http.StatusOK) assert(t, data.Message, "Draft note(s) published") }) - t.Run("Disallows non-POST method", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/mr/draft_notes/publish", testDraftNotePublishRequest) - svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{}} - data := getFailData(t, svc, request) - checkBadMethod(t, data, http.MethodPost) - }) t.Run("Handles error from Gitlab", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/draft_notes/publish", testDraftNotePublishRequest) - svc := draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) + svc := middleware( + draftNotePublisherService{testProjectData, fakeDraftNotePublisher{testBase: testBase{errFromGitlab: true}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPost: &DraftNotePublishRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not publish draft note(s)") }) } diff --git a/cmd/app/draft_notes.go b/cmd/app/draft_notes.go index 6af8f0d7..6c248115 100644 --- a/cmd/app/draft_notes.go +++ b/cmd/app/draft_notes.go @@ -3,8 +3,6 @@ package app import ( "encoding/json" "errors" - "fmt" - "io" "net/http" "strconv" "strings" @@ -12,36 +10,15 @@ import ( "github.com/xanzy/go-gitlab" ) -/* The data coming from the client when creating a draft note is the same, +/* The data coming from the client when creating a draft note is the same as when they are creating a normal comment, but the Gitlab endpoints + resources we handle are different */ -type PostDraftNoteRequest struct { - Comment string `json:"comment"` - DiscussionId string `json:"discussion_id,omitempty"` - PositionData -} - -type UpdateDraftNoteRequest struct { - Note string `json:"note"` - Position gitlab.PositionOptions -} - -type DraftNotePublishRequest struct { - Note int `json:"note,omitempty"` - PublishAll bool `json:"publish_all"` -} - type DraftNoteResponse struct { SuccessResponse DraftNote *gitlab.DraftNote `json:"draft_note"` } -type ListDraftNotesResponse struct { - SuccessResponse - DraftNotes []*gitlab.DraftNote `json:"draft_notes"` -} - /* DraftNoteWithPosition is a draft comment with an (optional) position data value embedded in it. The position data will be non-nil for range-based draft comments. */ type DraftNoteWithPosition struct { PositionData PositionData @@ -64,7 +41,7 @@ type draftNoteService struct { } /* draftNoteHandler creates, edits, and deletes draft notes */ -func (a draftNoteService) handler(w http.ResponseWriter, r *http.Request) { +func (a draftNoteService) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodGet: @@ -75,14 +52,16 @@ func (a draftNoteService) handler(w http.ResponseWriter, r *http.Request) { a.updateDraftNote(w, r) case http.MethodDelete: a.deleteDraftNote(w, r) - default: - w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s, %s, %s", http.MethodDelete, http.MethodPost, http.MethodPatch, http.MethodGet)) - handleError(w, InvalidRequestError{}, "Expected DELETE, GET, POST or PATCH", http.StatusMethodNotAllowed) } } +type ListDraftNotesResponse struct { + SuccessResponse + DraftNotes []*gitlab.DraftNote `json:"draft_notes"` +} + /* listDraftNotes lists all draft notes for the currently authenticated user */ -func (a draftNoteService) listDraftNotes(w http.ResponseWriter, _ *http.Request) { +func (a draftNoteService) listDraftNotes(w http.ResponseWriter, r *http.Request) { opt := gitlab.ListDraftNotesOptions{} draftNotes, res, err := a.client.ListDraftNotes(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opt) @@ -93,17 +72,14 @@ func (a draftNoteService) listDraftNotes(w http.ResponseWriter, _ *http.Request) } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not get draft notes", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not get draft notes", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := ListDraftNotesResponse{ - SuccessResponse: SuccessResponse{ - Message: "Draft notes fetched successfully", - Status: http.StatusOK, - }, - DraftNotes: draftNotes, + SuccessResponse: SuccessResponse{Message: "Draft notes fetched successfully"}, + DraftNotes: draftNotes, } err = json.NewEncoder(w).Encode(response) @@ -112,34 +88,27 @@ func (a draftNoteService) listDraftNotes(w http.ResponseWriter, _ *http.Request) } } +type PostDraftNoteRequest struct { + Comment string `json:"comment" validate:"required"` + DiscussionId string `json:"discussion_id,omitempty" validate:"required"` + PositionData // TODO: How to add validations to data from external package??? +} + /* postDraftNote creates a draft note */ func (a draftNoteService) postDraftNote(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - defer r.Body.Close() - - var postDraftNoteRequest PostDraftNoteRequest - err = json.Unmarshal(body, &postDraftNoteRequest) - if err != nil { - handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) - return - } + payload := r.Context().Value(payload("payload")).(*PostDraftNoteRequest) opt := gitlab.CreateDraftNoteOptions{ - Note: &postDraftNoteRequest.Comment, + Note: &payload.Comment, } // Draft notes can be posted in "response" to existing discussions - if postDraftNoteRequest.DiscussionId != "" { - opt.InReplyToDiscussionID = gitlab.Ptr(postDraftNoteRequest.DiscussionId) + if payload.DiscussionId != "" { + opt.InReplyToDiscussionID = gitlab.Ptr(payload.DiscussionId) } - if postDraftNoteRequest.FileName != "" { - draftNoteWithPosition := DraftNoteWithPosition{postDraftNoteRequest.PositionData} + if payload.FileName != "" { + draftNoteWithPosition := DraftNoteWithPosition{payload.PositionData} opt.Position = buildCommentPosition(draftNoteWithPosition) } @@ -151,17 +120,14 @@ func (a draftNoteService) postDraftNote(w http.ResponseWriter, r *http.Request) } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/draft_notes/"}, "Could not create draft note", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not create draft note", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := DraftNoteResponse{ - SuccessResponse: SuccessResponse{ - Message: "Draft note created successfully", - Status: http.StatusOK, - }, - DraftNote: draftNote, + SuccessResponse: SuccessResponse{Message: "Draft note created successfully"}, + DraftNote: draftNote, } err = json.NewEncoder(w).Encode(response) @@ -187,15 +153,12 @@ func (a draftNoteService) deleteDraftNote(w http.ResponseWriter, r *http.Request } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: fmt.Sprintf("/mr/draft_notes/%d", id)}, "Could not delete draft note", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not delete draft note", res.StatusCode) return } w.WriteHeader(http.StatusOK) - response := SuccessResponse{ - Message: "Draft note deleted", - Status: http.StatusOK, - } + response := SuccessResponse{Message: "Draft note deleted"} err = json.NewEncoder(w).Encode(response) if err != nil { @@ -203,6 +166,11 @@ func (a draftNoteService) deleteDraftNote(w http.ResponseWriter, r *http.Request } } +type UpdateDraftNoteRequest struct { + Note string `json:"note" validate:"required"` + Position gitlab.PositionOptions +} + /* updateDraftNote edits the text of a draft comment */ func (a draftNoteService) updateDraftNote(w http.ResponseWriter, r *http.Request) { suffix := strings.TrimPrefix(r.URL.Path, "/mr/draft_notes/") @@ -212,29 +180,16 @@ func (a draftNoteService) updateDraftNote(w http.ResponseWriter, r *http.Request return } - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - defer r.Body.Close() - - var updateDraftNoteRequest UpdateDraftNoteRequest - err = json.Unmarshal(body, &updateDraftNoteRequest) - if err != nil { - handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) - return - } + payload := r.Context().Value(payload("payload")).(*UpdateDraftNoteRequest) - if updateDraftNoteRequest.Note == "" { + if payload.Note == "" { handleError(w, errors.New("Draft note text missing"), "Must provide draft note text", http.StatusBadRequest) return } opt := gitlab.UpdateDraftNoteOptions{ - Note: &updateDraftNoteRequest.Note, - Position: &updateDraftNoteRequest.Position, + Note: &payload.Note, + Position: &payload.Position, } draftNote, res, err := a.client.UpdateDraftNote(a.projectInfo.ProjectId, a.projectInfo.MergeId, id, &opt) @@ -245,17 +200,14 @@ func (a draftNoteService) updateDraftNote(w http.ResponseWriter, r *http.Request } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: fmt.Sprintf("/mr/draft_notes/%d", id)}, "Could not update draft note", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not update draft note", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := DraftNoteResponse{ - SuccessResponse: SuccessResponse{ - Message: "Draft note updated", - Status: http.StatusOK, - }, - DraftNote: draftNote, + SuccessResponse: SuccessResponse{Message: "Draft note updated"}, + DraftNote: draftNote, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/draft_notes_test.go b/cmd/app/draft_notes_test.go index 17b32599..3702a69e 100644 --- a/cmd/app/draft_notes_test.go +++ b/cmd/app/draft_notes_test.go @@ -42,74 +42,99 @@ func (f fakeDraftNoteManager) UpdateDraftNote(pid interface{}, mergeRequest int, func TestListDraftNotes(t *testing.T) { t.Run("Lists all draft notes", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{}} + svc := middleware( + draftNoteService{testProjectData, fakeDraftNoteManager{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostDraftNoteRequest{}, + http.MethodPatch: &UpdateDraftNoteRequest{}, + }), + withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete), + ) + data := getSuccessData(t, svc, request) - assert(t, data.Status, http.StatusOK) assert(t, data.Message, "Draft notes fetched successfully") }) t.Run("Handles error from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) + svc := middleware( + draftNoteService{testProjectData, fakeDraftNoteManager{testBase: testBase{errFromGitlab: true}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostDraftNoteRequest{}, + http.MethodPatch: &UpdateDraftNoteRequest{}, + }), + withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not get draft notes") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/mr/draft_notes/", nil) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) + svc := middleware( + draftNoteService{testProjectData, fakeDraftNoteManager{testBase: testBase{status: http.StatusSeeOther}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostDraftNoteRequest{}, + http.MethodPatch: &UpdateDraftNoteRequest{}, + }), + withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete), + ) + data, _ := getFailData(t, svc, request) checkNon200(t, data, "Could not get draft notes", "/mr/draft_notes/") }) } func TestPostDraftNote(t *testing.T) { - var testPostDraftNoteRequestData = PostDraftNoteRequest{Comment: "Some comment"} + var testPostDraftNoteRequestData = PostDraftNoteRequest{ + Comment: "Some comment", + DiscussionId: "abc123", + } t.Run("Posts new draft note", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{}} + svc := middleware( + draftNoteService{testProjectData, fakeDraftNoteManager{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostDraftNoteRequest{}, + http.MethodPatch: &UpdateDraftNoteRequest{}, + }), + withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete), + ) data := getSuccessData(t, svc, request) - assert(t, data.Status, http.StatusOK) assert(t, data.Message, "Draft note created successfully") }) - t.Run("Handles error from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) - checkErrorFromGitlab(t, data, "Could not create draft note") - }) - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/draft_notes/", testPostDraftNoteRequestData) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) - checkNon200(t, data, "Could not create draft note", "/mr/draft_notes/") - }) } func TestDeleteDraftNote(t *testing.T) { t.Run("Deletes new draft note", func(t *testing.T) { request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{}} + svc := middleware( + draftNoteService{testProjectData, fakeDraftNoteManager{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostDraftNoteRequest{}, + http.MethodPatch: &UpdateDraftNoteRequest{}, + }), + withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete), + ) data := getSuccessData(t, svc, request) - assert(t, data.Status, http.StatusOK) assert(t, data.Message, "Draft note deleted") }) - t.Run("Handles error from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) - checkErrorFromGitlab(t, data, "Could not delete draft note") - }) - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/3", nil) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) - checkNon200(t, data, "Could not delete draft note", "/mr/draft_notes/3") - }) t.Run("Handles bad ID", func(t *testing.T) { request := makeRequest(t, http.MethodDelete, "/mr/draft_notes/blah", nil) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) + svc := middleware( + draftNoteService{testProjectData, fakeDraftNoteManager{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostDraftNoteRequest{}, + http.MethodPatch: &UpdateDraftNoteRequest{}, + }), + withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete), + ) + data, status := getFailData(t, svc, request) assert(t, data.Message, "Could not parse draft note ID") - assert(t, data.Status, http.StatusBadRequest) + assert(t, status, http.StatusBadRequest) }) } @@ -117,37 +142,49 @@ func TestEditDraftNote(t *testing.T) { var testUpdateDraftNoteRequest = UpdateDraftNoteRequest{Note: "Some new note"} t.Run("Edits new draft note", func(t *testing.T) { request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{}} + svc := middleware( + draftNoteService{testProjectData, fakeDraftNoteManager{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostDraftNoteRequest{}, + http.MethodPatch: &UpdateDraftNoteRequest{}, + }), + withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete), + ) data := getSuccessData(t, svc, request) - assert(t, data.Status, http.StatusOK) assert(t, data.Message, "Draft note updated") }) - t.Run("Handles error from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) - checkErrorFromGitlab(t, data, "Could not update draft note") - }) - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", testUpdateDraftNoteRequest) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) - checkNon200(t, data, "Could not update draft note", "/mr/draft_notes/3") - }) t.Run("Handles bad ID", func(t *testing.T) { request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/blah", testUpdateDraftNoteRequest) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) + svc := middleware( + draftNoteService{testProjectData, fakeDraftNoteManager{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostDraftNoteRequest{}, + http.MethodPatch: &UpdateDraftNoteRequest{}, + }), + withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete), + ) + data, status := getFailData(t, svc, request) assert(t, data.Message, "Could not parse draft note ID") - assert(t, data.Status, http.StatusBadRequest) + assert(t, status, http.StatusBadRequest) }) t.Run("Handles empty note", func(t *testing.T) { requestData := testUpdateDraftNoteRequest requestData.Note = "" request := makeRequest(t, http.MethodPatch, "/mr/draft_notes/3", requestData) - svc := draftNoteService{testProjectData, fakeDraftNoteManager{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) - assert(t, data.Message, "Must provide draft note text") - assert(t, data.Status, http.StatusBadRequest) + svc := middleware( + draftNoteService{testProjectData, fakeDraftNoteManager{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostDraftNoteRequest{}, + http.MethodPatch: &UpdateDraftNoteRequest{}, + }), + withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete), + ) + data, status := getFailData(t, svc, request) + assert(t, data.Message, "Invalid payload") + assert(t, data.Details, "Note is required") + assert(t, status, http.StatusBadRequest) }) } diff --git a/cmd/app/emoji.go b/cmd/app/emoji.go index 979fce6f..4a535038 100644 --- a/cmd/app/emoji.go +++ b/cmd/app/emoji.go @@ -48,16 +48,13 @@ type emojiService struct { client EmojiManager } -func (a emojiService) handler(w http.ResponseWriter, r *http.Request) { +func (a emojiService) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.Method { case http.MethodPost: a.postEmojiOnNote(w, r) case http.MethodDelete: a.deleteEmojiFromNote(w, r) - default: - w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s", http.MethodDelete, http.MethodPost)) - handleError(w, InvalidRequestError{}, "Expected DELETE or POST", http.StatusMethodNotAllowed) } } @@ -87,15 +84,12 @@ func (a emojiService) deleteEmojiFromNote(w http.ResponseWriter, r *http.Request } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/pipeline"}, "Could not delete awardable", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not delete awardable", res.StatusCode) return } w.WriteHeader(http.StatusOK) - response := SuccessResponse{ - Message: "Emoji deleted", - Status: http.StatusOK, - } + response := SuccessResponse{Message: "Emoji deleted"} err = json.NewEncoder(w).Encode(response) if err != nil { @@ -131,17 +125,14 @@ func (a emojiService) postEmojiOnNote(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/awardable/note"}, "Could not post emoji", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not post emoji", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := CreateEmojiResponse{ - SuccessResponse: SuccessResponse{ - Message: "Merge requests retrieved", - Status: http.StatusOK, - }, - Emoji: awardEmoji, + SuccessResponse: SuccessResponse{Message: "Merge requests retrieved"}, + Emoji: awardEmoji, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/info.go b/cmd/app/info.go index 8838efdc..ea1321ae 100644 --- a/cmd/app/info.go +++ b/cmd/app/info.go @@ -22,14 +22,7 @@ type infoService struct { } /* infoHandler fetches infomation about the current git project. The data returned here is used in many other API calls */ -func (a infoService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodGet { - w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) - handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed) - return - } - +func (a infoService) ServeHTTP(w http.ResponseWriter, r *http.Request) { mr, res, err := a.client.GetMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.GetMergeRequestsOptions{}) if err != nil { handleError(w, err, "Could not get project info", http.StatusInternalServerError) @@ -37,17 +30,14 @@ func (a infoService) handler(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/info"}, "Could not get project info", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not get project info", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := InfoResponse{ - SuccessResponse: SuccessResponse{ - Message: "Merge requests retrieved", - Status: http.StatusOK, - }, - Info: mr, + SuccessResponse: SuccessResponse{Message: "Merge requests retrieved"}, + Info: mr, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/info_test.go b/cmd/app/info_test.go index e2253b50..66dd90af 100644 --- a/cmd/app/info_test.go +++ b/cmd/app/info_test.go @@ -23,27 +23,29 @@ func (f fakeMergeRequestGetter) GetMergeRequest(pid interface{}, mergeRequest in func TestInfoHandler(t *testing.T) { t.Run("Returns normal information", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/mr/info", nil) - svc := infoService{testProjectData, fakeMergeRequestGetter{}} + svc := middleware( + infoService{testProjectData, fakeMergeRequestGetter{}}, + withMethodCheck(http.MethodGet), + ) data := getSuccessData(t, svc, request) assert(t, data.Message, "Merge requests retrieved") - assert(t, data.Status, http.StatusOK) - }) - t.Run("Disallows non-GET methods", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/info", nil) - svc := infoService{testProjectData, fakeMergeRequestGetter{}} - data := getFailData(t, svc, request) - checkBadMethod(t, data, http.MethodGet) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/mr/info", nil) - svc := infoService{testProjectData, fakeMergeRequestGetter{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) + svc := middleware( + infoService{testProjectData, fakeMergeRequestGetter{testBase{errFromGitlab: true}}}, + withMethodCheck(http.MethodGet), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not get project info") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/mr/info", nil) - svc := infoService{testProjectData, fakeMergeRequestGetter{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) + svc := middleware( + infoService{testProjectData, fakeMergeRequestGetter{testBase{status: http.StatusSeeOther}}}, + withMethodCheck(http.MethodGet), + ) + data, _ := getFailData(t, svc, request) checkNon200(t, data, "Could not get project info", "/mr/info") }) } diff --git a/cmd/app/job.go b/cmd/app/job.go index 4fc7abd9..ce7436e8 100644 --- a/cmd/app/job.go +++ b/cmd/app/job.go @@ -10,7 +10,7 @@ import ( ) type JobTraceRequest struct { - JobId int `json:"job_id"` + JobId int `json:"job_id" validate:"required"` } type JobTraceResponse struct { @@ -28,30 +28,11 @@ type traceFileService struct { } /* jobHandler returns a string that shows the output of a specific job run in a Gitlab pipeline */ -func (a traceFileService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodGet { - w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) - handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - defer r.Body.Close() +func (a traceFileService) ServeHTTP(w http.ResponseWriter, r *http.Request) { - var jobTraceRequest JobTraceRequest - err = json.Unmarshal(body, &jobTraceRequest) - if err != nil { - handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) - return - } + payload := r.Context().Value(payload("payload")).(*JobTraceRequest) - reader, res, err := a.client.GetTraceFile(a.projectInfo.ProjectId, jobTraceRequest.JobId) + reader, res, err := a.client.GetTraceFile(a.projectInfo.ProjectId, payload.JobId) if err != nil { handleError(w, err, "Could not get trace file for job", http.StatusInternalServerError) @@ -59,7 +40,7 @@ func (a traceFileService) handler(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/job"}, "Could not get trace file for job", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not get trace file for job", res.StatusCode) return } @@ -71,11 +52,8 @@ func (a traceFileService) handler(w http.ResponseWriter, r *http.Request) { } response := JobTraceResponse{ - SuccessResponse: SuccessResponse{ - Status: http.StatusOK, - Message: "Log file read", - }, - File: string(file), + SuccessResponse: SuccessResponse{Message: "Log file read"}, + File: string(file), } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/job_test.go b/cmd/app/job_test.go index 37073707..f9465484 100644 --- a/cmd/app/job_test.go +++ b/cmd/app/job_test.go @@ -14,9 +14,9 @@ type fakeTraceFileGetter struct { testBase } -func getTraceFileData(t *testing.T, svc ServiceWithHandler, request *http.Request) JobTraceResponse { +func getTraceFileData(t *testing.T, svc http.Handler, request *http.Request) JobTraceResponse { res := httptest.NewRecorder() - svc.handler(res, request) + svc.ServeHTTP(res, request) var data JobTraceResponse err := json.Unmarshal(res.Body.Bytes(), &data) @@ -35,37 +35,37 @@ func (f fakeTraceFileGetter) GetTraceFile(pid interface{}, jobID int, options .. return re, resp, err } -// var jobId = 0 func TestJobHandler(t *testing.T) { t.Run("Should read a job trace file", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) - client := fakeTraceFileGetter{} - svc := traceFileService{testProjectData, client} + request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{JobId: 3}) + svc := middleware( + traceFileService{testProjectData, fakeTraceFileGetter{}}, + withPayloadValidation(methodToPayload{http.MethodGet: &JobTraceRequest{}}), + withMethodCheck(http.MethodGet), + ) data := getTraceFileData(t, svc, request) assert(t, data.Message, "Log file read") - assert(t, data.Status, http.StatusOK) assert(t, data.File, "Some data") }) - t.Run("Disallows non-GET methods", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/job", JobTraceRequest{}) - client := fakeTraceFileGetter{} - svc := traceFileService{testProjectData, client} - data := getFailData(t, svc, request) - checkBadMethod(t, data, http.MethodGet) - }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) - client := fakeTraceFileGetter{testBase{errFromGitlab: true}} - svc := traceFileService{testProjectData, client} - data := getFailData(t, svc, request) + request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{JobId: 2}) + svc := middleware( + traceFileService{testProjectData, fakeTraceFileGetter{testBase{errFromGitlab: true}}}, + withPayloadValidation(methodToPayload{http.MethodGet: &JobTraceRequest{}}), + withMethodCheck(http.MethodGet), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not get trace file for job") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{}) - client := fakeTraceFileGetter{testBase{status: http.StatusSeeOther}} - svc := traceFileService{testProjectData, client} - data := getFailData(t, svc, request) + request := makeRequest(t, http.MethodGet, "/job", JobTraceRequest{JobId: 1}) + svc := middleware( + traceFileService{testProjectData, fakeTraceFileGetter{testBase{status: http.StatusSeeOther}}}, + withPayloadValidation(methodToPayload{http.MethodGet: &JobTraceRequest{}}), + withMethodCheck(http.MethodGet), + ) + data, _ := getFailData(t, svc, request) checkNon200(t, data, "Could not get trace file for job", "/job") }) } diff --git a/cmd/app/label.go b/cmd/app/label.go index d2c7b998..ca0ba426 100644 --- a/cmd/app/label.go +++ b/cmd/app/label.go @@ -2,7 +2,6 @@ package app import ( "encoding/json" - "fmt" "io" "net/http" @@ -39,20 +38,16 @@ type labelService struct { } /* labelsHandler adds or removes labels from a merge request, and returns all labels for the current project */ -func (a labelService) handler(w http.ResponseWriter, r *http.Request) { +func (a labelService) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: a.getLabels(w, r) case http.MethodPut: a.updateLabels(w, r) - default: - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s", http.MethodPut, http.MethodGet)) - handleError(w, InvalidRequestError{}, "Expected GET or PUT", http.StatusMethodNotAllowed) } } -func (a labelService) getLabels(w http.ResponseWriter, _ *http.Request) { +func (a labelService) getLabels(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") labels, res, err := a.client.ListLabels(a.projectInfo.ProjectId, &gitlab.ListLabelsOptions{}) @@ -63,7 +58,7 @@ func (a labelService) getLabels(w http.ResponseWriter, _ *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/label"}, "Could not modify merge request labels", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not modify merge request labels", res.StatusCode) return } @@ -78,11 +73,8 @@ func (a labelService) getLabels(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) response := LabelsRequestResponse{ - SuccessResponse: SuccessResponse{ - Message: "Labels updated", - Status: http.StatusOK, - }, - Labels: convertedLabels, + SuccessResponse: SuccessResponse{Message: "Labels updated"}, + Labels: convertedLabels, } err = json.NewEncoder(w).Encode(response) @@ -120,17 +112,14 @@ func (a labelService) updateLabels(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/label"}, "Could not modify merge request labels", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not modify merge request labels", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := LabelUpdateResponse{ - SuccessResponse: SuccessResponse{ - Message: "Labels updated", - Status: http.StatusOK, - }, - Labels: mr.Labels, + SuccessResponse: SuccessResponse{Message: "Labels updated"}, + Labels: mr.Labels, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/list_discussions.go b/cmd/app/list_discussions.go index c2a47b7d..a75134ce 100644 --- a/cmd/app/list_discussions.go +++ b/cmd/app/list_discussions.go @@ -1,7 +1,6 @@ package app import ( - "io" "net/http" "sort" "sync" @@ -21,7 +20,7 @@ func Contains[T comparable](elems []T, v T) bool { } type DiscussionsRequest struct { - Blacklist []string `json:"blacklist"` + Blacklist []string `json:"blacklist" validate:"required"` } type DiscussionsResponse struct { @@ -61,27 +60,9 @@ type discussionsListerService struct { listDiscussionsHandler lists all discusions for a given merge request, both those linked and unlinked to particular points in the code. The responses are sorted by date created, and blacklisted users are not included */ -func (a discussionsListerService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost { - w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) - handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(r.Body) +func (a discussionsListerService) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - } - - defer r.Body.Close() - - var requestBody DiscussionsRequest - err = json.Unmarshal(body, &requestBody) - if err != nil { - handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest) - } + request := r.Context().Value(payload(payload("payload"))).(*DiscussionsRequest) mergeRequestDiscussionOptions := gitlab.ListMergeRequestDiscussionsOptions{ Page: 1, @@ -96,7 +77,7 @@ func (a discussionsListerService) handler(w http.ResponseWriter, r *http.Request } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/discussions/list"}, "Could not list discussions", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not list discussions", res.StatusCode) return } @@ -106,7 +87,7 @@ func (a discussionsListerService) handler(w http.ResponseWriter, r *http.Request var linkedDiscussions []*gitlab.Discussion for _, discussion := range discussions { - if discussion.Notes == nil || len(discussion.Notes) == 0 || Contains(requestBody.Blacklist, discussion.Notes[0].Author.Username) { + if discussion.Notes == nil || len(discussion.Notes) == 0 || Contains(request.Blacklist, discussion.Notes[0].Author.Username) { continue } for _, note := range discussion.Notes { @@ -142,10 +123,7 @@ func (a discussionsListerService) handler(w http.ResponseWriter, r *http.Request w.WriteHeader(http.StatusOK) response := DiscussionsResponse{ - SuccessResponse: SuccessResponse{ - Message: "Discussions retrieved", - Status: http.StatusOK, - }, + SuccessResponse: SuccessResponse{Message: "Discussions retrieved"}, Discussions: linkedDiscussions, UnlinkedDiscussions: unlinkedDiscussions, Emojis: emojis, diff --git a/cmd/app/list_discussions_test.go b/cmd/app/list_discussions_test.go index ee4e0020..25d284c2 100644 --- a/cmd/app/list_discussions_test.go +++ b/cmd/app/list_discussions_test.go @@ -53,9 +53,9 @@ func (f fakeDiscussionsLister) ListMergeRequestAwardEmojiOnNote(pid interface{}, return []*gitlab.AwardEmoji{}, resp, err } -func getDiscussionsList(t *testing.T, svc ServiceWithHandler, request *http.Request) DiscussionsResponse { +func getDiscussionsList(t *testing.T, svc http.Handler, request *http.Request) DiscussionsResponse { res := httptest.NewRecorder() - svc.handler(res, request) + svc.ServeHTTP(res, request) var data DiscussionsResponse err := json.Unmarshal(res.Body.Bytes(), &data) @@ -67,46 +67,63 @@ func getDiscussionsList(t *testing.T, svc ServiceWithHandler, request *http.Requ func TestListDiscussions(t *testing.T) { t.Run("Returns sorted discussions", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) - svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}} + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{}}) + svc := middleware( + discussionsListerService{testProjectData, fakeDiscussionsLister{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPost: &DiscussionsRequest{}}), + withMethodCheck(http.MethodPost), + ) data := getDiscussionsList(t, svc, request) assert(t, data.Message, "Discussions retrieved") - assert(t, data.SuccessResponse.Status, http.StatusOK) assert(t, data.Discussions[0].Notes[0].Author.Username, "hcramer2") /* Sorting applied */ assert(t, data.Discussions[1].Notes[0].Author.Username, "hcramer") }) t.Run("Uses blacklist to filter unwanted authors", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{"hcramer"}}) - svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}} + svc := middleware( + discussionsListerService{testProjectData, fakeDiscussionsLister{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPost: &DiscussionsRequest{}}), + withMethodCheck(http.MethodPost), + ) data := getDiscussionsList(t, svc, request) assert(t, data.SuccessResponse.Message, "Discussions retrieved") - assert(t, data.SuccessResponse.Status, http.StatusOK) assert(t, len(data.Discussions), 1) assert(t, data.Discussions[0].Notes[0].Author.Username, "hcramer2") }) - t.Run("Disallows non-GET methods", func(t *testing.T) { - request := makeRequest(t, http.MethodGet, "/mr/discussions/list", DiscussionsRequest{}) - svc := discussionsListerService{testProjectData, fakeDiscussionsLister{}} - data := getFailData(t, svc, request) - checkBadMethod(t, data, http.MethodPost) - }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) - svc := discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{}}) + svc := middleware( + discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{errFromGitlab: true}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPost: &DiscussionsRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not list discussions") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) - svc := discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{}}) + svc := middleware( + discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{status: http.StatusSeeOther}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPost: &DiscussionsRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkNon200(t, data, "Could not list discussions", "/mr/discussions/list") }) t.Run("Handles error from emoji service", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{}) - svc := discussionsListerService{testProjectData, fakeDiscussionsLister{badEmojiResponse: true}} - data := getFailData(t, svc, request) + request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{}}) + svc := middleware( + discussionsListerService{testProjectData, fakeDiscussionsLister{badEmojiResponse: true, testBase: testBase{}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPost: &DiscussionsRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) assert(t, data.Message, "Could not fetch emojis") assert(t, data.Details, "Some error from emoji service") }) diff --git a/cmd/app/logging.go b/cmd/app/logging.go new file mode 100644 index 00000000..e7fda5d9 --- /dev/null +++ b/cmd/app/logging.go @@ -0,0 +1,96 @@ +package app + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "net/http/httputil" + "os" +) + +// LoggingServer is a wrapper around an http.Handler to log incoming requests and outgoing responses. +type LoggingServer struct { + handler http.Handler +} + +type LoggingResponseWriter struct { + statusCode int + body *bytes.Buffer + http.ResponseWriter +} + +func (l *LoggingResponseWriter) WriteHeader(statusCode int) { + l.statusCode = statusCode +} + +func (l *LoggingResponseWriter) Write(b []byte) (int, error) { + l.body.Write(b) + return l.ResponseWriter.Write(b) +} + +// Logs the request, calls the original handler on the ServeMux, then logs the response +func (l LoggingServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if pluginOptions.Debug.Request { + logRequest("REQUEST TO GO SERVER", r) + } + lrw := &LoggingResponseWriter{ResponseWriter: w, body: &bytes.Buffer{}} + l.handler.ServeHTTP(lrw, r) + resp := &http.Response{ + Status: http.StatusText(lrw.statusCode), + StatusCode: lrw.statusCode, + Body: io.NopCloser(bytes.NewBuffer(lrw.body.Bytes())), // Use the captured body + ContentLength: int64(lrw.body.Len()), + Header: lrw.Header(), + Request: r, + } + if pluginOptions.Debug.Response { + logResponse("RESPONSE FROM GO SERVER", resp) + } +} + +func logRequest(prefix string, r *http.Request) { + file := openLogFile() + defer file.Close() + token := r.Header.Get("Private-Token") + r.Header.Set("Private-Token", "REDACTED") + res, err := httputil.DumpRequest(r, true) + if err != nil { + log.Fatalf("Error dumping request: %v", err) + os.Exit(1) + } + r.Header.Set("Private-Token", token) + _, err = file.Write([]byte(fmt.Sprintf("\n-- %s --\n%s\n", prefix, res))) //nolint:all +} + +func logResponse(prefix string, r *http.Response) { + file := openLogFile() + defer file.Close() + + res, err := httputil.DumpResponse(r, true) + if err != nil { + log.Fatalf("Error dumping response: %v", err) + os.Exit(1) + } + + _, err = file.Write([]byte(fmt.Sprintf("\n-- %s --\n%s\n", prefix, res))) //nolint:all +} + +func openLogFile() *os.File { + file, err := os.OpenFile(pluginOptions.LogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + if os.IsNotExist(err) { + log.Printf("Log file %s does not exist", pluginOptions.LogPath) + } else if os.IsPermission(err) { + log.Printf("Permission denied for log file %s", pluginOptions.LogPath) + } else { + log.Printf("Error opening log file %s: %v", pluginOptions.LogPath, err) + } + + os.Exit(1) + } + + return file +} diff --git a/cmd/app/members.go b/cmd/app/members.go index 9002ac0b..d8293a4a 100644 --- a/cmd/app/members.go +++ b/cmd/app/members.go @@ -22,13 +22,7 @@ type projectMemberService struct { } /* projectMembersHandler returns all members of the current Gitlab project */ -func (a projectMemberService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodGet { - w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) - handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed) - return - } +func (a projectMemberService) ServeHTTP(w http.ResponseWriter, r *http.Request) { projectMemberOptions := gitlab.ListProjectMembersOptions{ ListOptions: gitlab.ListOptions{ @@ -44,18 +38,15 @@ func (a projectMemberService) handler(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/project/members"}, "Could not retrieve project members", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not retrieve project members", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := ProjectMembersResponse{ - SuccessResponse: SuccessResponse{ - Status: http.StatusOK, - Message: "Project members retrieved", - }, - ProjectMembers: projectMembers, + SuccessResponse: SuccessResponse{Message: "Project members retrieved"}, + ProjectMembers: projectMembers, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/members_test.go b/cmd/app/members_test.go index cdf10efe..e641b1ce 100644 --- a/cmd/app/members_test.go +++ b/cmd/app/members_test.go @@ -22,27 +22,29 @@ func (f fakeMemberLister) ListAllProjectMembers(pid interface{}, opt *gitlab.Lis func TestMembersHandler(t *testing.T) { t.Run("Returns project members", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/project/members", nil) - svc := projectMemberService{testProjectData, fakeMemberLister{}} + svc := middleware( + projectMemberService{testProjectData, fakeMemberLister{}}, + withMethodCheck(http.MethodGet), + ) data := getSuccessData(t, svc, request) - assert(t, data.Status, http.StatusOK) assert(t, data.Message, "Project members retrieved") }) - t.Run("Disallows non-GET methods", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/project/members", nil) - svc := projectMemberService{testProjectData, fakeMemberLister{}} - data := getFailData(t, svc, request) - checkBadMethod(t, data, http.MethodGet) - }) t.Run("Handles error from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/project/members", nil) - svc := projectMemberService{testProjectData, fakeMemberLister{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) + svc := middleware( + projectMemberService{testProjectData, fakeMemberLister{testBase{errFromGitlab: true}}}, + withMethodCheck(http.MethodGet), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not retrieve project members") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/project/members", nil) - svc := projectMemberService{testProjectData, fakeMemberLister{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) + svc := middleware( + projectMemberService{testProjectData, fakeMemberLister{testBase{status: http.StatusSeeOther}}}, + withMethodCheck(http.MethodGet), + ) + data, _ := getFailData(t, svc, request) checkNon200(t, data, "Could not retrieve project members", "/project/members") }) } diff --git a/cmd/app/merge_mr.go b/cmd/app/merge_mr.go index 89245cb4..872ac548 100644 --- a/cmd/app/merge_mr.go +++ b/cmd/app/merge_mr.go @@ -2,16 +2,15 @@ package app import ( "encoding/json" - "io" "net/http" "github.com/xanzy/go-gitlab" ) type AcceptMergeRequestRequest struct { - Squash bool `json:"squash"` - SquashMessage string `json:"squash_message"` DeleteBranch bool `json:"delete_branch"` + SquashMessage string `json:"squash_message"` + Squash bool `json:"squash"` } type MergeRequestAccepter interface { @@ -24,34 +23,16 @@ type mergeRequestAccepterService struct { } /* acceptAndMergeHandler merges a given merge request into the target branch */ -func (a mergeRequestAccepterService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) - if r.Method != http.MethodPost { - handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - var acceptAndMergeRequest AcceptMergeRequestRequest - err = json.Unmarshal(body, &acceptAndMergeRequest) - if err != nil { - handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest) - return - } +func (a mergeRequestAccepterService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + payload := r.Context().Value(payload("payload")).(*AcceptMergeRequestRequest) opts := gitlab.AcceptMergeRequestOptions{ - Squash: &acceptAndMergeRequest.Squash, - ShouldRemoveSourceBranch: &acceptAndMergeRequest.DeleteBranch, + Squash: &payload.Squash, + ShouldRemoveSourceBranch: &payload.DeleteBranch, } - if acceptAndMergeRequest.SquashMessage != "" { - opts.SquashCommitMessage = &acceptAndMergeRequest.SquashMessage + if payload.SquashMessage != "" { + opts.SquashCommitMessage = &payload.SquashMessage } _, res, err := a.client.AcceptMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opts) @@ -62,14 +43,11 @@ func (a mergeRequestAccepterService) handler(w http.ResponseWriter, r *http.Requ } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/merge"}, "Could not merge MR", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not merge MR", res.StatusCode) return } - response := SuccessResponse{ - Status: http.StatusOK, - Message: "MR merged successfully", - } + response := SuccessResponse{Message: "MR merged successfully"} w.WriteHeader(http.StatusOK) diff --git a/cmd/app/merge_mr_test.go b/cmd/app/merge_mr_test.go index 1adabba4..9c48cf41 100644 --- a/cmd/app/merge_mr_test.go +++ b/cmd/app/merge_mr_test.go @@ -24,27 +24,41 @@ func TestAcceptAndMergeHandler(t *testing.T) { var testAcceptMergeRequestPayload = AcceptMergeRequestRequest{Squash: false, SquashMessage: "Squash me!", DeleteBranch: false} t.Run("Accepts and merges a merge request", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) - svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{}} + svc := middleware( + mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &AcceptMergeRequestRequest{}, + }), + withMethodCheck(http.MethodPost), + ) data := getSuccessData(t, svc, request) assert(t, data.Message, "MR merged successfully") - assert(t, data.Status, http.StatusOK) - }) - t.Run("Disallows non-POST methods", func(t *testing.T) { - request := makeRequest(t, http.MethodPut, "/mr/merge", testAcceptMergeRequestPayload) - svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{}} - data := getFailData(t, svc, request) - checkBadMethod(t, data, http.MethodPost) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) - svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) + svc := middleware( + mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{errFromGitlab: true}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &AcceptMergeRequestRequest{}, + }), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not merge MR") }) t.Run("Handles non-200s from Gitlab", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/merge", testAcceptMergeRequestPayload) - svc := mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) + svc := middleware( + mergeRequestAccepterService{testProjectData, fakeMergeRequestAccepter{testBase{status: http.StatusSeeOther}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: &AcceptMergeRequestRequest{}, + }), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkNon200(t, data, "Could not merge MR", "/mr/merge") }) } diff --git a/cmd/app/merge_requests.go b/cmd/app/merge_requests.go index bc7d1ab1..4fd58b6d 100644 --- a/cmd/app/merge_requests.go +++ b/cmd/app/merge_requests.go @@ -3,7 +3,6 @@ package app import ( "encoding/json" "errors" - "io" "net/http" "github.com/xanzy/go-gitlab" @@ -23,37 +22,20 @@ type mergeRequestListerService struct { client MergeRequestLister } -func (a mergeRequestListerService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost { - w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) - handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) - return - } +// Lists all merge requests in Gitlab according to the provided filters +func (a mergeRequestListerService) ServeHTTP(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - defer r.Body.Close() - var listMergeRequestRequest gitlab.ListProjectMergeRequestsOptions - err = json.Unmarshal(body, &listMergeRequestRequest) - if err != nil { - handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) - return - } + payload := r.Context().Value(payload("payload")).(*gitlab.ListProjectMergeRequestsOptions) - if listMergeRequestRequest.State == nil { - listMergeRequestRequest.State = gitlab.Ptr("opened") + if payload.State == nil { + payload.State = gitlab.Ptr("opened") } - if listMergeRequestRequest.Scope == nil { - listMergeRequestRequest.Scope = gitlab.Ptr("all") + if payload.Scope == nil { + payload.Scope = gitlab.Ptr("all") } - mergeRequests, res, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, &listMergeRequestRequest) + mergeRequests, res, err := a.client.ListProjectMergeRequests(a.projectInfo.ProjectId, payload) if err != nil { handleError(w, err, "Failed to list merge requests", http.StatusInternalServerError) @@ -61,7 +43,7 @@ func (a mergeRequestListerService) handler(w http.ResponseWriter, r *http.Reques } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/merge_requests"}, "Failed to list merge requests", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Failed to list merge requests", res.StatusCode) return } @@ -72,11 +54,8 @@ func (a mergeRequestListerService) handler(w http.ResponseWriter, r *http.Reques w.WriteHeader(http.StatusOK) response := ListMergeRequestResponse{ - SuccessResponse: SuccessResponse{ - Message: "Merge requests fetched successfully", - Status: http.StatusOK, - }, - MergeRequests: mergeRequests, + SuccessResponse: SuccessResponse{Message: "Merge requests fetched successfully"}, + MergeRequests: mergeRequests, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/merge_requests_by_username.go b/cmd/app/merge_requests_by_username.go index 4f64448a..4a097566 100644 --- a/cmd/app/merge_requests_by_username.go +++ b/cmd/app/merge_requests_by_username.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "sync" @@ -21,42 +20,15 @@ type mergeRequestListerByUsernameService struct { } type MergeRequestByUsernameRequest struct { - UserId int `json:"user_id"` - Username string `json:"username"` + UserId int `json:"user_id" validate:"required"` + Username string `json:"username" validate:"required"` State string `json:"state,omitempty"` } -func (a mergeRequestListerByUsernameService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost { - w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) - handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - defer r.Body.Close() - var request MergeRequestByUsernameRequest - err = json.Unmarshal(body, &request) - if err != nil { - handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) - return - } - - if request.Username == "" { - handleError(w, errors.New("username is a required payload field"), "username is required", http.StatusBadRequest) - return - } +// Returns a list of merge requests where the given username/id is either an assignee, reviewer, or author +func (a mergeRequestListerByUsernameService) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if request.UserId == 0 { - handleError(w, errors.New("user_id is a required payload field"), "user_id is required", http.StatusBadRequest) - return - } + request := r.Context().Value(payload("payload")).(*MergeRequestByUsernameRequest) if request.State == "" { request.State = "opened" @@ -133,14 +105,11 @@ func (a mergeRequestListerByUsernameService) handler(w http.ResponseWriter, r *h w.WriteHeader(http.StatusOK) response := ListMergeRequestResponse{ - SuccessResponse: SuccessResponse{ - Message: fmt.Sprintf("Merge requests fetched for %s", request.Username), - Status: http.StatusOK, - }, - MergeRequests: mergeRequests, + SuccessResponse: SuccessResponse{Message: fmt.Sprintf("Merge requests fetched for %s", request.Username)}, + MergeRequests: mergeRequests, } - err = json.NewEncoder(w).Encode(response) + err := json.NewEncoder(w).Encode(response) if err != nil { handleError(w, err, "Could not encode response", http.StatusInternalServerError) } diff --git a/cmd/app/merge_requests_by_username_test.go b/cmd/app/merge_requests_by_username_test.go index 34631a0b..a6c2d010 100644 --- a/cmd/app/merge_requests_by_username_test.go +++ b/cmd/app/merge_requests_by_username_test.go @@ -30,58 +30,81 @@ func TestListMergeRequestByUsername(t *testing.T) { var testListMrsByUsernamePayload = MergeRequestByUsernameRequest{Username: "hcramer", UserId: 1234, State: "opened"} t.Run("Gets merge requests by username", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload) - svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}} + svc := middleware( + mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}}, + withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}), + withMethodCheck(http.MethodPost), + ) data := getSuccessData(t, svc, request) assert(t, data.Message, "Merge requests fetched for hcramer") - assert(t, data.Status, http.StatusOK) }) t.Run("Should handle no merge requests", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload) - svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{emptyResponse: true}} - data := getFailData(t, svc, request) + svc := middleware( + mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{emptyResponse: true}}, + withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, status := getFailData(t, svc, request) assert(t, data.Message, "No MRs found") assert(t, data.Details, "hcramer did not have any MRs") - assert(t, data.Status, http.StatusNotFound) + assert(t, status, http.StatusNotFound) }) t.Run("Should require username", func(t *testing.T) { missingUsernamePayload := testListMrsByUsernamePayload missingUsernamePayload.Username = "" request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", missingUsernamePayload) - svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}} - data := getFailData(t, svc, request) - assert(t, data.Message, "username is required") - assert(t, data.Details, "username is a required payload field") - assert(t, data.Status, http.StatusBadRequest) + svc := middleware( + mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}}, + withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, status := getFailData(t, svc, request) + assert(t, data.Message, "Invalid payload") + assert(t, data.Details, "Username is required") + assert(t, status, http.StatusBadRequest) }) t.Run("Should require User ID for assignee call", func(t *testing.T) { missingUsernamePayload := testListMrsByUsernamePayload missingUsernamePayload.UserId = 0 request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", missingUsernamePayload) - svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}} - data := getFailData(t, svc, request) - assert(t, data.Message, "user_id is required") - assert(t, data.Details, "user_id is a required payload field") - assert(t, data.Status, http.StatusBadRequest) + svc := middleware( + mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{}}, + withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, status := getFailData(t, svc, request) + assert(t, data.Message, "Invalid payload") + assert(t, data.Details, "UserId is required") + assert(t, status, http.StatusBadRequest) }) t.Run("Should handle error from Gitlab", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload) - svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) + svc := middleware( + mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{errFromGitlab: true}}}, + withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, status := getFailData(t, svc, request) assert(t, data.Message, "An error occurred") assert(t, data.Details, strings.Repeat("Some error from Gitlab; ", 3)) - assert(t, data.Status, http.StatusInternalServerError) + assert(t, status, http.StatusInternalServerError) }) t.Run("Handles non-200 from Gitlab", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/merge_requests_by_username", testListMrsByUsernamePayload) - svc := mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) + svc := middleware( + mergeRequestListerByUsernameService{testProjectData, fakeMergeRequestListerByUsername{testBase: testBase{status: http.StatusSeeOther}}}, + withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, status := getFailData(t, svc, request) assert(t, data.Message, "An error occurred") assert(t, data.Details, strings.Repeat("An error occurred on the /merge_requests_by_username endpoint; ", 3)) - assert(t, data.Status, http.StatusInternalServerError) + assert(t, status, http.StatusInternalServerError) }) } diff --git a/cmd/app/merge_requests_test.go b/cmd/app/merge_requests_test.go index 79020ce8..5f8cd1c3 100644 --- a/cmd/app/merge_requests_test.go +++ b/cmd/app/merge_requests_test.go @@ -10,6 +10,7 @@ import ( type fakeMergeRequestLister struct { testBase emptyResponse bool + multipleMrs bool } func (f fakeMergeRequestLister) ListProjectMergeRequests(pid interface{}, opt *gitlab.ListProjectMergeRequestsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequest, *gitlab.Response, error) { @@ -22,6 +23,10 @@ func (f fakeMergeRequestLister) ListProjectMergeRequests(pid interface{}, opt *g return []*gitlab.MergeRequest{}, resp, err } + if f.multipleMrs { + return []*gitlab.MergeRequest{{IID: 10}, {IID: 11}}, resp, err + } + return []*gitlab.MergeRequest{{IID: 10}}, resp, err } @@ -29,30 +34,45 @@ func TestMergeRequestHandler(t *testing.T) { var testListMergeRequestsRequest = gitlab.ListProjectMergeRequestsOptions{} t.Run("Should fetch merge requests", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) - svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{}} + svc := middleware( + mergeRequestListerService{testProjectData, fakeMergeRequestLister{}}, + withPayloadValidation(methodToPayload{http.MethodPost: &gitlab.ListProjectMergeRequestsOptions{}}), + withMethodCheck(http.MethodPost), + ) data := getSuccessData(t, svc, request) - assert(t, data.Status, http.StatusOK) assert(t, data.Message, "Merge requests fetched successfully") }) t.Run("Handles error from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) - svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) + svc := middleware( + mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{errFromGitlab: true}}}, + withPayloadValidation(methodToPayload{http.MethodPost: &gitlab.ListProjectMergeRequestsOptions{}}), + withMethodCheck(http.MethodPost), + ) + data, status := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Failed to list merge requests") - assert(t, data.Status, http.StatusInternalServerError) + assert(t, status, http.StatusInternalServerError) }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) - svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) + svc := middleware( + mergeRequestListerService{testProjectData, fakeMergeRequestLister{testBase: testBase{status: http.StatusSeeOther}}}, + withPayloadValidation(methodToPayload{http.MethodPost: &gitlab.ListProjectMergeRequestsOptions{}}), + withMethodCheck(http.MethodPost), + ) + data, status := getFailData(t, svc, request) checkNon200(t, data, "Failed to list merge requests", "/merge_requests") - assert(t, data.Status, http.StatusSeeOther) + assert(t, status, http.StatusSeeOther) }) t.Run("Should handle not having any merge requests with 404", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/merge_requests", testListMergeRequestsRequest) - svc := mergeRequestListerService{testProjectData, fakeMergeRequestLister{emptyResponse: true}} - data := getFailData(t, svc, request) + svc := middleware( + mergeRequestListerService{testProjectData, fakeMergeRequestLister{emptyResponse: true}}, + withPayloadValidation(methodToPayload{http.MethodPost: &gitlab.ListProjectMergeRequestsOptions{}}), + withMethodCheck(http.MethodPost), + ) + data, status := getFailData(t, svc, request) assert(t, data.Message, "No merge requests found") - assert(t, data.Status, http.StatusNotFound) + assert(t, status, http.StatusNotFound) }) } diff --git a/cmd/app/middleware.go b/cmd/app/middleware.go new file mode 100644 index 00000000..ae991911 --- /dev/null +++ b/cmd/app/middleware.go @@ -0,0 +1,173 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/go-playground/validator/v10" + "github.com/xanzy/go-gitlab" +) + +type mw func(http.Handler) http.Handler + +type payload string + +// Wraps a series of middleware around the base handler. Functions are called from bottom to top. +// The middlewares should call the serveHTTP method on their http.Handler argument to pass along the request. +func middleware(h http.Handler, middlewares ...mw) http.HandlerFunc { + for _, middleware := range middlewares { + h = middleware(h) + } + return h.ServeHTTP +} + +var validate = validator.New() + +type methodToPayload map[string]any + +type validatorMiddleware struct { + validate *validator.Validate + methodToPayload methodToPayload +} + +// Validates the fields in a payload and then attaches the validated payload to the request context so that +// subsequent handlers can use it. +func (p validatorMiddleware) handle(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + if p.methodToPayload[r.Method] == nil { // If no payload to validate for this method type... + next.ServeHTTP(w, r) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + pl := p.methodToPayload[r.Method] + err = json.Unmarshal(body, &pl) + + if err != nil { + handleError(w, err, "Could not parse JSON request body", http.StatusBadRequest) + return + } + + err = p.validate.Struct(pl) + if err != nil { + switch err := err.(type) { + case validator.ValidationErrors: + handleError(w, formatValidationErrors(err), "Invalid payload", http.StatusBadRequest) + return + case *validator.InvalidValidationError: + handleError(w, err, "Invalid validation error", http.StatusInternalServerError) + return + } + } + + // Pass the parsed data so we don't have to re-parse it in the handler + ctx := context.WithValue(r.Context(), payload(payload("payload")), pl) + r = r.WithContext(ctx) + + next.ServeHTTP(w, r) + }) +} + +func withPayloadValidation(mtp methodToPayload) mw { + return validatorMiddleware{validate: validate, methodToPayload: mtp}.handle +} + +type withMrMiddleware struct { + data data + client MergeRequestLister +} + +// Gets the current merge request ID and attaches it to the projectInfo +func (m withMrMiddleware) handle(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // If the merge request is already attached, skip the middleware logic + if m.data.projectInfo.MergeId == 0 { + options := gitlab.ListProjectMergeRequestsOptions{ + Scope: gitlab.Ptr("all"), + SourceBranch: &m.data.gitInfo.BranchName, + TargetBranch: pluginOptions.ChosenTargetBranch, + } + + mergeRequests, _, err := m.client.ListProjectMergeRequests(m.data.projectInfo.ProjectId, &options) + if err != nil { + handleError(w, fmt.Errorf("Failed to list merge requests: %w", err), "Failed to list merge requests", http.StatusInternalServerError) + return + } + + if len(mergeRequests) == 0 { + err := fmt.Errorf("Branch '%s' does not have any merge requests", m.data.gitInfo.BranchName) + handleError(w, err, "No MRs Found", http.StatusNotFound) + return + } + + if len(mergeRequests) > 1 { + err := errors.New("Please call gitlab.choose_merge_request()") + handleError(w, err, "Multiple MRs found", http.StatusBadRequest) + return + } + + mergeIdInt := mergeRequests[0].IID + m.data.projectInfo.MergeId = mergeIdInt + } + + // Call the next handler if middleware succeeds + next.ServeHTTP(w, r) + }) +} + +// Att +func withMr(data data, client MergeRequestLister) mw { + return withMrMiddleware{data, client}.handle +} + +type methodMiddleware struct { + methods []string +} + +func (m methodMiddleware) handle(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method := r.Method + for _, acceptableMethod := range m.methods { + if method == acceptableMethod { + next.ServeHTTP(w, r) + return + } + } + + w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) + handleError(w, InvalidRequestError{fmt.Sprintf("Expected: %s", strings.Join(m.methods, "; "))}, "Invalid request type", http.StatusMethodNotAllowed) + }) +} + +func withMethodCheck(methods ...string) mw { + return methodMiddleware{methods: methods}.handle +} + +// Helper function to format validation errors into more readable strings +func formatValidationErrors(errors validator.ValidationErrors) error { + var s strings.Builder + for i, e := range errors { + if i > 0 { + s.WriteString("; ") + } + switch e.Tag() { + case "required": + s.WriteString(fmt.Sprintf("%s is required", e.Field())) + default: + s.WriteString(fmt.Sprintf("The field '%s' failed on validation on the '%s' tag", e.Field(), e.Tag())) + } + } + + return fmt.Errorf(s.String()) +} diff --git a/cmd/app/middleware_test.go b/cmd/app/middleware_test.go new file mode 100644 index 00000000..6e9afdfb --- /dev/null +++ b/cmd/app/middleware_test.go @@ -0,0 +1,114 @@ +package app + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/harrisoncramer/gitlab.nvim/cmd/app/git" +) + +type FakePayload struct { + Foo string `json:"foo" validate:"required"` +} + +type fakeHandler struct{} + +func (f fakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + data := SuccessResponse{Message: "Some message"} + j, _ := json.Marshal(data) + w.Write(j) // nolint + +} + +func TestMethodMiddleware(t *testing.T) { + t.Run("Fails a bad method", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/foo", nil) + mw := withMethodCheck(http.MethodPost) + handler := middleware(fakeHandler{}, mw) + data, status := getFailData(t, handler, request) + assert(t, data.Message, "Invalid request type") + assert(t, data.Details, "Expected: POST") + assert(t, status, http.StatusMethodNotAllowed) + }) + t.Run("Fails bad method with multiple", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/foo", nil) + mw := withMethodCheck(http.MethodPost, http.MethodPatch) + handler := middleware(fakeHandler{}, mw) + data, status := getFailData(t, handler, request) + assert(t, data.Message, "Invalid request type") + assert(t, data.Details, "Expected: POST; PATCH") + assert(t, status, http.StatusMethodNotAllowed) + }) + t.Run("Allows ok method through", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/foo", nil) + mw := withMethodCheck(http.MethodGet) + handler := middleware(fakeHandler{}, mw) + data := getSuccessData(t, handler, request) + assert(t, data.Message, "Some message") + }) +} + +func TestWithMrMiddleware(t *testing.T) { + t.Run("Loads an MR ID into the projectInfo", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/foo", nil) + d := data{ + projectInfo: &ProjectInfo{}, + gitInfo: &git.GitData{BranchName: "foo"}, + } + mw := withMr(d, fakeMergeRequestLister{}) + handler := middleware(fakeHandler{}, mw) + getSuccessData(t, handler, request) + if d.projectInfo.MergeId != 10 { + t.FailNow() + } + }) + t.Run("Handles when there are no MRs", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/foo", nil) + d := data{ + projectInfo: &ProjectInfo{}, + gitInfo: &git.GitData{BranchName: "foo"}, + } + mw := withMr(d, fakeMergeRequestLister{emptyResponse: true}) + handler := middleware(fakeHandler{}, mw) + data, status := getFailData(t, handler, request) + assert(t, status, http.StatusNotFound) + assert(t, data.Message, "No MRs Found") + assert(t, data.Details, "Branch 'foo' does not have any merge requests") + }) + t.Run("Handles when there are too many MRs", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/foo", nil) + d := data{ + projectInfo: &ProjectInfo{}, + gitInfo: &git.GitData{BranchName: "foo"}, + } + mw := withMr(d, fakeMergeRequestLister{multipleMrs: true}) + handler := middleware(fakeHandler{}, mw) + data, status := getFailData(t, handler, request) + assert(t, status, http.StatusBadRequest) + assert(t, data.Message, "Multiple MRs found") + assert(t, data.Details, "Please call gitlab.choose_merge_request()") + }) +} + +func TestValidatorMiddleware(t *testing.T) { + t.Run("Should error with missing field", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/foo", FakePayload{}) // No Foo field + data, status := getFailData(t, middleware( + fakeHandler{}, + withPayloadValidation(methodToPayload{http.MethodPost: &FakePayload{}}), + ), request) + assert(t, data.Message, "Invalid payload") + assert(t, data.Details, "Foo is required") + assert(t, status, http.StatusBadRequest) + }) + t.Run("Should allow valid payload through", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/foo", FakePayload{Foo: "Some payload"}) + data := getSuccessData(t, middleware( + fakeHandler{}, + withPayloadValidation(methodToPayload{http.MethodPost: &FakePayload{}}), + ), request) + assert(t, data.Message, "Some message") + }) +} diff --git a/cmd/app/pipeline.go b/cmd/app/pipeline.go index 587c1e0c..8640613a 100644 --- a/cmd/app/pipeline.go +++ b/cmd/app/pipeline.go @@ -43,16 +43,12 @@ type pipelineService struct { pipelineHandler fetches information about the current pipeline, and retriggers a pipeline run. For more detailed information about a given job in a pipeline, see the jobHandler function */ -func (a pipelineService) handler(w http.ResponseWriter, r *http.Request) { +func (a pipelineService) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: a.GetPipelineAndJobs(w, r) case http.MethodPost: a.RetriggerPipeline(w, r) - default: - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, %s", http.MethodGet, http.MethodPost)) - handleError(w, InvalidRequestError{}, "Expected GET or POST", http.StatusMethodNotAllowed) } } @@ -100,7 +96,7 @@ func (a pipelineService) GetPipelineAndJobs(w http.ResponseWriter, r *http.Reque } if pipeline == nil { - handleError(w, GenericError{endpoint: "/pipeline"}, fmt.Sprintf("No pipeline found for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError) + handleError(w, GenericError{r.URL.Path}, fmt.Sprintf("No pipeline found for %s branch", a.gitInfo.BranchName), http.StatusInternalServerError) return } @@ -112,16 +108,13 @@ func (a pipelineService) GetPipelineAndJobs(w http.ResponseWriter, r *http.Reque } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/pipeline"}, "Could not get pipeline jobs", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not get pipeline jobs", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := GetPipelineAndJobsResponse{ - SuccessResponse: SuccessResponse{ - Status: http.StatusOK, - Message: "Pipeline retrieved", - }, + SuccessResponse: SuccessResponse{Message: "Pipeline retrieved"}, Pipeline: PipelineWithJobs{ LatestPipeline: pipeline, Jobs: jobs, @@ -153,17 +146,14 @@ func (a pipelineService) RetriggerPipeline(w http.ResponseWriter, r *http.Reques } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/pipeline"}, "Could not retrigger pipeline", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not retrigger pipeline", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := RetriggerPipelineResponse{ - SuccessResponse: SuccessResponse{ - Message: "Pipeline retriggered", - Status: http.StatusOK, - }, - LatestPipeline: pipeline, + SuccessResponse: SuccessResponse{Message: "Pipeline retriggered"}, + LatestPipeline: pipeline, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/pipeline_test.go b/cmd/app/pipeline_test.go index d8fcecb9..db354946 100644 --- a/cmd/app/pipeline_test.go +++ b/cmd/app/pipeline_test.go @@ -38,27 +38,29 @@ func (f fakePipelineManager) RetryPipelineBuild(pid interface{}, pipeline int, o func TestPipelineGetter(t *testing.T) { t.Run("Gets all pipeline jobs", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/pipeline", nil) - svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}} + svc := middleware( + pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}}, + withMethodCheck(http.MethodGet), + ) data := getSuccessData(t, svc, request) assert(t, data.Message, "Pipeline retrieved") - assert(t, data.Status, http.StatusOK) - }) - t.Run("Disallows non-GET, non-POST methods", func(t *testing.T) { - request := makeRequest(t, http.MethodPatch, "/pipeline", nil) - svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}} - data := getFailData(t, svc, request) - checkBadMethod(t, data, http.MethodGet, http.MethodPost) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/pipeline", nil) - svc := pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}} - data := getFailData(t, svc, request) + svc := middleware( + pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}}, + withMethodCheck(http.MethodGet), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Failed to get latest pipeline for some-branch branch") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodGet, "/pipeline", nil) - svc := pipelineService{testProjectData, fakePipelineManager{testBase: testBase{status: http.StatusSeeOther}}, FakeGitManager{}} - data := getFailData(t, svc, request) + svc := middleware( + pipelineService{testProjectData, fakePipelineManager{testBase{status: http.StatusSeeOther}}, FakeGitManager{}}, + withMethodCheck(http.MethodGet), + ) + data, _ := getFailData(t, svc, request) assert(t, data.Message, "Failed to get latest pipeline for some-branch branch") // Expected, we treat this as an error }) } @@ -66,21 +68,29 @@ func TestPipelineGetter(t *testing.T) { func TestPipelineTrigger(t *testing.T) { t.Run("Retriggers pipeline", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil) - svc := pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}} + svc := middleware( + pipelineService{testProjectData, fakePipelineManager{}, FakeGitManager{}}, + withMethodCheck(http.MethodPost), + ) data := getSuccessData(t, svc, request) assert(t, data.Message, "Pipeline retriggered") - assert(t, data.Status, http.StatusOK) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil) - svc := pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}} - data := getFailData(t, svc, request) + svc := middleware( + pipelineService{testProjectData, fakePipelineManager{testBase{errFromGitlab: true}}, FakeGitManager{}}, + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not retrigger pipeline") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/pipeline/trigger/3", nil) - svc := pipelineService{testProjectData, fakePipelineManager{testBase: testBase{status: http.StatusSeeOther}}, FakeGitManager{}} - data := getFailData(t, svc, request) - checkNon200(t, data, "Could not retrigger pipeline", "/pipeline") + svc := middleware( + pipelineService{testProjectData, fakePipelineManager{testBase{status: http.StatusSeeOther}}, FakeGitManager{}}, + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) + checkNon200(t, data, "Could not retrigger pipeline", "/pipeline/trigger/3") }) } diff --git a/cmd/app/reply.go b/cmd/app/reply.go index eda9c80a..38deed86 100644 --- a/cmd/app/reply.go +++ b/cmd/app/reply.go @@ -2,7 +2,6 @@ package app import ( "encoding/json" - "io" "net/http" "time" @@ -10,8 +9,8 @@ import ( ) type ReplyRequest struct { - DiscussionId string `json:"discussion_id"` - Reply string `json:"reply"` + DiscussionId string `json:"discussion_id" validate:"required"` + Reply string `json:"reply" validate:"required"` IsDraft bool `json:"is_draft"` } @@ -30,28 +29,8 @@ type replyService struct { } /* replyHandler sends a reply to a note or comment */ -func (a replyService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost { - w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) - handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - defer r.Body.Close() - var replyRequest ReplyRequest - err = json.Unmarshal(body, &replyRequest) - - if err != nil { - handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) - return - } +func (a replyService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + replyRequest := r.Context().Value(payload("payload")).(*ReplyRequest) now := time.Now() options := gitlab.AddMergeRequestDiscussionNoteOptions{ @@ -67,17 +46,14 @@ func (a replyService) handler(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/reply"}, "Could not leave reply", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not leave reply", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := ReplyResponse{ - SuccessResponse: SuccessResponse{ - Message: "Replied to comment", - Status: http.StatusOK, - }, - Note: note, + SuccessResponse: SuccessResponse{Message: "Replied to comment"}, + Note: note, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/reply_test.go b/cmd/app/reply_test.go index a57c2716..27d4416e 100644 --- a/cmd/app/reply_test.go +++ b/cmd/app/reply_test.go @@ -24,22 +24,36 @@ func TestReplyHandler(t *testing.T) { var testReplyRequest = ReplyRequest{DiscussionId: "abc123", Reply: "Some Reply", IsDraft: false} t.Run("Sends a reply", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) - svc := replyService{testProjectData, fakeReplyManager{}} + svc := middleware( + replyService{testProjectData, fakeReplyManager{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPost: &ReplyRequest{}}), + withMethodCheck(http.MethodPost), + ) data := getSuccessData(t, svc, request) assert(t, data.Message, "Replied to comment") - assert(t, data.Status, http.StatusOK) }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) - svc := replyService{testProjectData, fakeReplyManager{testBase{errFromGitlab: true}}} - data := getFailData(t, svc, request) + svc := middleware( + replyService{testProjectData, fakeReplyManager{testBase{errFromGitlab: true}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPost: &ReplyRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not leave reply") }) t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/reply", testReplyRequest) - svc := replyService{testProjectData, fakeReplyManager{testBase{status: http.StatusSeeOther}}} - data := getFailData(t, svc, request) + svc := middleware( + replyService{testProjectData, fakeReplyManager{testBase{status: http.StatusSeeOther}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPost: &ReplyRequest{}}), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) checkNon200(t, data, "Could not leave reply", "/mr/reply") }) } diff --git a/cmd/app/resolve_discussion.go b/cmd/app/resolve_discussion.go index 89284574..2b55acc0 100644 --- a/cmd/app/resolve_discussion.go +++ b/cmd/app/resolve_discussion.go @@ -3,17 +3,11 @@ package app import ( "encoding/json" "fmt" - "io" "net/http" "github.com/xanzy/go-gitlab" ) -type DiscussionResolveRequest struct { - DiscussionID string `json:"discussion_id"` - Resolved bool `json:"resolved"` -} - type DiscussionResolver interface { ResolveMergeRequestDiscussion(pid interface{}, mergeRequest int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) } @@ -23,40 +17,24 @@ type discussionsResolutionService struct { client DiscussionResolver } -/* discussionsResolveHandler sets a discussion to be "resolved" or not resolved, depending on the payload */ -func (a discussionsResolutionService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPut { - w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) - handleError(w, InvalidRequestError{}, "Expected PUT", http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - defer r.Body.Close() - - var resolveDiscussionRequest DiscussionResolveRequest - err = json.Unmarshal(body, &resolveDiscussionRequest) +type DiscussionResolveRequest struct { + DiscussionID string `json:"discussion_id" validate:"required"` + Resolved bool `json:"resolved"` +} - if err != nil { - handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) - return - } +/* discussionsResolveHandler sets a discussion to be "resolved" or not resolved, depending on the payload */ +func (a discussionsResolutionService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + payload := r.Context().Value(payload("payload")).(*DiscussionResolveRequest) _, res, err := a.client.ResolveMergeRequestDiscussion( a.projectInfo.ProjectId, a.projectInfo.MergeId, - resolveDiscussionRequest.DiscussionID, - &gitlab.ResolveMergeRequestDiscussionOptions{Resolved: &resolveDiscussionRequest.Resolved}, + payload.DiscussionID, + &gitlab.ResolveMergeRequestDiscussionOptions{Resolved: &payload.Resolved}, ) friendlyName := "unresolve" - if resolveDiscussionRequest.Resolved { + if payload.Resolved { friendlyName = "resolve" } @@ -66,15 +44,12 @@ func (a discussionsResolutionService) handler(w http.ResponseWriter, r *http.Req } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/discussions/resolve"}, fmt.Sprintf("Could not %s discussion", friendlyName), res.StatusCode) + handleError(w, GenericError{r.URL.Path}, fmt.Sprintf("Could not %s discussion", friendlyName), res.StatusCode) return } w.WriteHeader(http.StatusOK) - response := SuccessResponse{ - Message: fmt.Sprintf("Discussion %sd", friendlyName), - Status: http.StatusOK, - } + response := SuccessResponse{Message: fmt.Sprintf("Discussion %sd", friendlyName)} err = json.NewEncoder(w).Encode(response) if err != nil { diff --git a/cmd/app/resolve_discussion_test.go b/cmd/app/resolve_discussion_test.go new file mode 100644 index 00000000..18918e1a --- /dev/null +++ b/cmd/app/resolve_discussion_test.go @@ -0,0 +1,84 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +type fakeDiscussionResolver struct { + testBase +} + +func (f fakeDiscussionResolver) ResolveMergeRequestDiscussion(pid interface{}, mergeRequest int, discussion string, opt *gitlab.ResolveMergeRequestDiscussionOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Discussion, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + + return &gitlab.Discussion{}, resp, err +} + +func TestResolveDiscussion(t *testing.T) { + var testResolveMergeRequestPayload = DiscussionResolveRequest{ + DiscussionID: "abc123", + Resolved: true, + } + + t.Run("Resolves a discussion", func(t *testing.T) { + svc := middleware( + discussionsResolutionService{testProjectData, fakeDiscussionResolver{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPut: &DiscussionResolveRequest{}}), + withMethodCheck(http.MethodPut), + ) + request := makeRequest(t, http.MethodPut, "/mr/discussions/resolve", testResolveMergeRequestPayload) + data := getSuccessData(t, svc, request) + assert(t, data.Message, "Discussion resolved") + }) + + t.Run("Unresolves a discussion", func(t *testing.T) { + payload := testResolveMergeRequestPayload + payload.Resolved = false + svc := middleware( + discussionsResolutionService{testProjectData, fakeDiscussionResolver{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPut: &DiscussionResolveRequest{}}), + withMethodCheck(http.MethodPut), + ) + request := makeRequest(t, http.MethodPut, "/mr/discussions/resolve", payload) + data := getSuccessData(t, svc, request) + assert(t, data.Message, "Discussion unresolved") + }) + + t.Run("Requires a discussion ID", func(t *testing.T) { + payload := testResolveMergeRequestPayload + payload.DiscussionID = "" + svc := middleware( + discussionsResolutionService{testProjectData, fakeDiscussionResolver{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPut: &DiscussionResolveRequest{}}), + withMethodCheck(http.MethodPut), + ) + request := makeRequest(t, http.MethodPut, "/mr/discussions/resolve", payload) + data, status := getFailData(t, svc, request) + assert(t, data.Message, "Invalid payload") + assert(t, data.Details, "DiscussionID is required") + assert(t, status, http.StatusBadRequest) + }) + + t.Run("Handles error from Gitlab", func(t *testing.T) { + svc := middleware( + discussionsResolutionService{testProjectData, fakeDiscussionResolver{testBase: testBase{errFromGitlab: true}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{http.MethodPut: &DiscussionResolveRequest{}}), + withMethodCheck(http.MethodPut), + ) + request := makeRequest(t, http.MethodPut, "/mr/discussions/resolve", testResolveMergeRequestPayload) + data, status := getFailData(t, svc, request) + assert(t, data.Message, "Could not resolve discussion") + assert(t, data.Details, "Some error from Gitlab") + assert(t, status, http.StatusInternalServerError) + }) +} diff --git a/cmd/app/response_types.go b/cmd/app/response_types.go index 2fb3af04..c34fa0a2 100644 --- a/cmd/app/response_types.go +++ b/cmd/app/response_types.go @@ -7,12 +7,10 @@ import ( type ErrorResponse struct { Message string `json:"message"` Details string `json:"details"` - Status int `json:"status"` } type SuccessResponse struct { Message string `json:"message"` - Status int `json:"status"` } type GenericError struct { @@ -23,8 +21,8 @@ func (e GenericError) Error() string { return fmt.Sprintf("An error occurred on the %s endpoint", e.endpoint) } -type InvalidRequestError struct{} +type InvalidRequestError struct{ msg string } func (e InvalidRequestError) Error() string { - return "Invalid request type" + return e.msg } diff --git a/cmd/app/reviewer.go b/cmd/app/reviewer.go index 1ffd6a0e..7b98bdee 100644 --- a/cmd/app/reviewer.go +++ b/cmd/app/reviewer.go @@ -2,14 +2,13 @@ package app import ( "encoding/json" - "io" "net/http" "github.com/xanzy/go-gitlab" ) type ReviewerUpdateRequest struct { - Ids []int `json:"ids"` + Ids []int `json:"ids" validate:"required"` } type ReviewerUpdateResponse struct { @@ -32,31 +31,11 @@ type reviewerService struct { } /* reviewersHandler adds or removes reviewers from an MR */ -func (a reviewerService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPut { - w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) - handleError(w, InvalidRequestError{}, "Expected PUT", http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - defer r.Body.Close() - var reviewerUpdateRequest ReviewerUpdateRequest - err = json.Unmarshal(body, &reviewerUpdateRequest) - - if err != nil { - handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) - return - } +func (a reviewerService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + payload := r.Context().Value(payload("payload")).(*ReviewerUpdateRequest) mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{ - ReviewerIDs: &reviewerUpdateRequest.Ids, + ReviewerIDs: &payload.Ids, }) if err != nil { @@ -65,17 +44,14 @@ func (a reviewerService) handler(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/reviewer"}, "Could not modify merge request reviewers", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not modify merge request reviewers", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := ReviewerUpdateResponse{ - SuccessResponse: SuccessResponse{ - Message: "Reviewers updated", - Status: http.StatusOK, - }, - Reviewers: mr.Reviewers, + SuccessResponse: SuccessResponse{Message: "Reviewers updated"}, + Reviewers: mr.Reviewers, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/revisions.go b/cmd/app/revisions.go index f423f656..16e68226 100644 --- a/cmd/app/revisions.go +++ b/cmd/app/revisions.go @@ -25,13 +25,7 @@ type revisionsService struct { revisionsHandler gets revision information about the current MR. This data is not used directly but is a precursor API call for other functionality */ -func (a revisionsService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodGet { - w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) - handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed) - return - } +func (a revisionsService) ServeHTTP(w http.ResponseWriter, r *http.Request) { versionInfo, res, err := a.client.GetMergeRequestDiffVersions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.GetMergeRequestDiffVersionsOptions{}) if err != nil { @@ -40,17 +34,14 @@ func (a revisionsService) handler(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/revisions"}, "Could not get diff version info", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not get diff version info", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := RevisionsResponse{ - SuccessResponse: SuccessResponse{ - Message: "Revisions fetched successfully", - Status: http.StatusOK, - }, - Revisions: versionInfo, + SuccessResponse: SuccessResponse{Message: "Revisions fetched successfully"}, + Revisions: versionInfo, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/revoke.go b/cmd/app/revoke.go index c065b88f..ae45f805 100644 --- a/cmd/app/revoke.go +++ b/cmd/app/revoke.go @@ -17,13 +17,7 @@ type mergeRequestRevokerService struct { } /* revokeHandler revokes approval for the current merge request */ -func (a mergeRequestRevokerService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost { - w.Header().Set("Access-Control-Allow-Methods", http.MethodPost) - handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) - return - } +func (a mergeRequestRevokerService) ServeHTTP(w http.ResponseWriter, r *http.Request) { res, err := a.client.UnapproveMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, nil, nil) @@ -33,15 +27,12 @@ func (a mergeRequestRevokerService) handler(w http.ResponseWriter, r *http.Reque } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/mr/revoke"}, "Could not revoke approval", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not revoke approval", res.StatusCode) return } w.WriteHeader(http.StatusOK) - response := SuccessResponse{ - Message: "Success! Revoked MR approval", - Status: http.StatusOK, - } + response := SuccessResponse{Message: "Success! Revoked MR approval"} err = json.NewEncoder(w).Encode(response) if err != nil { diff --git a/cmd/app/server.go b/cmd/app/server.go index 7357a1bc..69b9c60d 100644 --- a/cmd/app/server.go +++ b/cmd/app/server.go @@ -76,7 +76,7 @@ type data struct { type optFunc func(a *data) error -func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s ShutdownHandler, optFuncs ...optFunc) *http.ServeMux { +func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s ShutdownHandler, optFuncs ...optFunc) http.Handler { m := http.NewServeMux() d := data{ @@ -92,37 +92,149 @@ func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s ShutdownHand } } - m.HandleFunc("/mr/approve", withMr(mergeRequestApproverService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/comment", withMr(commentService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/merge", withMr(mergeRequestAccepterService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/discussions/list", withMr(discussionsListerService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/discussions/resolve", withMr(discussionsResolutionService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/info", withMr(infoService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/assignee", withMr(assigneesService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/summary", withMr(summaryService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/reviewer", withMr(reviewerService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/revisions", withMr(revisionsService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/reply", withMr(replyService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/label", withMr(labelService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/revoke", withMr(mergeRequestRevokerService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/awardable/note/", withMr(emojiService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/draft_notes/", withMr(draftNoteService{d, gitlabClient}, d, gitlabClient)) - m.HandleFunc("/mr/draft_notes/publish", withMr(draftNotePublisherService{d, gitlabClient}, d, gitlabClient)) - - m.HandleFunc("/pipeline", pipelineService{d, gitlabClient, git.Git{}}.handler) - m.HandleFunc("/pipeline/trigger/", pipelineService{d, gitlabClient, git.Git{}}.handler) - m.HandleFunc("/users/me", meService{d, gitlabClient}.handler) - m.HandleFunc("/attachment", attachmentService{data: d, client: gitlabClient, fileReader: attachmentReader{}}.handler) - m.HandleFunc("/create_mr", mergeRequestCreatorService{d, gitlabClient}.handler) - m.HandleFunc("/job", traceFileService{d, gitlabClient}.handler) - m.HandleFunc("/project/members", projectMemberService{d, gitlabClient}.handler) - m.HandleFunc("/merge_requests", mergeRequestListerService{d, gitlabClient}.handler) - m.HandleFunc("/merge_requests_by_username", mergeRequestListerByUsernameService{d, gitlabClient}.handler) + m.HandleFunc("/mr/approve", middleware( + mergeRequestApproverService{d, gitlabClient}, // These functions are called from bottom to top... + withMr(d, gitlabClient), + withMethodCheck(http.MethodPost), + )) + m.HandleFunc("/mr/comment", middleware( + commentService{d, gitlabClient}, + withMr(d, gitlabClient), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostCommentRequest{}, + http.MethodDelete: &DeleteCommentRequest{}, + http.MethodPatch: &EditCommentRequest{}, + }), + withMethodCheck(http.MethodPost, http.MethodDelete, http.MethodPatch), + )) + m.HandleFunc("/mr/merge", middleware( + mergeRequestAccepterService{d, gitlabClient}, + withMr(d, gitlabClient), + withPayloadValidation(methodToPayload{http.MethodPost: &AcceptMergeRequestRequest{}}), + withMethodCheck(http.MethodPost), + )) + m.HandleFunc("/mr/discussions/list", middleware( + discussionsListerService{d, gitlabClient}, + withMr(d, gitlabClient), + withPayloadValidation(methodToPayload{http.MethodPost: &DiscussionsRequest{}}), + withMethodCheck(http.MethodPost), + )) + m.HandleFunc("/mr/discussions/resolve", middleware( + discussionsResolutionService{d, gitlabClient}, + withMr(d, gitlabClient), + withPayloadValidation(methodToPayload{http.MethodPut: &DiscussionResolveRequest{}}), + withMethodCheck(http.MethodPut), + )) + m.HandleFunc("/mr/info", middleware( + infoService{d, gitlabClient}, + withMr(d, gitlabClient), + withMethodCheck(http.MethodGet), + )) + m.HandleFunc("/mr/assignee", middleware( + assigneesService{d, gitlabClient}, + withMr(d, gitlabClient), + withPayloadValidation(methodToPayload{http.MethodPut: &AssigneeUpdateRequest{}}), + withMethodCheck(http.MethodPut), + )) + m.HandleFunc("/mr/summary", middleware( + summaryService{d, gitlabClient}, + withMr(d, gitlabClient), + withPayloadValidation(methodToPayload{http.MethodPut: &SummaryUpdateRequest{}}), + withMethodCheck(http.MethodPut), + )) + m.HandleFunc("/mr/reviewer", middleware( + reviewerService{d, gitlabClient}, + withMr(d, gitlabClient), + withPayloadValidation(methodToPayload{http.MethodPut: &ReviewerUpdateRequest{}}), + withMethodCheck(http.MethodPut), + )) + m.HandleFunc("/mr/revisions", middleware( + revisionsService{d, gitlabClient}, + withMr(d, gitlabClient), + withMethodCheck(http.MethodGet), + )) + m.HandleFunc("/mr/reply", middleware( + replyService{d, gitlabClient}, + withMr(d, gitlabClient), + withPayloadValidation(methodToPayload{http.MethodPost: &ReplyRequest{}}), + withMethodCheck(http.MethodPost), + )) + m.HandleFunc("/mr/label", middleware( + labelService{d, gitlabClient}, + withMr(d, gitlabClient), + )) + m.HandleFunc("/mr/revoke", middleware( + mergeRequestRevokerService{d, gitlabClient}, + withMethodCheck(http.MethodPost), + withMr(d, gitlabClient), + )) + m.HandleFunc("/mr/awardable/note/", middleware( + emojiService{d, gitlabClient}, + withMethodCheck(http.MethodPost, http.MethodDelete), + withMr(d, gitlabClient), + )) + m.HandleFunc("/mr/draft_notes/", middleware( + draftNoteService{d, gitlabClient}, + withMr(d, gitlabClient), + withPayloadValidation(methodToPayload{ + http.MethodPost: &PostDraftNoteRequest{}, + http.MethodPatch: &UpdateDraftNoteRequest{}, + }), + withMethodCheck(http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete), + )) + m.HandleFunc("/mr/draft_notes/publish", middleware( + draftNotePublisherService{d, gitlabClient}, + withMr(d, gitlabClient), + withPayloadValidation(methodToPayload{http.MethodPost: &DraftNotePublishRequest{}}), + withMethodCheck(http.MethodPost), + )) + + m.HandleFunc("/pipeline", middleware( + pipelineService{d, gitlabClient, git.Git{}}, + withMethodCheck(http.MethodGet), + )) + m.HandleFunc("/pipeline/trigger/", middleware( + pipelineService{d, gitlabClient, git.Git{}}, + withMethodCheck(http.MethodPost), + )) + m.HandleFunc("/users/me", middleware( + meService{d, gitlabClient}, + withMethodCheck(http.MethodGet), + )) + m.HandleFunc("/attachment", middleware( + attachmentService{data: d, client: gitlabClient, fileReader: attachmentReader{}}, + withPayloadValidation(methodToPayload{http.MethodPost: &AttachmentRequest{}}), + withMethodCheck(http.MethodPost), + )) + m.HandleFunc("/create_mr", middleware( + mergeRequestCreatorService{d, gitlabClient}, + withPayloadValidation(methodToPayload{http.MethodPost: &CreateMrRequest{}}), + withMethodCheck(http.MethodPost), + )) + m.HandleFunc("/job", middleware( + traceFileService{d, gitlabClient}, + withPayloadValidation(methodToPayload{http.MethodGet: &JobTraceRequest{}}), + withMethodCheck(http.MethodGet), + )) + m.HandleFunc("/project/members", middleware( + projectMemberService{d, gitlabClient}, + withMethodCheck(http.MethodGet), + )) + m.HandleFunc("/merge_requests", middleware( + mergeRequestListerService{d, gitlabClient}, + withPayloadValidation(methodToPayload{http.MethodPost: &gitlab.ListProjectMergeRequestsOptions{}}), // TODO: How to validate external object + withMethodCheck(http.MethodPost), + )) + m.HandleFunc("/merge_requests_by_username", middleware( + mergeRequestListerByUsernameService{d, gitlabClient}, + withPayloadValidation(methodToPayload{http.MethodPost: &MergeRequestByUsernameRequest{}}), + withMethodCheck(http.MethodPost), + )) m.HandleFunc("/shutdown", s.shutdownHandler) m.Handle("/ping", http.HandlerFunc(pingHandler)) - return m + return LoggingServer{handler: m} } /* Used to check whether the server has started yet */ @@ -155,45 +267,3 @@ func createListener() (l net.Listener) { return l } - -type ServiceWithHandler interface { - handler(http.ResponseWriter, *http.Request) -} - -/* withMr is a Middlware that gets the current merge request ID and attaches it to the projectInfo */ -func withMr(svc ServiceWithHandler, c data, client MergeRequestLister) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // If the merge request is already attached, skip the middleware logic - if c.projectInfo.MergeId == 0 { - options := gitlab.ListProjectMergeRequestsOptions{ - Scope: gitlab.Ptr("all"), - SourceBranch: &c.gitInfo.BranchName, - TargetBranch: pluginOptions.ChosenTargetBranch, - } - - mergeRequests, _, err := client.ListProjectMergeRequests(c.projectInfo.ProjectId, &options) - if err != nil { - handleError(w, fmt.Errorf("Failed to list merge requests: %w", err), "Failed to list merge requests", http.StatusInternalServerError) - return - } - - if len(mergeRequests) == 0 { - err := fmt.Errorf("No merge requests found for branch '%s'", c.gitInfo.BranchName) - handleError(w, err, "No merge requests found", http.StatusBadRequest) - return - } - - if len(mergeRequests) > 1 { - err := errors.New("Please call gitlab.choose_merge_request()") - handleError(w, err, "Multiple MRs found", http.StatusBadRequest) - return - } - - mergeIdInt := mergeRequests[0].IID - c.projectInfo.MergeId = mergeIdInt - } - - // Call the next handler if middleware succeeds - svc.handler(w, r) - } -} diff --git a/cmd/app/shutdown.go b/cmd/app/shutdown.go index 9f1a6d6e..5f4bd305 100644 --- a/cmd/app/shutdown.go +++ b/cmd/app/shutdown.go @@ -69,10 +69,7 @@ func (s shutdown) shutdownHandler(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusOK) - response := SuccessResponse{ - Message: text, - Status: http.StatusOK, - } + response := SuccessResponse{Message: text} err = json.NewEncoder(w).Encode(response) if err != nil { diff --git a/cmd/app/summary.go b/cmd/app/summary.go index 411747a6..a345135c 100644 --- a/cmd/app/summary.go +++ b/cmd/app/summary.go @@ -2,15 +2,14 @@ package app import ( "encoding/json" - "io" "net/http" "github.com/xanzy/go-gitlab" ) type SummaryUpdateRequest struct { + Title string `json:"title" validate:"required"` Description string `json:"description"` - Title string `json:"title"` } type SummaryUpdateResponse struct { @@ -23,33 +22,13 @@ type summaryService struct { client MergeRequestUpdater } -func (a summaryService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") +func (a summaryService) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPut { - w.Header().Set("Access-Control-Allow-Methods", http.MethodPut) - handleError(w, InvalidRequestError{}, "Expected PUT", http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - defer r.Body.Close() - var SummaryUpdateRequest SummaryUpdateRequest - err = json.Unmarshal(body, &SummaryUpdateRequest) - - if err != nil { - handleError(w, err, "Could not read JSON from request", http.StatusBadRequest) - return - } + payload := r.Context().Value(payload("payload")).(*SummaryUpdateRequest) mr, res, err := a.client.UpdateMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &gitlab.UpdateMergeRequestOptions{ - Description: &SummaryUpdateRequest.Description, - Title: &SummaryUpdateRequest.Title, + Description: &payload.Description, + Title: &payload.Title, }) if err != nil { @@ -58,18 +37,15 @@ func (a summaryService) handler(w http.ResponseWriter, r *http.Request) { } if res.StatusCode >= 300 { - handleError(w, GenericError{endpoint: "/summary"}, "Could not edit merge request summary", res.StatusCode) + handleError(w, GenericError{r.URL.Path}, "Could not edit merge request summary", res.StatusCode) return } w.WriteHeader(http.StatusOK) response := SummaryUpdateResponse{ - SuccessResponse: SuccessResponse{ - Message: "Summary updated", - Status: http.StatusOK, - }, - MergeRequest: mr, + SuccessResponse: SuccessResponse{Message: "Summary updated"}, + MergeRequest: mr, } err = json.NewEncoder(w).Encode(response) diff --git a/cmd/app/test_helpers.go b/cmd/app/test_helpers.go index 29b5903d..98ec8d2b 100644 --- a/cmd/app/test_helpers.go +++ b/cmd/app/test_helpers.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "net/http/httptest" - "strings" "testing" "github.com/harrisoncramer/gitlab.nvim/cmd/app/git" @@ -63,9 +62,9 @@ var testProjectData = data{ }, } -func getSuccessData(t *testing.T, svc ServiceWithHandler, request *http.Request) SuccessResponse { +func getSuccessData(t *testing.T, svc http.Handler, request *http.Request) SuccessResponse { res := httptest.NewRecorder() - svc.handler(res, request) + svc.ServeHTTP(res, request) var data SuccessResponse err := json.Unmarshal(res.Body.Bytes(), &data) @@ -75,16 +74,16 @@ func getSuccessData(t *testing.T, svc ServiceWithHandler, request *http.Request) return data } -func getFailData(t *testing.T, svc ServiceWithHandler, request *http.Request) ErrorResponse { +func getFailData(t *testing.T, svc http.Handler, request *http.Request) (errResponse ErrorResponse, status int) { res := httptest.NewRecorder() - svc.handler(res, request) + svc.ServeHTTP(res, request) var data ErrorResponse err := json.Unmarshal(res.Body.Bytes(), &data) if err != nil { t.Error(err) } - return data + return data, res.Result().StatusCode } type testBase struct { @@ -105,22 +104,12 @@ func (f *testBase) handleGitlabError() (*gitlab.Response, error) { func checkErrorFromGitlab(t *testing.T, data ErrorResponse, msg string) { t.Helper() - assert(t, data.Status, http.StatusInternalServerError) assert(t, data.Message, msg) assert(t, data.Details, errorFromGitlab.Error()) } -func checkBadMethod(t *testing.T, data ErrorResponse, methods ...string) { - t.Helper() - assert(t, data.Status, http.StatusMethodNotAllowed) - assert(t, data.Details, "Invalid request type") - expectedMethods := strings.Join(methods, " or ") - assert(t, data.Message, fmt.Sprintf("Expected %s", expectedMethods)) -} - func checkNon200(t *testing.T, data ErrorResponse, msg, endpoint string) { t.Helper() - assert(t, data.Status, http.StatusSeeOther) assert(t, data.Message, msg) assert(t, data.Details, fmt.Sprintf("An error occurred on the %s endpoint", endpoint)) } diff --git a/cmd/app/user.go b/cmd/app/user.go index ae2c6c58..da31a9ac 100644 --- a/cmd/app/user.go +++ b/cmd/app/user.go @@ -21,13 +21,7 @@ type meService struct { client MeGetter } -func (a meService) handler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodGet { - w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) - handleError(w, InvalidRequestError{}, "Expected GET", http.StatusMethodNotAllowed) - return - } +func (a meService) ServeHTTP(w http.ResponseWriter, r *http.Request) { user, res, err := a.client.CurrentUser() @@ -42,11 +36,8 @@ func (a meService) handler(w http.ResponseWriter, r *http.Request) { } response := UserResponse{ - SuccessResponse: SuccessResponse{ - Message: "User fetched successfully", - Status: http.StatusOK, - }, - User: user, + SuccessResponse: SuccessResponse{Message: "User fetched successfully"}, + User: user, } err = json.NewEncoder(w).Encode(response) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 58cc8d0a..2a529e00 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -152,7 +152,12 @@ you call this function with no values the defaults will be used: port = nil, -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically log_path = vim.fn.stdpath("cache") .. "/gitlab.nvim.log", -- Log path for the Go server config_path = nil, -- Custom path for `.gitlab.nvim` file, please read the "Connecting to Gitlab" section - debug = { go_request = false, go_response = false }, -- Which values to log + debug = { + request = false, -- Requests to/from Go server + response = false, + gitlab_request = false, -- Requests to/from Gitlab + gitlab_response = false, + }, attachment_dir = nil, -- The local directory for files (see the "summary" section) reviewer_settings = { jump_with_no_diagnostics = false, -- Jump to last position in discussion tree if true, otherwise stay in reviewer and show warning. diff --git a/go.mod b/go.mod index 6410a147..e16b85bd 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,24 @@ module github.com/harrisoncramer/gitlab.nvim go 1.19 require ( + github.com/go-playground/validator/v10 v10.22.1 github.com/hashicorp/go-retryablehttp v0.7.7 github.com/xanzy/go-gitlab v0.108.0 ) require ( + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - golang.org/x/net v0.8.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect golang.org/x/oauth2 v0.6.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.3.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.29.1 // indirect diff --git a/go.sum b/go.sum index f00dd864..5df86b54 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= +github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= @@ -14,22 +23,29 @@ github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/S github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/xanzy/go-gitlab v0.108.0 h1:IEvEUWFR5G1seslRhJ8gC//INiIUqYXuSUoBd7/gFKE= github.com/xanzy/go-gitlab v0.108.0/go.mod h1:wKNKh3GkYDMOsGmnfuX+ITCmDuSDWFO0G+C4AygL9RY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index 1122b967..a8716750 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -84,7 +84,7 @@ end ---Publishes all draft notes and comments. Re-renders all discussion views. M.confirm_publish_all_drafts = function() - local body = { publish_all = true } + local body = {} job.run_job("/mr/draft_notes/publish", "POST", body, function(data) u.notify(data.message, vim.log.levels.INFO) state.DRAFT_NOTES = {} @@ -109,7 +109,7 @@ M.confirm_publish_draft = function(tree) ---@type integer local note_id = note_node.is_root and root_node.id or note_node.id - local body = { note = note_id, publish_all = false } + local body = { note = note_id } job.run_job("/mr/draft_notes/publish", "POST", body, function(data) u.notify(data.message, vim.log.levels.INFO) diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index 82a675b9..e4993498 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -226,6 +226,8 @@ ---@class DebugSettings: table ---@field go_request? boolean -- Log the requests to Gitlab sent by the Go server ---@field go_response? boolean -- Log the responses received from Gitlab to the Go server +---@field request? boolean -- Log the requests to the Go server +---@field response? boolean -- Log the responses from the Go server ---@class PopupSettings: table ---@field width? string -- The width of the popup, by default "40%" diff --git a/lua/gitlab/job.lua b/lua/gitlab/job.lua index 2ca4e52e..7f5f4d8e 100644 --- a/lua/gitlab/job.lua +++ b/lua/gitlab/job.lua @@ -26,6 +26,8 @@ M.run_job = function(endpoint, method, body, callback) return end local data_ok, data = pcall(vim.json.decode, output) + + -- Failing to unmarshal JSON if not data_ok then local msg = string.format("Failed to parse JSON from %s endpoint", endpoint) if type(output) == "string" then @@ -34,17 +36,22 @@ M.run_job = function(endpoint, method, body, callback) u.notify(string.format(msg, endpoint, output), vim.log.levels.WARN) return end + + -- If JSON provided, handle success or error cases if data ~= nil then - local status = (tonumber(data.status) >= 200 and tonumber(data.status) < 300) and "success" or "error" - if status == "success" and callback ~= nil then - callback(data) - elseif status == "success" then + if data.details == nil then + if callback then + callback(data) + return + end local message = string.format("%s", data.message) u.notify(message, vim.log.levels.INFO) - else - local message = string.format("%s: %s", data.message, data.details) - u.notify(message, vim.log.levels.ERROR) + return end + + -- Handle error case + local message = string.format("%s: %s", data.message, data.details) + u.notify(message, vim.log.levels.ERROR) end end, 0) end, diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 2007fe32..a6349b50 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -47,8 +47,10 @@ M.settings = { file_separator = u.path_separator, port = nil, -- choose random port debug = { - go_request = false, - go_response = false, + request = false, + response = false, + gitlab_request = false, + gitlab_response = false, }, log_path = (vim.fn.stdpath("cache") .. "/gitlab.nvim.log"), config_path = nil, From 4f0826ab01b8cb8c0c08f032758b4d070287d33a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Fri, 13 Sep 2024 15:57:37 +0200 Subject: [PATCH 90/97] fix: format of date when MR was closed or merged (#367) --- lua/gitlab/reviewer/init.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index e561162b..3a95ec69 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -67,11 +67,11 @@ M.open = function() end if state.INFO.state == "closed" then - u.notify(string.format("This MR was closed on %s", u.format_date(state.INFO.closed_at)), vim.log.levels.WARN) + u.notify(string.format("This MR was closed %s", u.time_since(state.INFO.closed_at)), vim.log.levels.WARN) end if state.INFO.state == "merged" then - u.notify(string.format("This MR was merged on %s", u.format_date(state.INFO.merged_at)), vim.log.levels.WARN) + u.notify(string.format("This MR was merged %s", u.time_since(state.INFO.merged_at)), vim.log.levels.WARN) end if state.settings.discussion_diagnostic ~= nil or state.settings.discussion_sign ~= nil then From 9ce109c51cfc96696b38c528a405a54b28b0701f Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Fri, 13 Sep 2024 13:03:50 -0400 Subject: [PATCH 91/97] fix: Add better checks for leaving comments (#369) --- lua/gitlab/actions/comment.lua | 58 ++++++++++++++++++++++++++++------ lua/gitlab/reviewer/init.lua | 18 ----------- 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 4723a364..c77f8f45 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -3,6 +3,7 @@ --- 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") @@ -153,17 +154,45 @@ end ---@class LayoutOpts ---@field ranged boolean ----@field discussion_id string|nil ---@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|nil ----@return NuiLayout +---@param opts LayoutOpts +---@return NuiLayout|nil M.create_comment_layout = function(opts) - if opts == nil then - 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 ~= "markdown" 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" @@ -229,7 +258,8 @@ M.create_comment = function() if err ~= nil then return end - local is_modified = vim.api.nvim_buf_get_option(0, "modified") + + 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.", @@ -243,7 +273,9 @@ M.create_comment = function() end local layout = M.create_comment_layout({ ranged = false, unlinked = false }) - layout:mount() + 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 @@ -257,14 +289,18 @@ M.create_multiline_comment = function() end local layout = M.create_comment_layout({ ranged = true, unlinked = false }) - layout:mount() + 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 }) - layout:mount() + if layout ~= nil then + layout:mount() + end end ---Given the current visually selected area of text, builds text to fill in the @@ -319,7 +355,9 @@ M.create_comment_suggestion = function() local suggestion_lines, range_length = build_suggestion() local layout = M.create_comment_layout({ ranged = range_length > 0, unlinked = false }) - layout:mount() + 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) diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 3a95ec69..9fb819a7 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -151,25 +151,7 @@ end ---other modules such as the comment module to create line codes or set diagnostics ---@return DiffviewInfo | nil M.get_reviewer_data = function() - if M.tabnr == nil then - u.notify("Diffview reviewer must be initialized first", vim.log.levels.ERROR) - return - end - - -- Check if we are in the diffview tab - local tabnr = vim.api.nvim_get_current_tabpage() - if tabnr ~= M.tabnr then - u.notify("Line location can only be determined within reviewer window", vim.log.levels.ERROR) - return - end - - -- Check if we are in the diffview buffer local view = diffview_lib.get_current_view() - if view == nil then - u.notify("Could not find Diffview view", vim.log.levels.ERROR) - return - end - local layout = view.cur_layout local old_win = u.get_window_id_by_buffer_id(layout.a.file.bufnr) local new_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr) From 7b5d1cb61cb7e5b9571f46df285e8498b5d1b0db Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Fri, 13 Sep 2024 14:31:47 -0400 Subject: [PATCH 92/97] Updated lua/gitlab/actions/comment.lua --- lua/gitlab/actions/comment.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index c77f8f45..daadd3be 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -186,7 +186,7 @@ M.create_comment_layout = function(opts) -- Check that we are hovering over the code local filetype = vim.bo[0].filetype - if filetype ~= "markdown" then + 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 From 1c9994356ef35f408024ed96d9a8b7465e19bffa Mon Sep 17 00:00:00 2001 From: Nicolas Iderhoff Date: Sat, 14 Sep 2024 22:50:25 +0200 Subject: [PATCH 93/97] fix: regex support for http credentials embedded in remote url (#372) fix: Introduce a modification to the regular expression for repository remote url parsing. Remove parser failure with credentials embedded into http remote urls. --- cmd/app/git/git.go | 2 +- cmd/app/git/git_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/cmd/app/git/git.go b/cmd/app/git/git.go index 2398d99f..7307430b 100644 --- a/cmd/app/git/git.go +++ b/cmd/app/git/git.go @@ -59,7 +59,7 @@ func NewGitData(remote string, g GitManager) (GitData, error) { https://git@gitlab.com/namespace/subnamespace/dummy-test-repo.git git@git@gitlab.com:namespace/subnamespace/dummy-test-repo.git */ - re := regexp.MustCompile(`(?:^https?:\/\/|^ssh:\/\/|^git@)(?:[^\/:]+)(?::\d+)?[\/:](.*)\/([^\/]+?)(?:\.git)?$`) + re := regexp.MustCompile(`^(?:git@[^\/:]*|https?:\/\/[^\/]+|ssh:\/\/[^\/:]+)(?::\d+)?[\/:](.*)\/([^\/]+?)(?:\.git)?\/?$`) matches := re.FindStringSubmatch(url) if len(matches) != 3 { return GitData{}, fmt.Errorf("Invalid Git URL format: %s", url) diff --git a/cmd/app/git/git_test.go b/cmd/app/git/git_test.go index 60b496d8..e8864fb2 100644 --- a/cmd/app/git/git_test.go +++ b/cmd/app/git/git_test.go @@ -101,6 +101,13 @@ func TestExtractGitInfo_Success(t *testing.T) { projectName: "project-name", namespace: "namespace-1", }, + { + desc: "Project configured in HTTP and under a single folder without .git extension (with embedded credentials)", + remote: "http://username:password@custom-gitlab.com/namespace-1/project-name", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1", + }, { desc: "Project configured in HTTPS and under a single folder", remote: "https://custom-gitlab.com/namespace-1/project-name.git", @@ -108,6 +115,13 @@ func TestExtractGitInfo_Success(t *testing.T) { projectName: "project-name", namespace: "namespace-1", }, + { + desc: "Project configured in HTTPS and under a single folder (with embedded credentials)", + remote: "https://username:password@custom-gitlab.com/namespace-1/project-name.git", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1", + }, { desc: "Project configured in HTTPS and under a nested folder", remote: "https://custom-gitlab.com/namespace-1/namespace-2/project-name.git", @@ -115,6 +129,13 @@ func TestExtractGitInfo_Success(t *testing.T) { projectName: "project-name", namespace: "namespace-1/namespace-2", }, + { + desc: "Project configured in HTTPS and under a nested folder (with embedded credentials)", + remote: "https://username:password@custom-gitlab.com/namespace-1/namespace-2/project-name.git", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1/namespace-2", + }, { desc: "Project configured in HTTPS and under two nested folders", remote: "https://custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git", @@ -122,6 +143,13 @@ func TestExtractGitInfo_Success(t *testing.T) { projectName: "project-name", namespace: "namespace-1/namespace-2/namespace-3", }, + { + desc: "Project configured in HTTPS and under two nested folders (with embedded credentials)", + remote: "https://username:password@custom-gitlab.com/namespace-1/namespace-2/namespace-3/project-name.git", + branch: "feature/abc", + projectName: "project-name", + namespace: "namespace-1/namespace-2/namespace-3", + }, } for _, tC := range testCases { t.Run(tC.desc, func(t *testing.T) { From ad7b866933fcfe428dbd80750d412012ffb7e627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sat, 14 Sep 2024 22:51:33 +0200 Subject: [PATCH 94/97] fix: Comment on single line selects two lines (#371) --- lua/gitlab/reviewer/init.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 9fb819a7..9ef37ea2 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -303,7 +303,7 @@ local set_keymaps = function(bufnr, keymaps) if keymaps.reviewer.create_comment ~= false then -- Set keymap for repeated operator keybinding vim.keymap.set("o", keymaps.reviewer.create_comment, function() - vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { tostring(vim.v.count1) .. "j" } }, {}) + vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { tostring(vim.v.count1) .. "$" } }, {}) end, { buffer = bufnr, desc = "Create comment for [count] lines", @@ -333,7 +333,7 @@ local set_keymaps = function(bufnr, keymaps) if keymaps.reviewer.create_suggestion ~= false then -- Set keymap for repeated operator keybinding vim.keymap.set("o", keymaps.reviewer.create_suggestion, function() - vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { tostring(vim.v.count1) .. "j" } }, {}) + vim.api.nvim_cmd({ cmd = "normal", bang = true, args = { tostring(vim.v.count1) .. "$" } }, {}) end, { buffer = bufnr, desc = "Create suggestion for [count] lines", From 4826c41cf827493d71ad726ebb8e3454f2100d13 Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sat, 14 Sep 2024 18:22:30 -0400 Subject: [PATCH 95/97] fix: Fix broken shutdown handler (#373) fix: Fix broken shutdown handler (#373) --- cmd/app/server.go | 19 +++++++++++-------- cmd/app/shutdown.go | 36 ++++++++---------------------------- doc/gitlab.nvim.txt | 20 +++----------------- 3 files changed, 22 insertions(+), 53 deletions(-) diff --git a/cmd/app/server.go b/cmd/app/server.go index 69b9c60d..42ac9e4e 100644 --- a/cmd/app/server.go +++ b/cmd/app/server.go @@ -18,7 +18,7 @@ to handle potential shutdown requests and incoming HTTP requests. */ func StartServer(client *Client, projectInfo *ProjectInfo, GitInfo git.GitData) { - s := shutdown{ + s := shutdownService{ sigCh: make(chan os.Signal, 1), } @@ -26,7 +26,7 @@ func StartServer(client *Client, projectInfo *ProjectInfo, GitInfo git.GitData) r := CreateRouter( client, projectInfo, - s, + &s, func(a *data) error { a.projectInfo = projectInfo; return nil }, func(a *data) error { a.gitInfo = &GitInfo; return nil }, func(a *data) error { err := attachEmojis(a, fr); return err }, @@ -39,10 +39,8 @@ func StartServer(client *Client, projectInfo *ProjectInfo, GitInfo git.GitData) go func() { err := server.Serve(l) if err != nil { - if errors.Is(err, http.ErrServerClosed) { - os.Exit(0) - } else { - fmt.Fprintf(os.Stderr, "Server did not respond: %s\n", err) + if !errors.Is(err, http.ErrServerClosed) { + fmt.Fprintf(os.Stderr, "Server crashed: %s\n", err) os.Exit(1) } } @@ -76,7 +74,7 @@ type data struct { type optFunc func(a *data) error -func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s ShutdownHandler, optFuncs ...optFunc) http.Handler { +func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s *shutdownService, optFuncs ...optFunc) http.Handler { m := http.NewServeMux() d := data{ @@ -231,7 +229,12 @@ func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s ShutdownHand withMethodCheck(http.MethodPost), )) - m.HandleFunc("/shutdown", s.shutdownHandler) + m.HandleFunc("/shutdown", middleware( + *s, + withPayloadValidation(methodToPayload{http.MethodPost: &ShutdownRequest{}}), + withMethodCheck(http.MethodPost), + )) + m.Handle("/ping", http.HandlerFunc(pingHandler)) return LoggingServer{handler: m} diff --git a/cmd/app/shutdown.go b/cmd/app/shutdown.go index 5f4bd305..043b232e 100644 --- a/cmd/app/shutdown.go +++ b/cmd/app/shutdown.go @@ -3,9 +3,7 @@ package app import ( "context" "encoding/json" - "errors" "fmt" - "io" "net/http" "os" ) @@ -19,22 +17,20 @@ func (k killer) String() string { type ShutdownHandler interface { WatchForShutdown(server *http.Server) - shutdownHandler(w http.ResponseWriter, r *http.Request) + ServeHTTP(w http.ResponseWriter, r *http.Request) } -type shutdown struct { +type shutdownService struct { sigCh chan os.Signal } -func (s shutdown) WatchForShutdown(server *http.Server) { +func (s shutdownService) WatchForShutdown(server *http.Server) { /* Handles shutdown requests */ <-s.sigCh err := server.Shutdown(context.Background()) if err != nil { fmt.Fprintf(os.Stderr, "Server could not shut down gracefully: %s\n", err) os.Exit(1) - } else { - os.Exit(0) } } @@ -42,36 +38,20 @@ type ShutdownRequest struct { Restart bool `json:"restart"` } -/* shutdownHandler will shutdown the HTTP server and exit the process by signaling to the shutdown channel */ -func (s shutdown) shutdownHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.Header().Set("Allow", http.MethodPost) - handleError(w, errors.New("Invalid request type"), "That request type is not allowed", http.StatusMethodNotAllowed) - return - } +/* Shuts down the HTTP server and exit the process by signaling to the shutdown channel */ +func (s shutdownService) ServeHTTP(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - handleError(w, err, "Could not read request body", http.StatusBadRequest) - return - } - - var shutdownRequest ShutdownRequest - err = json.Unmarshal(body, &shutdownRequest) - if err != nil { - handleError(w, err, "Could not unmarshal data from request body", http.StatusBadRequest) - return - } + payload := r.Context().Value(payload("payload")).(*ShutdownRequest) var text = "Shut down server" - if shutdownRequest.Restart { + if payload.Restart { text = "Restarted server" } w.WriteHeader(http.StatusOK) response := SuccessResponse{Message: text} - err = json.NewEncoder(w).Encode(response) + err := json.NewEncoder(w).Encode(response) if err != nil { handleError(w, err, "Could not encode response", http.StatusInternalServerError) } else { diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 2a529e00..a657e744 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -22,7 +22,7 @@ Table of Contents *gitlab.nvim.table-of-contents* - Creating an MR |gitlab.nvim.creating-an-mr| - Pipelines |gitlab.nvim.pipelines| - Reviewers and Assignees |gitlab.nvim.reviewers-and-assignees| - - Restarting or Shutting Down |gitlab.nvim.restarting-or-shutting-down| + - Restarting The Go Server |gitlab.nvim.restarting| - Keybindings |gitlab.nvim.keybindings| - Troubleshooting |gitlab.nvim.troubleshooting| - Api |gitlab.nvim.api| @@ -591,26 +591,12 @@ install `dressing.nvim`. If you use Dressing, please enable it: }) < -RESTARTING OR SHUTTING DOWN *gitlab.nvim.restarting-or-shutting-down* +RESTARTING THE GO SERVER *gitlab.nvim.restarting* The `gitlab.nvim` server will shut down automatically when you exit Neovim. -However, if you would like to manage this yourself (for instance, restart the -server when you check out a new branch) you may do so via the `restart` -command, or `shutdown` commands, which both accept callbacks: +You may restart the server at any time via the the `restart` command: >lua require("gitlab.server").restart() - require("gitlab.server").shutdown() -< -For instance you could set up the following keybinding to close and reopen the -reviewer when checking out a new branch: ->lua - local gitlab = require("gitlab") - vim.keymap.set("n", "glB", function () - require("gitlab.server").restart(function () - vim.cmd.tabclose() - gitlab.review() -- Reopen the reviewer after the server restarts - end) - end) < KEYBINDINGS *gitlab.nvim.keybindings* From 17cf9b93d946c620c3570e209d90168ae40644ba Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Sat, 14 Sep 2024 18:32:20 -0400 Subject: [PATCH 96/97] move out ping handler --- cmd/app/server.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/cmd/app/server.go b/cmd/app/server.go index 9774011e..342eb947 100644 --- a/cmd/app/server.go +++ b/cmd/app/server.go @@ -233,17 +233,14 @@ func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s *shutdownSer withMethodCheck(http.MethodPost), )) - m.Handle("/ping", http.HandlerFunc(pingHandler)) + m.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "pong") + }) return LoggingServer{handler: m} } -/* Used to check whether the server has started yet */ -func pingHandler(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fmt.Fprintln(w, "pong") -} - /* checkServer pings the server repeatedly for 1 full second after startup in order to notify the plugin that the server is ready */ func checkServer(port int) error { for i := 0; i < 10; i++ { From 778a4c4a52106905c03400c4c1a908668bbd3e30 Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Sat, 14 Sep 2024 20:50:11 -0400 Subject: [PATCH 97/97] Fixed reply (leaving comment on gitlab buffer) --- lua/gitlab/actions/comment.lua | 5 +++-- lua/gitlab/actions/discussions/init.lua | 12 ++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index daadd3be..95abe305 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -156,6 +156,7 @@ end ---@field ranged boolean ---@field unlinked boolean ---@field discussion_id string|nil +---@field reply boolean|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 @@ -172,7 +173,7 @@ M.create_comment_layout = function(opts) -- Check that Diffview is the current view local view = diffview_lib.get_current_view() - if view == nil then + if view == nil and not opts.reply then u.notify("Comments should be left in the reviewer pane", vim.log.levels.ERROR) return end @@ -186,7 +187,7 @@ M.create_comment_layout = function(opts) -- Check that we are hovering over the code local filetype = vim.bo[0].filetype - if filetype == "DiffviewFiles" or filetype == "gitlab" then + if not opts.reply and (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 diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 5e7cfdda..571fe47f 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -244,8 +244,16 @@ M.reply = function(tree) local discussion_id = tostring(discussion_node.id) local comment = require("gitlab.actions.comment") local unlinked = tree.bufnr == M.unlinked_bufnr - local layout = comment.create_comment_layout({ ranged = false, discussion_id = discussion_id, unlinked = unlinked }) - layout:mount() + local layout = comment.create_comment_layout({ + ranged = false, + discussion_id = discussion_id, + unlinked = unlinked, + reply = true, + }) + + if layout then + layout:mount() + end end -- This function (settings.keymaps.discussion_tree.delete_comment) will trigger a popup prompting you to delete the current comment