diff --git a/README.md b/README.md index 12ca0cb7..074f462e 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 @@ -140,9 +140,13 @@ glrd Delete reviewer glA Approve MR glR Revoke MR approval glM Merge the feature branch to the target branch and close MR +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 1040deff..2f586d96 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 @@ -179,9 +179,13 @@ you call this function with no values the defaults will be used: approve = "glA", -- Approve MR revoke = "glR", -- Revoke MR approval merge = "glM", -- Merge the feature branch to the target branch and close MR + 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 @@ -608,6 +612,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 @@ -832,6 +849,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() ~ @@ -1078,6 +1114,23 @@ Gitlab online. 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/rebase.lua b/lua/gitlab/actions/rebase.lua new file mode 100644 index 00000000..e84507eb --- /dev/null +++ b/lua/gitlab/actions/rebase.lua @@ -0,0 +1,94 @@ +-- 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) + if already_rebased and not opts.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" + end) + if has_conflicts then + u.notify("Rebase locally, resolve all conflicts, then push the branch", vim.log.levels.ERROR) + 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").pull_async( + 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/annotations.lua b/lua/gitlab/annotations.lua index a3fcb3ac..4bf6f4a2 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 DiscussionSigns: table @@ -199,6 +200,9 @@ ---@field title_input? TitleInputSettings ---@field fork? ForkSettings +---@class RebaseMrSettings: table +---@field skip_ci? boolean -- Whether to skip CI after rabasing + ---@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. @@ -364,6 +368,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..90ac018c 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -14,6 +14,26 @@ local run_system = function(command) return result, nil end +---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) + +---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 + ---Returns all branches for the current repository ---@param args table|nil extra arguments for `git branch` ---@return string|nil, string|nil @@ -23,7 +43,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,7 +83,7 @@ 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 _, fetch_err = run_system({ "git", "fetch", remote, branch }) @@ -104,6 +124,26 @@ M.get_ahead_behind = function(current_branch, remote_branch) return tonumber(ahead), tonumber(behind) 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_async = function(remote, branch, on_exit, args) + local current_branch = M.get_current_branch() + if not current_branch then + return + end + if current_branch ~= branch then + require("gitlab.utils").notify("Cannot pull. Remote branch is not the same as current branch", vim.log.levels.ERROR) + 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 + ---Return the name of the current branch or nil if it can't be retrieved ---@return string|nil M.get_current_branch = function() diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index ccdd9363..02ec9baa 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") @@ -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 @@ -117,7 +116,7 @@ M.place_diagnostics = function(bufnr) 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 da066e6b..1d35b4fe 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") @@ -83,11 +84,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 diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 76d24f32..9f7b398b 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 + local git = require("gitlab.git") + git.check_current_branch_up_to_date_on_remote(vim.log.levels.WARN) + 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 5db54a11..560fc7dd 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -82,9 +82,13 @@ M.settings = { approve = "glA", revoke = "glR", 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", @@ -197,6 +201,9 @@ M.settings = { border = "rounded", }, }, + rebase_mr = { + skip_ci = false, + }, choose_merge_request = { open_reviewer = true, }, @@ -323,6 +330,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() @@ -419,6 +432,24 @@ M.set_global_keymaps = function() end, { desc = "Merge MR", nowait = keymaps.global.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()