From 64b36ac51de76b471325aa7b90c9349ad4e89bee Mon Sep 17 00:00:00 2001 From: "Harrison (Harry) Cramer" <32515581+harrisoncramer@users.noreply.github.com> Date: Sun, 17 Dec 2023 14:28:21 -0500 Subject: [PATCH] Feat: Adds Ability to Merge MR (#147) This adds the ability to merge an MR from within `gitlab.nvim` directly. If the reviewer is open, it'll be closed automatically. Users may configure whether they'd like to squash commits on the merge, as well as whether they'd like to delete the original source branch on a merge. If squashing, users are prompted to provide an optional custom squash message for the squash commit. --- README.md | 20 +++++++++ cmd/merge.go | 71 ++++++++++++++++++++++++++++++++ cmd/merge_test.go | 52 +++++++++++++++++++++++ cmd/server.go | 1 + cmd/test.go | 5 +++ cmd/types.go | 1 + lua/gitlab/actions/merge.lua | 55 +++++++++++++++++++++++++ lua/gitlab/actions/summary.lua | 8 +++- lua/gitlab/init.lua | 7 +++- lua/gitlab/reviewer/diffview.lua | 6 +++ lua/gitlab/reviewer/init.lua | 3 ++ lua/gitlab/state.lua | 5 +++ 12 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 cmd/merge.go create mode 100644 cmd/merge_test.go create mode 100644 lua/gitlab/actions/merge.lua 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,