diff --git a/README.md b/README.md index 196e359..c17f4f8 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ https://github.com/harrisoncramer/gitlab.nvim/assets/32515581/dc5c07de-4ae6-4335 - [Usage](#usage) - [The summary command](#summary) - [Reviewing Diffs](#reviewing-diffs) + - [Merging](#merging-an-mr) - [Discussions and Notes](#discussions-and-notes) - [Discussion signs and diagnostics](#discussion-signs-and-diagnostics) - [Uploading Files](#uploading-files) @@ -131,6 +132,7 @@ require("gitlab").setup({ note = nil, pipeline = nil, reply = nil, + squash_message = nil, }, discussion_tree = { -- The discussion tree that holds all comments auto_open = true, -- Automatically open when the reviewer is opened @@ -210,6 +212,10 @@ require("gitlab").setup({ success = "✓", failed = "", }, + merge = { -- The default behaviors when merging an MR, see "Merging an MR" + squash = false, + delete_branch = false, + }, colors = { discussion_tree = { username = "Keyword", @@ -258,6 +264,19 @@ require("gitlab").create_comment_suggestion() For suggesting changes you can use `create_comment_suggestion` in visual mode which works similar to `create_multiline_comment` but prefills the comment window with Gitlab's [suggest changes](https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html) code block with prefilled code from the visual selection. +### Merging an MR + +The `merge` action will merge an MR. The MR must be in a "mergeable" state for this command to work. + +```lua +require("gitlab").merge() +require("gitlab").merge({ squash = false, delete_branch = false }) +``` + +You can configure default behaviors via the setup function, values passed into this function will override the defaults. + +If you enable `squash` you will be prompted for a squash message. To use the default message, leave the popup empty. Use the `settings.popup.perform_action` to merge the MR with your message. + ### Discussions and Notes Gitlab groups threads of comments together into "discussions." @@ -391,6 +410,7 @@ vim.keymap.set("n", "glra", gitlab.add_reviewer) vim.keymap.set("n", "glrd", gitlab.delete_reviewer) vim.keymap.set("n", "glp", gitlab.pipeline) vim.keymap.set("n", "glo", gitlab.open_in_browser) +vim.keymap.set("n", "glM", gitlab.merge) ``` ## Troubleshooting diff --git a/cmd/merge.go b/cmd/merge.go new file mode 100644 index 0000000..d8fcb16 --- /dev/null +++ b/cmd/merge.go @@ -0,0 +1,71 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/xanzy/go-gitlab" +) + +type AcceptMergeRequestRequest struct { + Squash bool `json:"squash"` + SquashMessage string `json:"squash_message"` + DeleteBranch bool `json:"delete_branch"` +} + +/* acceptAndMergeHandler merges a given merge request into the target branch */ +func (a *api) acceptAndMergeHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Methods", http.MethodGet) + if r.Method != http.MethodPost { + handleError(w, InvalidRequestError{}, "Expected POST", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + handleError(w, err, "Could not read request body", http.StatusBadRequest) + return + } + + var acceptAndMergeRequest AcceptMergeRequestRequest + err = json.Unmarshal(body, &acceptAndMergeRequest) + if err != nil { + handleError(w, err, "Could not unmarshal request body", http.StatusBadRequest) + return + } + + opts := gitlab.AcceptMergeRequestOptions{ + Squash: &acceptAndMergeRequest.Squash, + ShouldRemoveSourceBranch: &acceptAndMergeRequest.DeleteBranch, + } + + if acceptAndMergeRequest.SquashMessage != "" { + opts.SquashCommitMessage = &acceptAndMergeRequest.SquashMessage + } + + _, res, err := a.client.AcceptMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opts) + + if err != nil { + handleError(w, err, "Could not merge MR", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{endpoint: "/merge"}, "Could not merge MR", res.StatusCode) + return + } + + response := SuccessResponse{ + Status: http.StatusOK, + Message: "MR merged successfully", + } + + 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/merge_test.go b/cmd/merge_test.go new file mode 100644 index 0000000..dbc3ef4 --- /dev/null +++ b/cmd/merge_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + "net/http" + "testing" + + "github.com/xanzy/go-gitlab" +) + +func acceptAndMergeFn(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + return &gitlab.MergeRequest{}, makeResponse(http.StatusOK), nil +} + +func acceptAndMergeFnErr(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + return nil, nil, errors.New("Some error from Gitlab") +} + +func acceptAndMergeNon200(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + return nil, makeResponse(http.StatusSeeOther), nil +} + +func TestAcceptAndMergeHandler(t *testing.T) { + t.Run("Accepts and merges a merge request", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/merge", AcceptMergeRequestRequest{}) + server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn}) + data := serveRequest(t, server, request, SuccessResponse{}) + assert(t, data.Message, "MR merged successfully") + assert(t, data.Status, http.StatusOK) + }) + + t.Run("Disallows non-POST methods", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/merge", AcceptMergeRequestRequest{}) + server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFn}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkBadMethod(t, *data, http.MethodPost) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/merge", AcceptMergeRequestRequest{}) + server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeFnErr}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkErrorFromGitlab(t, *data, "Could not merge MR") + }) + + t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/merge", AcceptMergeRequestRequest{}) + server, _ := createRouterAndApi(fakeClient{acceptAndMergeFn: acceptAndMergeNon200}) + data := serveRequest(t, server, request, ErrorResponse{}) + checkNon200(t, *data, "Could not merge MR", "/merge") + }) +} diff --git a/cmd/server.go b/cmd/server.go index c5b3b82..2490c18 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -105,6 +105,7 @@ func createRouterAndApi(client ClientInterface, optFuncs ...optFunc) (*http.Serv m.HandleFunc("/shutdown", a.shutdownHandler) m.HandleFunc("/approve", a.approveHandler) m.HandleFunc("/comment", a.commentHandler) + m.HandleFunc("/merge", a.acceptAndMergeHandler) m.HandleFunc("/discussions/list", a.listDiscussionsHandler) m.HandleFunc("/discussions/resolve", a.discussionsResolveHandler) m.HandleFunc("/info", a.infoHandler) diff --git a/cmd/test.go b/cmd/test.go index bb924a6..10716db 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -20,6 +20,7 @@ The FakeHandlerClient is used to create a fake gitlab client for testing our han type fakeClient struct { getMergeRequestFn func(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) updateMergeRequestFn func(pid interface{}, mergeRequest int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) + acceptAndMergeFn func(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) unapprorveMergeRequestFn func(pid interface{}, mr int, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) uploadFile func(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) getMergeRequestDiffVersions func(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) @@ -46,6 +47,10 @@ type Author struct { WebURL string `json:"web_url"` } +func (f fakeClient) AcceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + return f.acceptAndMergeFn(pid, mergeRequest, opt, options...) +} + func (f fakeClient) GetMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { return f.getMergeRequestFn(pid, mergeRequest, opt, options...) } diff --git a/cmd/types.go b/cmd/types.go index 50ea17d..d085662 100644 --- a/cmd/types.go +++ b/cmd/types.go @@ -36,6 +36,7 @@ func (e InvalidRequestError) Error() string { /* The ClientInterface interface implements all the methods that our handlers need */ type ClientInterface interface { GetMergeRequest(pid interface{}, mr int, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) + AcceptMergeRequest(pid interface{}, mergeRequest int, opt *gitlab.AcceptMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) UpdateMergeRequest(pid interface{}, mr int, opt *gitlab.UpdateMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) UploadFile(pid interface{}, content io.Reader, filename string, options ...gitlab.RequestOptionFunc) (*gitlab.ProjectFile, *gitlab.Response, error) GetMergeRequestDiffVersions(pid interface{}, mr int, opt *gitlab.GetMergeRequestDiffVersionsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.MergeRequestDiffVersion, *gitlab.Response, error) diff --git a/lua/gitlab/actions/merge.lua b/lua/gitlab/actions/merge.lua new file mode 100644 index 0000000..9ba225c --- /dev/null +++ b/lua/gitlab/actions/merge.lua @@ -0,0 +1,55 @@ +local u = require("gitlab.utils") +local Popup = require("nui.popup") +local state = require("gitlab.state") +local job = require("gitlab.job") +local reviewer = require("gitlab.reviewer") + +local M = {} + +local function create_squash_message_popup() + return Popup(u.create_popup_state("Squash Commit Message", state.settings.popup.squash_message)) +end + +---@class MergeOpts +---@field delete_branch boolean? +---@field squash boolean? +---@field squash_message string? + +---@param opts MergeOpts +M.merge = function(opts) + local merge_body = { squash = state.settings.merge.squash, delete_branch = state.settings.merge.delete_branch } + if opts then + merge_body.squash = opts.squash ~= nil and opts.squash + merge_body.delete_branch = opts.delete_branch ~= nil and opts.delete_branch + end + + if state.INFO.detailed_merge_status ~= "mergeable" then + u.notify(string.format("MR not mergeable, currently '%s'", state.INFO.detailed_merge_status), vim.log.levels.ERROR) + return + end + + if merge_body.squash then + local squash_message_popup = create_squash_message_popup() + squash_message_popup:mount() + state.set_popup_keymaps(squash_message_popup, function(text) + M.confirm_merge(merge_body, text) + end) + else + M.confirm_merge(merge_body) + end +end + +---@param merge_body MergeOpts +---@param squash_message string? +M.confirm_merge = function(merge_body, squash_message) + if squash_message ~= nil then + merge_body.squash_message = squash_message + end + + job.run_job("/merge", "POST", merge_body, function(data) + reviewer.close() + u.notify(data.message, vim.log.levels.INFO) + end) +end + +return M diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index a1a4b7a..1cf5ac2 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -122,7 +122,7 @@ M.build_info_lines = function() author = { title = "Author", content = "@" .. info.author.username .. " (" .. info.author.name .. ")" }, created_at = { title = "Created", content = u.format_to_local(info.created_at, vim.fn.strftime("%z")) }, updated_at = { title = "Updated", content = u.format_to_local(info.updated_at, vim.fn.strftime("%z")) }, - merge_status = { title = "Status", content = info.detailed_merge_status }, + detailed_merge_status = { title = "Status", content = info.detailed_merge_status }, draft = { title = "Draft", content = (info.draft and "Yes" or "No") }, conflicts = { title = "Merge Conflicts", content = (info.has_conflicts and "Yes" or "No") }, assignees = { title = "Assignees", content = u.make_readable_list(info.assignees, "name") }, @@ -138,6 +138,9 @@ M.build_info_lines = function() local longest_used = "" for _, v in ipairs(state.settings.info.fields) do + if v == "merge_status" then + v = "detailed_merge_status" + end -- merge_status was deprecated, see https://gitlab.com/gitlab-org/gitlab/-/issues/3169#note_1162532204 local title = options[v].title if string.len(title) > string.len(longest_used) then longest_used = title @@ -151,6 +154,9 @@ M.build_info_lines = function() local lines = {} for _, v in ipairs(state.settings.info.fields) do + if v == "merge_status" then + v = "detailed_merge_status" + end local row = options[v] local line = "* " .. row.title .. row_offset(row.title) if type(row.content) == "function" then diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 971aded..8661bd0 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -4,6 +4,7 @@ local server = require("gitlab.server") local state = require("gitlab.state") local reviewer = require("gitlab.reviewer") local discussions = require("gitlab.actions.discussions") +local merge = require("gitlab.actions.merge") local summary = require("gitlab.actions.summary") local assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers") local comment = require("gitlab.actions.comment") @@ -27,7 +28,7 @@ return { discussions.initialize_discussions() -- place signs / diagnostics for discussions in reviewer end, -- Global Actions 🌎 - summary = async.sequence({ info }, summary.summary), + summary = async.sequence({ u.merge(info, { refresh = true }) }, summary.summary), approve = async.sequence({ info }, approvals.approve), revoke = async.sequence({ info }, approvals.revoke), add_reviewer = async.sequence({ info, project_members }, assignees_and_reviewers.add_reviewer), @@ -42,7 +43,11 @@ return { review = async.sequence({ u.merge(info, { refresh = true }), revisions }, function() reviewer.open() end), + close_review = function() + reviewer.close() + end, pipeline = async.sequence({ info }, pipeline.open), + merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge), -- Discussion Tree Actions 🌴 toggle_discussions = async.sequence({ info }, discussions.toggle), edit_comment = async.sequence({ info }, discussions.edit_comment), diff --git a/lua/gitlab/reviewer/diffview.lua b/lua/gitlab/reviewer/diffview.lua index a638f73..2b4d362 100644 --- a/lua/gitlab/reviewer/diffview.lua +++ b/lua/gitlab/reviewer/diffview.lua @@ -47,6 +47,12 @@ M.open = function() end end +M.close = function() + vim.cmd("DiffviewClose") + local discussions = require("gitlab.actions.discussions") + discussions.close() +end + M.jump = function(file_name, new_line, old_line) if M.tabnr == nil then u.notify("Can't jump to Diffvew. Is it open?", vim.log.levels.ERROR) diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index e1fc4cd..297ef2f 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -22,6 +22,9 @@ M.init = function() M.open = reviewer.open -- Opens the reviewer window + M.close = reviewer.close + -- Closes the reviewer and cleans up + M.jump = reviewer.jump -- Jumps to the location provided in the reviewer window -- Parameters: diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 85746c5..f3ab62c 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -29,6 +29,7 @@ M.settings = { note = nil, help = nil, pipeline = nil, + squash_message = nil, }, discussion_tree = { auto_open = true, @@ -66,6 +67,10 @@ M.settings = { return " " .. discussions_content .. " %#Comment#| " .. notes_content end, }, + merge = { + squash = false, + delete_branch = false, + }, info = { enabled = true, horizontal = false,