diff --git a/README.md b/README.md index 12ca0cb7..67af9a41 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,8 @@ 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 and pull the new state +glrs Same as `glrr`, but skip the CI pipeline 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 diff --git a/cmd/app/rebase_mr.go b/cmd/app/rebase_mr.go new file mode 100644 index 00000000..8bc164e3 --- /dev/null +++ b/cmd/app/rebase_mr.go @@ -0,0 +1,57 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http" + + 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 mergeRequestRebaserService struct { + data + client MergeRequestRebaser +} + +/* 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 + } + + skippingCI := "" + if payload.SkipCI { + skippingCI = " (skipping CI)" + } + response := SuccessResponse{Message: fmt.Sprintf("MR rebased successfully%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..9d14fc3f --- /dev/null +++ b/cmd/app/rebase_mr_test.go @@ -0,0 +1,78 @@ +package app + +import ( + "net/http" + "testing" + + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +type fakeMergeRequestRebaser struct { + testBase +} + +func (f fakeMergeRequestRebaser) 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 TestRebaseHandler(t *testing.T) { + var testRebaseMrPayload = RebaseMrRequest{SkipCI: false} + t.Run("Rebases merge request", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload) + svc := middleware( + mergeRequestRebaserService{testProjectData, fakeMergeRequestRebaser{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: newPayload[RebaseMrRequest], + }), + withMethodCheck(http.MethodPost), + ) + data := getSuccessData(t, svc, request) + assert(t, data.Message, "MR rebased successfully") + }) + 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) + svc := middleware( + mergeRequestRebaserService{testProjectData, fakeMergeRequestRebaser{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: newPayload[RebaseMrRequest], + }), + withMethodCheck(http.MethodPost), + ) + data := getSuccessData(t, svc, request) + assert(t, data.Message, "MR rebased successfully (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, fakeMergeRequestRebaser{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, fakeMergeRequestRebaser{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 5fff24ed..69bf5131 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 2db9f633..b6b35868 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -179,6 +179,8 @@ 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 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 @@ -1045,6 +1047,20 @@ 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 }) +< + Parameters: ~ + • {opts}: (table|nil) Keyword arguments that can be used to override + default behavior. + • {skip_ci}: (bool) If true, the CI pipeline will be skipped. + *gitlab.nvim.data* gitlab.data({resources}, {cb}) ~ diff --git a/lua/gitlab/actions/rebase.lua b/lua/gitlab/actions/rebase.lua new file mode 100644 index 00000000..fe9d3cd4 --- /dev/null +++ b/lua/gitlab/actions/rebase.lua @@ -0,0 +1,60 @@ +local u = require("gitlab.utils") + +local M = {} + +---@class RebaseOpts +---@field skip_ci boolean? + +local can_rebase = function() + local git = require("gitlab.git") + -- Check if there are local changes (we wouldn't be able to run `git pull` after rebasing) + local has_clean_tree, err = 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 + return true +end + +---@param opts RebaseOpts +M.rebase = function(opts) + if not can_rebase() then + return + end + + -- TODO: check that MR needs rebasing (requires https://github.com/harrisoncramer/gitlab.nvim/pull/532) + + local state = require("gitlab.state") + local rebase_body = { skip_ci = state.settings.rebase_mr.skip_ci } + if opts and opts.skip_ci ~= nil then + rebase_body.skip_ci = opts.skip_ci + end + + M.confirm_rebase(rebase_body) +end + +---@param merge_body RebaseOpts +M.confirm_rebase = function(merge_body) + local job = require("gitlab.job") + job.run_job("/mr/rebase", "POST", merge_body, function(data) + u.notify(data.message, vim.log.levels.INFO) + local git = require("gitlab.git") + local state = require("gitlab.state") + local success = git.pull(state.settings.connection_settings.remote, state.INFO.source_branch, { "--rebase" }) + if success then + u.notify( + string.format( + "Pulled `%s %s` successfully", + state.settings.connection_settings.remote, + state.INFO.source_branch + ), + vim.log.levels.INFO + ) + end + end) +end + +return M diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index e1403249..93e553e0 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -162,6 +162,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 @@ -196,6 +197,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. diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index ba42546e..5a7394c6 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -124,6 +124,34 @@ M.get_ahead_behind = function(current_branch, remote_branch, log_level) return true -- Checks passed, branch is up-to-date end +---Pull the branch from remote +---@param remote string +---@param branch string +---@param opts string[]? +---@return boolean success True if the branch has been pulled successfully +M.pull = function(remote, branch, opts) + local current_branch = M.get_current_branch() + if not current_branch then + return false + end + if current_branch ~= branch then + local u = require("gitlab.utils") + u.notify("Cannot pull. Remote branch is not the same as current branch", vim.log.levels.ERROR) + return false + end + local args = { "git", "pull" } + for _, opt in ipairs(opts or {}) do + table.insert(args, opt) + end + table.insert(args, remote) + table.insert(args, branch) + local _, err = run_system(args) + if err ~= nil then + return false + end + return true +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/init.lua b/lua/gitlab/init.lua index 8c52c73b..099561e9 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") @@ -86,6 +87,7 @@ return { end, pipeline = async.sequence({ latest_pipeline }, pipeline.open), merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge), + rebase = async.sequence({ u.merge(info, { refresh = true }) }, 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..cec65321 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -6,7 +6,6 @@ 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") @@ -29,18 +28,16 @@ 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 - u.notify("Gitlab did not provide diff refs required to review this MR", vim.log.levels.ERROR) - return - end + local git = require("gitlab.git") - if diff_refs.base_sha == "" or diff_refs.head_sha == "" then - u.notify("Merge request contains no changes", vim.log.levels.ERROR) + local remote_target_branch = + string.format("%s/%s", state.settings.connection_settings.remote, state.INFO.target_branch) + if not git.fetch_remote_branch(remote_target_branch) then return end + git.check_current_branch_up_to_date_on_remote(vim.log.levels.WARN) local diffview_open_command = "DiffviewOpen" @@ -53,17 +50,22 @@ M.open = function() 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.", + "Working tree unclean, cannot use 'imply_local' for review. Stash or commit all changes to use.", vim.log.levels.WARN ) state.settings.reviewer_settings.diffview.imply_local = false end end - vim.api.nvim_command(string.format("%s %s..%s", diffview_open_command, diff_refs.base_sha, diff_refs.head_sha)) + local full_command = string.format("%s %s..%s", diffview_open_command, remote_target_branch, state.INFO.source_branch) + vim.api.nvim_command(full_command) M.is_open = true local cur_view = diffview_lib.get_current_view() + if cur_view == nil then + u.notify("Could not find Diffview view", vim.log.levels.ERROR) + return + end M.diffview_layout = cur_view.cur_layout M.tabnr = vim.api.nvim_get_current_tabpage() @@ -94,7 +96,6 @@ 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 diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index d67e0c06..b4cfcb76 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -81,6 +81,8 @@ M.settings = { approve = "glA", revoke = "glR", merge = "glM", + rebase = "glrr", + rebase_skip_ci = "glrs", create_mr = "glC", choose_merge_request = "glc", start_review = "glS", @@ -196,6 +198,9 @@ M.settings = { border = "rounded", }, }, + rebase_mr = { + skip_ci = false, + }, choose_merge_request = { open_reviewer = true, }, @@ -389,6 +394,18 @@ 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.copy_mr_url then vim.keymap.set("n", keymaps.global.copy_mr_url, function() require("gitlab").copy_mr_url()