diff --git a/README.md b/README.md index 4b12b1fe..b6e4dff9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This Neovim plugin is designed to make it easy to review Gitlab MRs from within the editor. This means you can do things like: -- Create, approve, and merge MRs for the current branch +- Create, approve, rebase, and merge MRs for the current branch - Read and edit an MR description - Add or remove reviewers and assignees - Resolve, reply to, and unresolve discussion threads @@ -143,9 +143,13 @@ glA Approve MR glR Revoke MR approval glM Merge the feature branch to the target branch and close MR glm Set MR to merge automatically when the pipeline succeeds +glrr Rebase the feature branch of the MR on the server (if not already rebased) and pull the new state +glrs Same as `glrr`, but skip the CI pipeline +glrf Same as `glrr`, but rebase even if MR already is rebased glC Create a new MR for currently checked-out feature branch glc Chose MR for review glS Start review for the currently checked-out branch +gl Load new MR state from Gitlab and apply new diff refs to the diff view gls Show the editable summary of the MR glu Copy the URL of the MR to the system clipboard glo Open the URL of the MR in the default Internet browser diff --git a/cmd/app/rebase_mr.go b/cmd/app/rebase_mr.go new file mode 100644 index 00000000..6034b135 --- /dev/null +++ b/cmd/app/rebase_mr.go @@ -0,0 +1,80 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +type RebaseMrRequest struct { + SkipCI bool `json:"skip_ci,omitempty"` +} + +type MergeRequestRebaser interface { + RebaseMergeRequest(pid interface{}, mergeRequest int64, opt *gitlab.RebaseMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) +} + +type MergeRequestRebaserClient interface { + MergeRequestRebaser + MergeRequestGetter +} + +type mergeRequestRebaserService struct { + data + client MergeRequestRebaserClient +} + +/* Rebases a merge request on the server */ +func (a mergeRequestRebaserService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + payload := r.Context().Value(payload("payload")).(*RebaseMrRequest) + + opts := gitlab.RebaseMergeRequestOptions{ + SkipCI: &payload.SkipCI, + } + + res, err := a.client.RebaseMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opts) + if err != nil { + handleError(w, err, "Could not rebase MR", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{r.URL.Path}, "Could not rebase MR", res.StatusCode) + return + } + + // Poll until rebase completes (GitLab rebase is async) + for { + mr, _, getMrErr := a.client.GetMergeRequest( + a.projectInfo.ProjectId, + a.projectInfo.MergeId, + &gitlab.GetMergeRequestsOptions{ + IncludeRebaseInProgress: gitlab.Ptr(true), + }, + ) + if getMrErr != nil { + handleError(w, getMrErr, "Could not check rebase status", http.StatusInternalServerError) + return + } + if !mr.RebaseInProgress { + break + } + time.Sleep(1 * time.Second) + } + + skippingCI := "" + if payload.SkipCI { + skippingCI = " (skipping CI)" + } + response := SuccessResponse{Message: fmt.Sprintf("MR rebased on server%s", skippingCI)} + + w.WriteHeader(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/rebase_mr_test.go b/cmd/app/rebase_mr_test.go new file mode 100644 index 00000000..0571d7e3 --- /dev/null +++ b/cmd/app/rebase_mr_test.go @@ -0,0 +1,110 @@ +package app + +import ( + "net/http" + "testing" + + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +type fakeMergeRequestRebaserClient struct { + testBase + rebaseInProgressCount int // number of times to return RebaseInProgress: true + getMergeRequestCalls int // tracks how many times GetMergeRequest was called +} + +func (f *fakeMergeRequestRebaserClient) RebaseMergeRequest(pid interface{}, mergeRequest int64, opt *gitlab.RebaseMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, err + } + + return resp, err +} + +func (f *fakeMergeRequestRebaserClient) GetMergeRequest(pid interface{}, mergeRequest int64, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + + f.getMergeRequestCalls++ + rebaseInProgress := f.getMergeRequestCalls <= f.rebaseInProgressCount + + return &gitlab.MergeRequest{RebaseInProgress: rebaseInProgress}, resp, err +} + +func TestRebaseHandler(t *testing.T) { + var testRebaseMrPayload = RebaseMrRequest{SkipCI: false} + t.Run("Rebases merge request when rebase completes immediately", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload) + fakeClient := &fakeMergeRequestRebaserClient{rebaseInProgressCount: 0} + svc := middleware( + mergeRequestRebaserService{testProjectData, fakeClient}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: newPayload[RebaseMrRequest], + }), + withMethodCheck(http.MethodPost), + ) + data := getSuccessData(t, svc, request) + assert(t, data.Message, "MR rebased on server") + assert(t, fakeClient.getMergeRequestCalls, 1) + }) + t.Run("Rebases merge request and polls until rebase completes", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload) + fakeClient := &fakeMergeRequestRebaserClient{rebaseInProgressCount: 1} + svc := middleware( + mergeRequestRebaserService{testProjectData, fakeClient}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: newPayload[RebaseMrRequest], + }), + withMethodCheck(http.MethodPost), + ) + data := getSuccessData(t, svc, request) + assert(t, data.Message, "MR rebased on server") + assert(t, fakeClient.getMergeRequestCalls, 2) + }) + var testRebaseMrPayloadSkipCI = RebaseMrRequest{SkipCI: true} + t.Run("Rebases merge request and skips CI", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayloadSkipCI) + fakeClient := &fakeMergeRequestRebaserClient{} + svc := middleware( + mergeRequestRebaserService{testProjectData, fakeClient}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: newPayload[RebaseMrRequest], + }), + withMethodCheck(http.MethodPost), + ) + data := getSuccessData(t, svc, request) + assert(t, data.Message, "MR rebased on server (skipping CI)") + }) + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload) + svc := middleware( + mergeRequestRebaserService{testProjectData, &fakeMergeRequestRebaserClient{testBase: testBase{errFromGitlab: true}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: newPayload[RebaseMrRequest], + }), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not rebase MR") + }) + t.Run("Handles non-200s from Gitlab", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload) + svc := middleware( + mergeRequestRebaserService{testProjectData, &fakeMergeRequestRebaserClient{testBase: testBase{status: http.StatusSeeOther}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: newPayload[RebaseMrRequest], + }), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) + checkNon200(t, data, "Could not rebase MR", "/mr/rebase") + }) +} diff --git a/cmd/app/server.go b/cmd/app/server.go index f143904a..404c5bbe 100644 --- a/cmd/app/server.go +++ b/cmd/app/server.go @@ -117,6 +117,12 @@ func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s *shutdownSer withPayloadValidation(methodToPayload{http.MethodPost: newPayload[AcceptMergeRequestRequest]}), withMethodCheck(http.MethodPost), )) + m.HandleFunc("/mr/rebase", middleware( + mergeRequestRebaserService{d, gitlabClient}, + withMr(d, gitlabClient), + withPayloadValidation(methodToPayload{http.MethodPost: newPayload[RebaseMrRequest]}), + withMethodCheck(http.MethodPost), + )) m.HandleFunc("/mr/discussions/list", middleware( discussionsListerService{d, gitlabClient}, withMr(d, gitlabClient), diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index b95b55aa..fae8b12e 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -33,7 +33,7 @@ OVERVIEW *gitlab.nvim.overview* This Neovim plugin is designed to make it easy to review Gitlab MRs from within the editor. This means you can do things like: -- Create, approve, and merge MRs for the current branch +- Create, approve, rebase, and merge MRs for the current branch - Read and edit an MR description - Add or remove reviewers and assignees - Resolve, reply to, and unresolve discussion threads @@ -183,9 +183,13 @@ you call this function with no values the defaults will be used: revoke = "glR", -- Revoke MR approval merge = "glM", -- Merge the feature branch to the target branch and close MR set_auto_merge = "glm", -- Set MR to merge automatically when the pipeline succeeds + rebase = "glrr", -- Rebase the feature branch of the MR on the server and pull the new state + rebase_skip_ci = "glrs", -- Same as `rebase`, but skip the CI pipeline + rebase_force = "glrf", -- Same as `rebase`, but rebase even if MR already is rebased 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 + reload_review = "gl", -- Load new MR state from Gitlab and apply new diff refs to the diff view 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 @@ -260,6 +264,7 @@ you call this function with no values the defaults will be used: }, spinner_chars = { "/", "|", "\\", "-" }, -- Characters for the refresh animation auto_open = true, -- Automatically open when the reviewer is opened + enter_on_open = true, -- Automatically enter the discussion tree when it is opened default_view = "discussions", -- Show "discussions" or "notes" by default blacklist = {}, -- List of usernames to remove from tree (bots, CI, etc) sort_by = "latest_reply", -- Sort discussion tree by the "latest_reply", or by "original_comment", see `:h gitlab.nvim.toggle_sort_method` @@ -391,6 +396,10 @@ you call this function with no values the defaults will be used: border = "rounded", }, }, + rebase_mr = { + skip_ci = false, -- If true, a CI pipeline is not created. Can be overridden in gitlab.rebase call. + force = false, -- If true, MR is rebased even if MR already is rebased. Can be overridden in gitlab.rebase call. + }, colors = { discussion_tree = { username = "Keyword", @@ -614,6 +623,19 @@ this command to work. See |gitlab.nvim.merge| for more help on this function. +REBASING AN MR *gitlab.nvim.rebasing-an-mr* + +The `rebase` action will rebase an MR on the server, wait for the rebase to +take effect and then update the local reviewer state. The MR must be in a +"rebasable" state for this command to work, i.e., the worktree must be clean +and there must be no conflicts. +>lua + require("gitlab").rebase() + require("gitlab").rebase({ skip_ci = true, force = true }) +< +See |gitlab.nvim.rebase| 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 @@ -838,6 +860,25 @@ 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() +< + *gitlab.nvim.reload_review* +gitlab.reload_review() ~ + +Loads new MR state from Gitlab. Then if diffview.api is available (with the +https://github.com/dlyongemallo/diffview.nvim fork) applies the new diff refs +to the existing diffview, otherwise (with +https://github.com/sindrets/diffview.nvim) closes and re-opens the reviewer. + +>lua + require("gitlab").reload_review() +< + *gitlab.nvim.close_review* +gitlab.close_review() ~ + +Closes the reviewer tab and discussion tree and cleans up (e.g., removes +winbar timer). +>lua + require("gitlab").close_review() < *gitlab.nvim.summary* gitlab.summary() ~ @@ -1091,6 +1132,23 @@ request" page). You can see the current settings in the Summary view, see Use the `keymaps.popup.perform_action` to merge the MR with your message. + *gitlab.nvim.rebase* +gitlab.rebase({opts}) ~ + +Rebases the feature branch of the MR on the server and pulls the new state of +the target branch. +>lua + require("gitlab").rebase() + require("gitlab").rebase({ skip_ci = true, force = true }) +< + Parameters: ~ + • {opts}: (table|nil) Keyword arguments that can be used to override + default behavior. + • {skip_ci}: (bool) If true, a CI pipeline is not created (this + may lead to a failed Mergeability Check (CI_MUST_PASS). + • {force}: (bool) If true, MR is rebased even if MR already is + rebased (this may run an unnecessary CI pipeline). + *gitlab.nvim.data* gitlab.data({resources}, {cb}) ~ diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 086cd1e6..56617464 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -300,14 +300,14 @@ end ---@return boolean M.can_create_comment = function(must_be_visual) -- Check that diffview is initialized - if reviewer.tabnr == nil then + if reviewer.tabid == nil then u.notify("Reviewer must be initialized first", vim.log.levels.ERROR) return false end -- Check that we are in the Diffview tab - local tabnr = vim.api.nvim_get_current_tabpage() - if tabnr ~= reviewer.tabnr then + local tabid = vim.api.nvim_get_current_tabpage() + if tabid ~= reviewer.tabid then u.notify("Comments can only be left in the reviewer pane", vim.log.levels.ERROR) return false end diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua index 30140c7c..d8e66fbe 100644 --- a/lua/gitlab/actions/common.lua +++ b/lua/gitlab/actions/common.lua @@ -127,7 +127,7 @@ M.print_node = function(tree) end ---Check if type of node is note or note body ----@param node NuiTree.Node? +---@param node? NuiTree.Node ---@return boolean M.is_node_note = function(node) if node and (node.type == "note_body" or node.type == "note") then @@ -139,7 +139,7 @@ end ---Get root node ---@param tree NuiTree ----@param node NuiTree.Node? +---@param node? NuiTree.Node ---@return NuiTree.Node? M.get_root_node = function(tree, node) if not node then @@ -155,7 +155,7 @@ end ---Get note node ---@param tree NuiTree ----@param node NuiTree.Node? +---@param node? NuiTree.Node ---@return NuiTree.Node? M.get_note_node = function(tree, node) if not node then diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index eb1d6c4c..1e49ee75 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -56,8 +56,13 @@ end ---@param callback function|nil M.load_discussions = function(callback) local git = require("gitlab.git") - local ahead, behind = git.get_ahead_behind(git.get_current_branch(), git.get_remote_branch()) - state.ahead_behind = { ahead, behind } + require("gitlab.git_async").get_ahead_behind( + git.get_current_branch(), + git.get_remote_branch(), + function(ahead, behind) + state.ahead_behind = { ahead, behind } + end + ) state.discussion_tree.last_updated = nil state.load_new_state("discussion_data", function(data) if not state.DISCUSSION_DATA then @@ -84,7 +89,7 @@ M.initialize_discussions = function() M.refresh_diagnostics() end) reviewer.set_callback_for_buf_read(function(args) - vim.api.nvim_buf_set_option(args.buf, "modifiable", false) + vim.api.nvim_set_option_value("modifiable", false, { buf = args.buf }) reviewer.set_keymaps(args.buf) reviewer.set_reviewer_autocommands(args.buf) end) @@ -104,20 +109,26 @@ 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? +---@param callback? function ---@param view_type "discussions"|"notes" Defines the view type to select (useful for overriding the default view type when jumping to discussion tree when it's closed). M.open = function(callback, view_type) - view_type = view_type and view_type or state.settings.discussion_tree.default_view + local original_window = vim.api.nvim_get_current_win() -- The window from which ther user called M.open + + M.current_view_type = view_type and view_type or state.settings.discussion_tree.default_view + state.DISCUSSION_DATA = u.ensure_table(state.DISCUSSION_DATA) 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 + -- Make discussion split window and buffers, store buffer numbers local split, linked_bufnr, unlinked_bufnr = M.create_split_and_bufs() M.split = split M.linked_bufnr = linked_bufnr M.unlinked_bufnr = unlinked_bufnr + M.split_visible = true + split:mount() + -- Set window and buffer local options to discussion tree split after mounting the split for opt, val in pairs(state.settings.discussion_tree.winopts) do vim.api.nvim_set_option_value(opt, val, { win = M.split.winid }) end @@ -125,28 +136,43 @@ M.open = function(callback, view_type) vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_bufnr }) vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.unlinked_bufnr }) - M.split = split - M.split_visible = true - split:mount() + -- Set autocmds to clean up state when discussions buffers are deleted manually + vim.api.nvim_create_autocmd("BufWipeout", { + buffer = M.linked_bufnr, + callback = function() + M.linked_bufnr = nil + end, + }) + vim.api.nvim_create_autocmd("BufWipeout", { + buffer = M.unlinked_bufnr, + callback = function() + M.unlinked_bufnr = nil + end, + }) - -- Initialize winbar module with data from buffers - winbar.start_timer() - winbar.set_buffers(M.linked_bufnr, M.unlinked_bufnr) - winbar.switch_view_type(view_type) + -- Set autocmd to clean up state when discussions split is closed manually + vim.api.nvim_create_autocmd("WinClosed", { + pattern = tostring(M.split.winid), + callback = M.close, + }) - 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) + -- Initialize winbar + winbar.start_timer() - common.switch_can_edit_bufs(true, M.linked_bufnr, M.unlinked_bufnr) - M.rebuild_discussion_tree() + -- Rebuild trees in order to set keymaps and make buffers protected + M.switch_view_type(M.current_view_type) M.rebuild_unlinked_discussion_tree() + M.rebuild_discussion_tree() - -- Set default buffer - local default_buffer = winbar.bufnr_map[view_type] - vim.api.nvim_set_current_buf(default_buffer) - common.switch_can_edit_bufs(false, M.linked_bufnr, M.unlinked_bufnr) + -- Focus the correct window + local win_to_enter = not state.settings.discussion_tree.enter_on_open and original_window or M.split.winid + if vim.api.nvim_win_is_valid(win_to_enter) then + vim.api.nvim_set_current_win(win_to_enter) + end + + -- Relooad data + draft_notes.rebuild_view(false, true) - vim.api.nvim_set_current_win(current_window) if type(callback) == "function" then callback() end @@ -190,7 +216,7 @@ M.move_to_discussion_tree = function() end M.discussion_tree:render() vim.api.nvim_set_current_win(M.split.winid) - winbar.switch_view_type("discussions") + M.switch_view_type("discussions") vim.api.nvim_win_set_cursor(M.split.winid, { line_number, 0 }) end @@ -436,6 +462,7 @@ M.rebuild_discussion_tree = function() end local current_node = discussions_tree.get_node_at_cursor(M.discussion_tree, M.last_node_at_cursor) + local current_cursor_column = vim.api.nvim_win_get_cursor(0)[2] local expanded_node_ids = M.gather_expanded_node_ids(M.discussion_tree) common.switch_can_edit_bufs(true, M.linked_bufnr, M.unlinked_bufnr) @@ -458,7 +485,7 @@ M.rebuild_discussion_tree = function() tree_utils.open_node_by_id(discussion_tree, id) end discussion_tree:render() - discussions_tree.restore_cursor_position(M.split.winid, discussion_tree, current_node) + discussions_tree.restore_cursor_position(M.split.winid, discussion_tree, current_cursor_column, current_node, nil) M.set_tree_keymaps(discussion_tree, M.linked_bufnr, false) M.discussion_tree = discussion_tree @@ -474,6 +501,7 @@ M.rebuild_unlinked_discussion_tree = function() end local current_node = discussions_tree.get_node_at_cursor(M.unlinked_discussion_tree, M.last_node_at_cursor) + local current_cursor_column = vim.api.nvim_win_get_cursor(0)[2] 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) @@ -496,7 +524,7 @@ M.rebuild_unlinked_discussion_tree = function() tree_utils.open_node_by_id(unlinked_discussion_tree, id) end unlinked_discussion_tree:render() - discussions_tree.restore_cursor_position(M.split.winid, unlinked_discussion_tree, current_node) + discussions_tree.restore_cursor_position(M.split.winid, unlinked_discussion_tree, current_cursor_column, current_node) M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_bufnr, true) M.unlinked_discussion_tree = unlinked_discussion_tree @@ -676,7 +704,7 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) if keymaps.discussion_tree.toggle_node then vim.keymap.set("n", keymaps.discussion_tree.toggle_node, function() - tree_utils.toggle_node(tree) + tree_utils.toggle_node(M.split.winid, tree) end, { buffer = bufnr, desc = "Toggle node", nowait = keymaps.discussion_tree.toggle_node_nowait }) end @@ -732,7 +760,7 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) if keymaps.discussion_tree.switch_view then vim.keymap.set("n", keymaps.discussion_tree.switch_view, function() - winbar.switch_view_type() + M.switch_view_type() end, { buffer = bufnr, desc = "Change view type between discussions and notes", @@ -799,6 +827,21 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) emoji.init_popup(tree, bufnr) end +---Toggles the current view type (or sets it to `override`) and then updates the view. +---@param override? "discussions"|"notes" The view type to select. +M.switch_view_type = function(override) + vim.api.nvim_set_option_value("winfixbuf", false, { win = M.split.winid }) + if override == "discussions" or M.current_view_type == "notes" then + M.current_view_type = "discussions" + vim.api.nvim_set_current_buf(M.linked_bufnr) + elseif override == "notes" or M.current_view_type == "discussions" then + M.current_view_type = "notes" + vim.api.nvim_set_current_buf(M.unlinked_bufnr) + end + vim.api.nvim_set_option_value("winfixbuf", true, { win = M.split.winid }) + winbar.update_winbar() +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 diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index e4f192ba..91c76c42 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -11,7 +11,7 @@ local M = {} ---Create nodes for NuiTree from discussions ---@param items Discussion[] ----@param unlinked boolean? False or nil means that discussions are linked to code lines +---@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 = {} @@ -102,7 +102,7 @@ end ---Create path node ---@param relative_path string ---@param full_path string ----@param child_nodes NuiTree.Node[]? +---@param child_nodes? NuiTree.Node[] ---@return NuiTree.Node local function create_path_node(relative_path, full_path, child_nodes) return NuiTree.Node({ @@ -154,7 +154,7 @@ end ---Create file name node ---@param file_name string ---@param full_file_path string ----@param child_nodes NuiTree.Node[]? +---@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) @@ -256,29 +256,26 @@ M.create_node_list_by_file_name = function(node_list) 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? +---@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) + local i = 0 + for body_line in u.split_by_new_lines(note.body or note.note) do 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, + text = body_line, + id = string.format("%d:%d", note.id, i), type = "note_body", }, {}) ) + i = i + 1 end local symbol = "" @@ -297,7 +294,7 @@ end ---Build note node ---@param note Note|DraftNote ----@param resolve_info table? +---@param resolve_info? table ---@return NuiTree.Node ---@return string ---@return NuiTree.Node[] @@ -377,8 +374,8 @@ end ---@field keep_current_open boolean Whether to keep the current discussion open even if it should otherwise be closed. ---This function expands/collapses all nodes and their children according to the opts. ----@param tree NuiTree ---@param winid integer +---@param tree NuiTree ---@param unlinked boolean ---@param opts ToggleNodesOptions M.toggle_nodes = function(winid, tree, unlinked, opts) @@ -387,6 +384,7 @@ M.toggle_nodes = function(winid, tree, unlinked, opts) return end local root_node = common.get_root_node(tree, current_node) + local current_cursor_column = vim.api.nvim_win_get_cursor(winid)[2] for _, node in ipairs(tree:get_nodes()) do if opts.toggle_resolved then if @@ -426,7 +424,7 @@ M.toggle_nodes = function(winid, tree, unlinked, opts) end end tree:render() - M.restore_cursor_position(winid, tree, current_node, root_node) + M.restore_cursor_position(winid, tree, current_cursor_column, current_node, root_node) end -- Get current node for restoring cursor position @@ -446,9 +444,10 @@ end ---Restore cursor position to the original node if possible ---@param winid integer Window number of the discussions split ---@param tree NuiTree The inline discussion tree or the unlinked discussion tree +---@param cursor_column integer The original column of the cursor ---@param original_node NuiTree.Node|nil The last node with the cursor ---@param root_node NuiTree.Node|nil The root node of the last node with the cursor -M.restore_cursor_position = function(winid, tree, original_node, root_node) +M.restore_cursor_position = function(winid, tree, cursor_column, original_node, root_node) if original_node == nil or tree == nil then return end @@ -460,10 +459,9 @@ M.restore_cursor_position = function(winid, tree, original_node, root_node) _, line_number = tree:get_node("-" .. tostring(root_node.id)) end end - if line_number ~= nil then - if vim.api.nvim_win_is_valid(winid) then - vim.api.nvim_win_set_cursor(winid, { line_number, 0 }) - end + if line_number ~= nil and winid and vim.api.nvim_win_is_valid(winid) then + local last_line = vim.fn.line("$") + vim.api.nvim_win_set_cursor(winid, { math.min(line_number, last_line), cursor_column or 0 }) end end @@ -518,14 +516,14 @@ M.open_node_by_id = function(tree, id) end -- This function (settings.keymaps.discussion_tree.toggle_node) expands/collapses the current node and its children -M.toggle_node = function(tree) +---@param winid integer The id if the tree split. +---@param tree NuiTree The current discussion tree. +M.toggle_node = function(winid, tree) local node = tree:get_node() - if node == nil then - return - end + local current_cursor_column = vim.api.nvim_win_get_cursor(winid)[2] -- Switch to the "note" node from "note_body" nodes to enable toggling discussions inside comments - if node.type == "note_body" then + if node ~= nil and node.type == "note_body" then node = tree:get_node(node:get_parent_id()) end if node == nil then @@ -533,9 +531,7 @@ M.toggle_node = function(tree) 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 @@ -553,6 +549,7 @@ M.toggle_node = function(tree) end tree:render() + M.restore_cursor_position(winid, tree, current_cursor_column, node, common.get_root_node(tree, node)) end return M diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index 6dcd6011..cae784ad 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -2,20 +2,7 @@ local u = require("gitlab.utils") 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 +local M = {} ---@param nodes Discussion[]|UnlinkedDiscussion[]|nil ---@return number, number, number @@ -158,7 +145,7 @@ end ---@param t WinbarTable M.make_winbar = function(t) - local discussions_focused = M.current_view_type == "discussions" + local discussions_focused = require("gitlab.actions.discussions").current_view_type == "discussions" local discussion_text = add_drafts_and_resolvable( "Comments:", t.resolvable_discussions, @@ -269,23 +256,6 @@ M.get_ahead_behind = function(ahead, behind) return a .. "↑ " .. b .. "↓" end ----Toggles the current view type (or sets it to `override`) and then updates the view. ----@param override "discussions"|"notes" Defines the view type to select. -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 - ---Set up a timer to update the winbar periodically M.start_timer = function() M.cleanup_timer() diff --git a/lua/gitlab/actions/merge.lua b/lua/gitlab/actions/merge.lua index 5e0e5478..c8f5e097 100644 --- a/lua/gitlab/actions/merge.lua +++ b/lua/gitlab/actions/merge.lua @@ -46,7 +46,7 @@ M.merge = function(opts) end ---@param merge_body MergeOpts ----@param squash_message string? +---@param squash_message? string M.confirm_merge = function(merge_body, squash_message) if squash_message ~= nil then merge_body.squash_message = squash_message diff --git a/lua/gitlab/actions/rebase.lua b/lua/gitlab/actions/rebase.lua new file mode 100644 index 00000000..91bb6114 --- /dev/null +++ b/lua/gitlab/actions/rebase.lua @@ -0,0 +1,104 @@ +-- This module is responsible for rebasing the MR on the Gitlab server and updating the local +-- branch and reviewer state. + +local M = {} + +---@class RebaseOpts +---@field skip_ci boolean? If true, a CI pipeline is not created. +---@field force boolean? If true, MR is rebased even if MR already is rebased. + +---@param opts RebaseOpts +local can_rebase = function(opts) + local u = require("gitlab.utils") + -- Check if there are local changes (we wouldn't be able to run `git pull` after rebasing) + local has_clean_tree, err = require("gitlab.git").has_clean_tree() + if not has_clean_tree then + u.notify("Cannot rebase when there are changed files", vim.log.levels.ERROR) + return false + elseif err ~= nil then + u.notify("Error while inspecting working tree", vim.log.levels.ERROR) + return false + end + + local state = require("gitlab.state") + + local already_rebased = vim.iter(state.MERGEABILITY):find(function(check) + return check.identifier == "NEED_REBASE" and check.status == "SUCCESS" + end) + + local force = require("gitlab.state").settings.rebase_mr.force + if opts.force ~= nil then + force = opts.force + end + + if already_rebased and not force then + u.notify("MR is already rebased", vim.log.levels.ERROR) + return false + end + + local has_conflicts = vim.iter(state.MERGEABILITY):find(function(check) + return check.identifier == "CONFLICT" and check.status ~= "SUCCESS" and check.status ~= "INACTIVE" + end) + if has_conflicts then + if has_conflicts.status == "FAILED" then + u.notify("Rebase locally, resolve all conflicts, then push the branch", vim.log.levels.ERROR) + else + u.notify("Check for conflicts hasn't passed successfully", vim.log.levels.WARN) + end + return false + end + + return true +end + +---Callback to run after the async `git pull` call exits +---@param result string|nil The stdout from the `git pull` call if any. +---@param err string|nil The stderr from the `git pull` call if any. +local on_pull_exit = function(result, err) + if result ~= nil then + local reviewer = require("gitlab.reviewer") + if reviewer.tabid ~= nil then + reviewer.reload() + end + elseif err ~= nil then + require("gitlab.utils").notify(err, vim.log.levels.ERROR) + end +end + +---@class RebaseBody +---@field skip_ci boolean? If true, a CI pipeline is not created. + +---@param rebase_body RebaseBody +local confirm_rebase = function(rebase_body) + local u = require("gitlab.utils") + u.notify("Rebase in progress", vim.log.levels.INFO) + local job = require("gitlab.job") + job.run_job("/mr/rebase", "POST", rebase_body, function(data) + u.notify(data.message .. ", updating local state", vim.log.levels.INFO) + local state = require("gitlab.state") + require("gitlab.git_async").pull( + state.settings.connection_settings.remote, + state.INFO.source_branch, + on_pull_exit, + { "--rebase" } + ) + end) +end + +---@param opts RebaseOpts +M.rebase = function(opts) + opts = opts or {} + + if not can_rebase(opts) then + return + end + + local rebase_body = { skip_ci = require("gitlab.state").settings.rebase_mr.skip_ci } + if opts.skip_ci ~= nil then + rebase_body.skip_ci = opts.skip_ci + end + + confirm_rebase(rebase_body) +end + +return M diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 651b7a47..13509ba7 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -32,6 +32,8 @@ M.summary = function() return end + require("gitlab.git_async").check_current_branch_up_to_date_on_remote() + local title = state.INFO.title local description_lines = common.build_content(state.INFO.description) local info_lines = state.settings.info.enabled and M.build_info_lines() or { "" } @@ -89,7 +91,6 @@ M.summary = function() vim.api.nvim_set_current_buf(description_popup.bufnr) end) - git.check_current_branch_up_to_date_on_remote(vim.log.levels.WARN) git.check_mr_in_good_condition() end diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index 4b8dfa6c..fcca3ba4 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -165,6 +165,7 @@ ---@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 rebase_mr? RebaseMrSettings -- The settings when rebasing an MR ---@field colors? ColorSettings --- Colors settings for the plugin ---@class ServerSettings @@ -203,6 +204,10 @@ ---@field title_input? TitleInputSettings ---@field fork? ForkSettings +---@class RebaseMrSettings: table +---@field skip_ci? boolean -- If true, a CI pipeline is not created. +---@field force? boolean -- If true, MR is rebased even if MR already is rebased. + ---@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. @@ -295,6 +300,7 @@ ---@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 enter_on_open? boolean -- Automatically enter the discussion tree when it 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 @@ -369,6 +375,7 @@ ---@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 reload_review? string -- Load new MR state from Gitlab and apply new diff refs to the diff view ---@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 diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index ae139df5..01f0df2e 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -23,7 +23,7 @@ M.branches = function(args) return run_system(u.combine({ "git", "branch" }, args or {})) end ----Returns true if the working tree hasn't got any changes that haven't been commited +---Returns true if the working tree hasn't got any changes that haven't been committed ---@return boolean, string|nil M.has_clean_tree = function() local changes, err = run_system({ "git", "status", "--short", "--untracked-files=no" }) @@ -63,9 +63,13 @@ end ---Fetch the remote branch ---@param remote_branch string The name of the repo and branch to fetch (e.g., "origin/some_branch") ----@return boolean fetch_successfull False if an error occurred while fetching, true otherwise. +---@return boolean fetch_successful False if an error occurred while fetching, true otherwise. M.fetch_remote_branch = function(remote_branch) - local remote, branch = string.match(remote_branch, "([^/]+)/(.*)") + local remote, branch = string.match(remote_branch, "([^/]+)/(.+)") + if not remote or not branch then + require("gitlab.utils").notify("Invalid remote branch format: " .. remote_branch, vim.log.levels.ERROR) + return false + end local _, fetch_err = run_system({ "git", "fetch", remote, branch }) if fetch_err ~= nil then require("gitlab.utils").notify("Error fetching remote-tracking branch: " .. fetch_err, vim.log.levels.ERROR) @@ -159,13 +163,13 @@ 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, otherwise false and warns user ----@param log_level integer +--- Returns true if `branch` is up-to-date on remote, otherwise false and warns user +---@param ahead integer|nil The number of commits the current branch is ahead of remote +---@param behind integer|nil The number of commits the current branch is behind remote +---@param remote_branch string|nil The remote branch, e.g., origin/feature-branch +---@param log_level number ---@return boolean -M.check_current_branch_up_to_date_on_remote = function(log_level) - local current_branch = M.get_current_branch() - local remote_branch = M.get_remote_branch() - local ahead, behind = M.get_ahead_behind(current_branch, remote_branch) +M.evaluate_ahead_behind = function(ahead, behind, remote_branch, log_level) if ahead == nil or behind == nil then return false end @@ -195,6 +199,19 @@ M.check_current_branch_up_to_date_on_remote = function(log_level) return true -- Checks passed, branch is up-to-date end +--- Returns true if `branch` is up-to-date on remote, otherwise false and notifies user. +--- This is a blocking function. For a non-blocking version use +--- gitlab.git_async.check_current_branch_up_to_date_on_remote. +---@param log_level number The log level with which user will be notified +---@return boolean +M.check_current_branch_up_to_date_on_remote = function(log_level) + local current_branch = M.get_current_branch() + local remote_branch = M.get_remote_branch() + local ahead, behind = M.get_ahead_behind(current_branch, remote_branch) + require("gitlab.state").ahead_behind = { ahead, behind } + return M.evaluate_ahead_behind(ahead, behind, remote_branch, log_level) +end + ---Warns user if the current MR is in a bad state (closed, has conflicts, merged) M.check_mr_in_good_condition = function() local state = require("gitlab.state") diff --git a/lua/gitlab/git_async.lua b/lua/gitlab/git_async.lua new file mode 100644 index 00000000..ba7bf675 --- /dev/null +++ b/lua/gitlab/git_async.lua @@ -0,0 +1,124 @@ +local M = {} + +---Function to run when an async system call finishes. Receives the command's stdout as result when +---successful, or the command's stderr as err when unsuccessful. +---@alias OnExitCallback fun(result:string|nil, err:string|nil) + +---Function to run on the result of getting the ahead and behind in the get_ahead_behind function. +---@alias GetAheadBehindCallback fun(ahead:integer|nil, behind:integer|nil, remote_branch:string|nil) + +---Runs a system command asynchronously +---@param command string[] +---@param on_exit OnExitCallback +local run_system_async = function(command, on_exit) + vim.system(command, { text = true }, function(result) + vim.schedule(function() + if result.code ~= 0 then + require("gitlab.utils").notify(result.stderr, vim.log.levels.ERROR) + on_exit(nil, result.stderr) + else + on_exit(vim.fn.trim(result.stdout), nil) + end + end) + end) +end + +---Pull a branch asynchronously from a remote and execute callback on exit. +---@param remote string The remote from which to pull. +---@param branch string The branch to pull. +---@param on_exit OnExitCallback The callback to execute when the command finishes. +---@param args? string[] Extra arguments passed to the `git pull` command. +M.pull = function(remote, branch, on_exit, args) + local current_branch = require("gitlab.git").get_current_branch() + if not current_branch then + return + end + if current_branch ~= branch then + on_exit(nil, "Cannot pull. Remote branch is not the same as current branch") + return + end + local cmd = { "git", "pull" } + vim.list_extend(cmd, args or {}) + vim.list_extend(cmd, { remote, branch }) + run_system_async(cmd, on_exit) +end + +---Fetch the remote branch +---@param remote_branch string The name of the repo and branch to fetch (e.g., "origin/some_branch") +---@param on_exit OnExitCallback +M.fetch_remote_branch = function(remote_branch, on_exit) + local remote, branch = string.match(remote_branch, "([^/]+)/(.+)") + if not remote or not branch then + on_exit(nil, "Invalid remote branch format: " .. remote_branch) + return + end + run_system_async({ "git", "fetch", remote, branch }, on_exit) +end + +--- Determines the number of commits the current branch is ahead of or behind the remote branch and +--- runs on_exit callback with the values. +---@param current_branch string|nil +---@param remote_branch string|nil +---@param on_exit GetAheadBehindCallback +M.get_ahead_behind = function(current_branch, remote_branch, on_exit) + if current_branch == nil or remote_branch == nil then + on_exit(nil, nil, remote_branch) + return + end + + ---Callback to run after the async `git fetch` call exits + ---@param result string|nil The stdout from the `git fetch` call if any. + ---@param err string|nil The stderr from the `git fetch` call if any. + local fetch_remote_branch_callback = function(result, err) + if result ~= nil then + local u = require("gitlab.utils") + run_system_async( + { "git", "rev-list", "--left-right", "--count", current_branch .. "..." .. remote_branch }, + --- The function to run after the async `git rev-list` call exits + ---@param r string|nil The stdout from the `git rev-list` call if any. + ---@param e string|nil The stderr from the `git rev-list` call if any. + function(r, e) + if e ~= nil or r == nil then + u.notify("Could not determine if branch is up-to-date: " .. (e or "unknown error"), vim.log.levels.ERROR) + on_exit(nil, nil, remote_branch) + return + end + local ahead, behind = r:match("(%d+)%s+(%d+)") + if ahead == nil or behind == nil then + u.notify("Error parsing ahead/behind information", vim.log.levels.ERROR) + on_exit(nil, nil, remote_branch) + return + end + on_exit(tonumber(ahead), tonumber(behind), remote_branch) + return + end + ) + elseif err ~= nil then + require("gitlab.utils").notify("Error fetching remote-tracking branch: " .. err, vim.log.levels.ERROR) + on_exit(nil, nil, remote_branch) + end + end + + M.fetch_remote_branch(remote_branch, fetch_remote_branch_callback) +end + +---Callback to run on the result of getting the ahead and behind in the get_ahead_behind function. +---@param ahead integer|nil The number of commits the current branch is ahead of remote +---@param behind integer|nil The number of commits the current branch is behind remote +---@param remote_branch string|nil The remote from which to pull. +local check_up_to_date_callback = function(ahead, behind, remote_branch) + require("gitlab.state").ahead_behind = { ahead, behind } + require("gitlab.git").evaluate_ahead_behind(ahead, behind, remote_branch, vim.log.levels.WARN) +end + +--- Evaluates if `branch` is up-to-date on remote and warns user. +--- This is a non-blocking function. For a blocking version that can be used to abort further +--- execution if branch is not up-to-date use gitlab.git.check_current_branch_up_to_date_on_remote. +M.check_current_branch_up_to_date_on_remote = function() + local git = require("gitlab.git") + local current_branch = git.get_current_branch() + local remote_branch = git.get_remote_branch() + M.get_ahead_behind(current_branch, remote_branch, check_up_to_date_callback) +end + +return M diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index ccdd9363..44abec68 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -1,5 +1,4 @@ local u = require("gitlab.utils") -local diffview_lib = require("diffview.lib") local indicators_common = require("gitlab.indicators.common") local actions_common = require("gitlab.actions.common") local List = require("gitlab.utils.list") @@ -89,7 +88,7 @@ end ---@param namespace number namespace for diagnostics ---@param bufnr number the bufnr for placing the diagnostics ---@param diagnostics table see :h vim.diagnostic.set ----@param opts table? see :h vim.diagnostic.set +---@param opts? table see :h vim.diagnostic.set local set_diagnostics = function(namespace, bufnr, diagnostics, opts) vim.diagnostic.set(namespace, bufnr, diagnostics, opts) require("gitlab.indicators.signs").set_signs(diagnostics, bufnr) @@ -102,7 +101,7 @@ M.refresh_diagnostics = function() M.clear_diagnostics() M.placeable_discussions = indicators_common.filter_placeable_discussions() - local view = diffview_lib.get_current_view() + local view = require("gitlab.reviewer").diffview if view == nil then u.notify("Could not find Diffview view", vim.log.levels.ERROR) return @@ -114,10 +113,13 @@ end ---Filter and place the diagnostics for the given buffer. ---@param bufnr number The number of the buffer for placing diagnostics. M.place_diagnostics = function(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end if not state.settings.discussion_signs.enabled then return end - local view = diffview_lib.get_current_view() + local view = require("gitlab.reviewer").diffview if view == nil then u.notify("Could not find Diffview view", vim.log.levels.ERROR) return diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index e788a518..c6f286ea 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_requests = require("gitlab.actions.merge_requests") local merge = require("gitlab.actions.merge") +local rebase = require("gitlab.actions.rebase") local summary = require("gitlab.actions.summary") local data = require("gitlab.actions.data") local assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers") @@ -29,8 +30,6 @@ 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 ---@param args Settings | {} | nil ---@return nil @@ -83,11 +82,15 @@ return { review = async.sequence({ u.merge(info, { refresh = true }), revisions, user }, function() reviewer.open() end), + reload_review = function() + reviewer.reload() + end, close_review = function() reviewer.close() end, pipeline = async.sequence({ latest_pipeline }, pipeline.open), merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge), + rebase = async.sequence({ u.merge(mergeability, { refresh = true }), info }, rebase.rebase), -- Discussion Tree Actions 🌴 toggle_discussions = function() if discussions.split_visible then @@ -96,8 +99,6 @@ return { async.sequence({ info, user, - u.merge(draft_notes_dep, { refresh = true }), - u.merge(discussion_data, { refresh = true }), }, discussions.open)() end end, diff --git a/lua/gitlab/popup.lua b/lua/gitlab/popup.lua index caa55f6d..1f95bf38 100644 --- a/lua/gitlab/popup.lua +++ b/lua/gitlab/popup.lua @@ -5,9 +5,9 @@ local M = {} ---Get the popup view_opts ---@param title string The string to appear on top of the popup ---@param user_settings table|nil User-defined popup settings ----@param width number? Override default width ----@param height number? Override default height ----@param zindex number? Override default zindex +---@param width? number Override default width +---@param height? number Override default height +---@param zindex? number Override default zindex ---@return table M.create_popup_state = function(title, user_settings, width, height, zindex) local settings = u.merge(require("gitlab.state").settings.popup, user_settings or {}) diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 76d24f32..dd9f8243 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -6,15 +6,13 @@ local List = require("gitlab.utils.list") local u = require("gitlab.utils") local state = require("gitlab.state") -local git = require("gitlab.git") local hunks = require("gitlab.hunks") local async = require("diffview.async") -local diffview_lib = require("diffview.lib") local M = { is_open = false, bufnr = nil, - tabnr = nil, + tabid = nil, stored_win = nil, buf_winids = {}, } @@ -29,7 +27,7 @@ M.init = function() end end --- Opens the reviewer window. +-- Opens the reviewer windows. M.open = function() local diff_refs = state.INFO.diff_refs if diff_refs == nil then @@ -42,6 +40,9 @@ M.open = function() return end + require("gitlab.git_async").check_current_branch_up_to_date_on_remote() + local git = require("gitlab.git") + local diffview_open_command = "DiffviewOpen" if state.settings.reviewer_settings.diffview.imply_local then @@ -52,10 +53,7 @@ M.open = function() if has_clean_tree then diffview_open_command = diffview_open_command .. " --imply-local" else - u.notify( - "Your working tree has changes, cannot use 'imply_local' setting for gitlab reviews.\n Stash or commit all changes to use.", - vim.log.levels.WARN - ) + u.notify("Working tree unclean. Stash or commit all changes to use 'imply_local'.", vim.log.levels.WARN) state.settings.reviewer_settings.diffview.imply_local = false end end @@ -63,9 +61,13 @@ M.open = function() vim.api.nvim_command(string.format("%s %s..%s", diffview_open_command, diff_refs.base_sha, diff_refs.head_sha)) M.is_open = true - local cur_view = diffview_lib.get_current_view() - M.diffview_layout = cur_view.cur_layout - M.tabnr = vim.api.nvim_get_current_tabpage() + M.diffview = require("diffview.lib").get_current_view() + if M.diffview == nil then + u.notify("Could not find Diffview view", vim.log.levels.ERROR) + return + end + M.diffview_layout = M.diffview.cur_layout + M.tabid = vim.api.nvim_get_current_tabpage() if state.settings.discussion_diagnostic ~= nil or state.settings.discussion_sign ~= nil then u.notify( @@ -76,13 +78,13 @@ M.open = function() -- 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 + if view.tabpage == M.tabid then + M.tabid = nil require("gitlab.actions.discussions.winbar").cleanup_timer() end end require("diffview.config").user_emitter:on("view_closed", function(_, args) - if M.tabnr == args.tabpage then + if M.tabid == args.tabpage then M.is_open = false on_diffview_closed(args) end @@ -94,17 +96,37 @@ M.open = function() require("gitlab").toggle_discussions() -- Fetches data and opens discussions end - git.check_current_branch_up_to_date_on_remote(vim.log.levels.WARN) git.check_mr_in_good_condition() end -- Closes the reviewer and cleans up M.close = function() - vim.cmd("DiffviewClose") + if M.tabid ~= nil and vim.api.nvim_tabpage_is_valid(M.tabid) then + vim.cmd.tabclose(vim.api.nvim_tabpage_get_number(M.tabid)) + end local discussions = require("gitlab.actions.discussions") discussions.close() end +---Loads new INFO state from Gitlab, then if diffview.api is available applies the new diff refs to +---the existing diffview, otherwise closes and re-opens the reviewer. +M.reload = function() + state.load_new_state("info", function() + state.load_new_state("revisions", function() + local has_api, api = pcall(require, "diffview.api") + if has_api then + api.set_revs( + string.format("%s..%s", state.INFO.diff_refs.base_sha, state.INFO.diff_refs.head_sha), + { view = M.diffview } + ) + else + M.close() + M.open() + end + end) + end) +end + --- Jumps to the location provided in the reviewer window ---@param file_name string The file name after change. ---@param old_file_name string The file name before change (different from file_name for renamed/moved files). @@ -114,18 +136,18 @@ M.jump = function(file_name, old_file_name, line_number, new_buffer) -- Draft comments don't have `old_file_name` set old_file_name = old_file_name or file_name - if M.tabnr == nil then + if M.tabid == 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) - local view = diffview_lib.get_current_view() - if view == nil then + vim.api.nvim_set_current_tabpage(M.tabid) + + if M.diffview == nil then u.notify("Could not find Diffview view", vim.log.levels.ERROR) return end - local files = view.panel:ordered_file_list() + local files = M.diffview.panel:ordered_file_list() local file = List.new(files):find(function(f) local oldpath = f.oldpath ~= nil and f.oldpath or f.path return new_buffer and f.path == file_name or oldpath == old_file_name @@ -137,9 +159,9 @@ M.jump = function(file_name, old_file_name, line_number, new_buffer) ) return end - async.await(view:set_file(file)) + async.await(M.diffview:set_file(file)) - local layout = view.cur_layout + local layout = M.diffview.cur_layout local number_of_lines if new_buffer then layout.b:focus() @@ -162,11 +184,10 @@ end ---@param current_win integer The ID of the currently focused window ---@return DiffviewInfo | nil M.get_reviewer_data = function(current_win) - local view = diffview_lib.get_current_view() - if view == nil then + if M.diffview == nil then return end - local layout = view.cur_layout + local layout = M.diffview.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) @@ -216,8 +237,7 @@ end ---@param current_win integer The ID of the currently focused window ---@return boolean M.is_new_sha_focused = function(current_win) - local view = diffview_lib.get_current_view() - local layout = view.cur_layout + local layout = M.diffview.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) if a_win ~= current_win and b_win ~= current_win then @@ -229,8 +249,7 @@ end ---Get currently shown file data M.get_current_file_data = function() - local view = diffview_lib.get_current_view() - return view and view.panel and view.panel.cur_file + return M.diffview and M.diffview.panel and M.diffview.panel.cur_file end ---Get currently shown file path @@ -269,7 +288,7 @@ M.set_callback_for_file_changed = function(callback) pattern = { "DiffviewDiffBufWinEnter" }, group = group, callback = function(...) - if M.tabnr == vim.api.nvim_get_current_tabpage() then + if M.tabid == vim.api.nvim_get_current_tabpage() then callback(...) end end, @@ -284,7 +303,7 @@ M.set_callback_for_buf_read = function(callback) pattern = { "DiffviewDiffBufRead" }, group = group, callback = function(...) - if vim.api.nvim_get_current_tabpage() == M.tabnr then + if vim.api.nvim_get_current_tabpage() == M.tabid then callback(...) end end, @@ -299,7 +318,7 @@ M.set_callback_for_reviewer_leave = function(callback) pattern = { "DiffviewViewLeave", "DiffviewViewClosed" }, group = group, callback = function(...) - if vim.api.nvim_get_current_tabpage() == M.tabnr then + if vim.api.nvim_get_current_tabpage() == M.tabid then callback(...) end end, @@ -315,7 +334,7 @@ M.set_callback_for_reviewer_enter = function(callback) pattern = { "DiffviewViewEnter", "DiffviewViewOpened" }, group = group, callback = function(...) - if vim.api.nvim_get_current_tabpage() == M.tabnr then + if vim.api.nvim_get_current_tabpage() == M.tabid then callback(...) end end, diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 7a570e8d..dd8766ca 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -86,9 +86,13 @@ M.settings = { revoke = "glR", merge = "glM", set_auto_merge = "glm", + rebase = "glrr", + rebase_skip_ci = "glrs", + rebase_force = "glrf", create_mr = "glC", choose_merge_request = "glc", start_review = "glS", + reload_review = "gl", summary = "gls", copy_mr_url = "glu", open_in_browser = "glo", @@ -163,6 +167,7 @@ M.settings = { }, spinner_chars = { "-", "\\", "|", "/" }, auto_open = true, + enter_on_open = true, default_view = "discussions", blacklist = {}, sort_by = "latest_reply", @@ -201,6 +206,10 @@ M.settings = { border = "rounded", }, }, + rebase_mr = { + skip_ci = false, + force = false, + }, choose_merge_request = { open_reviewer = true, }, @@ -329,6 +338,12 @@ M.set_global_keymaps = function() end, { desc = "Start Gitlab review", nowait = keymaps.global.start_review_nowait }) end + if keymaps.global.reload_review then + vim.keymap.set("n", keymaps.global.reload_review, function() + require("gitlab").reload_review() + end, { desc = "Reload Gitlab review", nowait = keymaps.global.reload_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() @@ -431,6 +446,24 @@ M.set_global_keymaps = function() end, { desc = "Set MR to auto-merge", nowait = keymaps.global.set_auto_merge_nowait }) end + if keymaps.global.rebase then + vim.keymap.set("n", keymaps.global.rebase, function() + require("gitlab").rebase() + end, { desc = "Rebase MR", nowait = keymaps.global.rebase_nowait }) + end + + if keymaps.global.rebase_skip_ci then + vim.keymap.set("n", keymaps.global.rebase_skip_ci, function() + require("gitlab").rebase({ skip_ci = true }) + end, { desc = "Rebase MR and skip CI", nowait = keymaps.global.rebase_skip_ci_nowait }) + end + + if keymaps.global.rebase_force then + vim.keymap.set("n", keymaps.global.rebase_force, function() + require("gitlab").rebase({ force = true }) + end, { desc = "Force rebase MR", nowait = keymaps.global.rebase_force_nowait }) + end + if keymaps.global.copy_mr_url then vim.keymap.set("n", keymaps.global.copy_mr_url, function() require("gitlab").copy_mr_url() diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 0b239630..e65648b0 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -510,15 +510,6 @@ M.get_root_path = function() return vim.fn.fnamemodify(path, ":p:h:h:h:h") end -local random = math.random -M.uuid = function() - local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" - return string.gsub(template, "[xy]", function(c) - local v = (c == "x") and random(0, 0xf) or random(8, 0xb) - return string.format("%x", v) - end) -end - M.remove_last_chunk = function(sentence) local words = {} for word in sentence:gmatch("%S+") do @@ -536,6 +527,9 @@ M.get_line_content = function(bufnr, start) end M.switch_can_edit_buf = function(buf, bool) + if not vim.api.nvim_buf_is_valid(buf) then + return + end vim.api.nvim_set_option_value("modifiable", bool, { buf = buf }) vim.api.nvim_set_option_value("readonly", not bool, { buf = buf }) end @@ -663,7 +657,7 @@ M.get_web_url = function() M.notify("Could not get Gitlab URL", vim.log.levels.ERROR) end ----@param url string? +---@param url? string M.open_in_browser = function(url) if vim.fn.has("mac") == 1 then vim.fn.jobstart({ "open", url })