Skip to content
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<C-R> 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
Expand Down
80 changes: 80 additions & 0 deletions cmd/app/rebase_mr.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
110 changes: 110 additions & 0 deletions cmd/app/rebase_mr_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
6 changes: 6 additions & 0 deletions cmd/app/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
55 changes: 54 additions & 1 deletion doc/gitlab.nvim.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<C-R>", -- Load new MR state from Gitlab and apply new diff refs to the diff view
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering whether it would make sense to connect this to the refresh of the discussion tree instead of making it a separate function and keybinding.

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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
<
Comment on lines +865 to 871
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an existing API that was just missing documentation.

*gitlab.nvim.summary*
gitlab.summary() ~
Expand Down Expand Up @@ -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}) ~

Expand Down
6 changes: 3 additions & 3 deletions lua/gitlab/actions/comment.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading