Skip to content

Commit 291636a

Browse files
committed
feat: implement rebasing
- Rebases the MR on the server. - Pulls the remote branch after rebasing. - Aborts rebase if it is not necessary, but enables forcing a rebase. - Aborts rebase when there are conflicts. - Adds keymaps for common rebase variants, and for reloading reviewer. - Checks if branch is up-to-date asynchronously.
1 parent 9c9fc89 commit 291636a

16 files changed

Lines changed: 623 additions & 54 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This Neovim plugin is designed to make it easy to review Gitlab MRs from within the editor. This means you can do things like:
44

5-
- Create, approve, and merge MRs for the current branch
5+
- Create, approve, rebase, and merge MRs for the current branch
66
- Read and edit an MR description
77
- Add or remove reviewers and assignees
88
- Resolve, reply to, and unresolve discussion threads
@@ -143,9 +143,13 @@ glA Approve MR
143143
glR Revoke MR approval
144144
glM Merge the feature branch to the target branch and close MR
145145
glm Set MR to merge automatically when the pipeline succeeds
146+
glrr Rebase the feature branch of the MR on the server (if not already rebased) and pull the new state
147+
glrs Same as `glrr`, but skip the CI pipeline
148+
glrf Same as `glrr`, but rebase even if MR already is rebased
146149
glC Create a new MR for currently checked-out feature branch
147150
glc Chose MR for review
148151
glS Start review for the currently checked-out branch
152+
gl<C-R> Load new MR state from Gitlab and apply new diff refs to the diff view
149153
gls Show the editable summary of the MR
150154
glu Copy the URL of the MR to the system clipboard
151155
glo Open the URL of the MR in the default Internet browser

cmd/app/rebase_mr.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package app
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"time"
8+
9+
gitlab "gitlab.com/gitlab-org/api/client-go"
10+
)
11+
12+
type RebaseMrRequest struct {
13+
SkipCI bool `json:"skip_ci,omitempty"`
14+
}
15+
16+
type MergeRequestRebaser interface {
17+
RebaseMergeRequest(pid interface{}, mergeRequest int64, opt *gitlab.RebaseMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error)
18+
}
19+
20+
type MergeRequestRebaserClient interface {
21+
MergeRequestRebaser
22+
MergeRequestGetter
23+
}
24+
25+
type mergeRequestRebaserService struct {
26+
data
27+
client MergeRequestRebaserClient
28+
}
29+
30+
/* Rebases a merge request on the server */
31+
func (a mergeRequestRebaserService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
32+
payload := r.Context().Value(payload("payload")).(*RebaseMrRequest)
33+
34+
opts := gitlab.RebaseMergeRequestOptions{
35+
SkipCI: &payload.SkipCI,
36+
}
37+
38+
res, err := a.client.RebaseMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opts)
39+
if err != nil {
40+
handleError(w, err, "Could not rebase MR", http.StatusInternalServerError)
41+
return
42+
}
43+
44+
if res.StatusCode >= 300 {
45+
handleError(w, GenericError{r.URL.Path}, "Could not rebase MR", res.StatusCode)
46+
return
47+
}
48+
49+
// Poll until rebase completes (GitLab rebase is async)
50+
for {
51+
mr, _, getMrErr := a.client.GetMergeRequest(
52+
a.projectInfo.ProjectId,
53+
a.projectInfo.MergeId,
54+
&gitlab.GetMergeRequestsOptions{
55+
IncludeRebaseInProgress: gitlab.Ptr(true),
56+
},
57+
)
58+
if getMrErr != nil {
59+
handleError(w, getMrErr, "Could not check rebase status", http.StatusInternalServerError)
60+
return
61+
}
62+
if !mr.RebaseInProgress {
63+
break
64+
}
65+
time.Sleep(1 * time.Second)
66+
}
67+
68+
skippingCI := ""
69+
if payload.SkipCI {
70+
skippingCI = " (skipping CI)"
71+
}
72+
response := SuccessResponse{Message: fmt.Sprintf("MR rebased on server%s", skippingCI)}
73+
74+
w.WriteHeader(http.StatusOK)
75+
76+
err = json.NewEncoder(w).Encode(response)
77+
if err != nil {
78+
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
79+
}
80+
}

cmd/app/rebase_mr_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package app
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
gitlab "gitlab.com/gitlab-org/api/client-go"
8+
)
9+
10+
type fakeMergeRequestRebaserClient struct {
11+
testBase
12+
rebaseInProgressCount int // number of times to return RebaseInProgress: true
13+
getMergeRequestCalls int // tracks how many times GetMergeRequest was called
14+
}
15+
16+
func (f *fakeMergeRequestRebaserClient) RebaseMergeRequest(pid interface{}, mergeRequest int64, opt *gitlab.RebaseMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
17+
resp, err := f.handleGitlabError()
18+
if err != nil {
19+
return nil, err
20+
}
21+
22+
return resp, err
23+
}
24+
25+
func (f *fakeMergeRequestRebaserClient) GetMergeRequest(pid interface{}, mergeRequest int64, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) {
26+
resp, err := f.handleGitlabError()
27+
if err != nil {
28+
return nil, nil, err
29+
}
30+
31+
f.getMergeRequestCalls++
32+
rebaseInProgress := f.getMergeRequestCalls <= f.rebaseInProgressCount
33+
34+
return &gitlab.MergeRequest{RebaseInProgress: rebaseInProgress}, resp, err
35+
}
36+
37+
func TestRebaseHandler(t *testing.T) {
38+
var testRebaseMrPayload = RebaseMrRequest{SkipCI: false}
39+
t.Run("Rebases merge request when rebase completes immediately", func(t *testing.T) {
40+
request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload)
41+
fakeClient := &fakeMergeRequestRebaserClient{rebaseInProgressCount: 0}
42+
svc := middleware(
43+
mergeRequestRebaserService{testProjectData, fakeClient},
44+
withMr(testProjectData, fakeMergeRequestLister{}),
45+
withPayloadValidation(methodToPayload{
46+
http.MethodPost: newPayload[RebaseMrRequest],
47+
}),
48+
withMethodCheck(http.MethodPost),
49+
)
50+
data := getSuccessData(t, svc, request)
51+
assert(t, data.Message, "MR rebased on server")
52+
assert(t, fakeClient.getMergeRequestCalls, 1)
53+
})
54+
t.Run("Rebases merge request and polls until rebase completes", func(t *testing.T) {
55+
request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload)
56+
fakeClient := &fakeMergeRequestRebaserClient{rebaseInProgressCount: 1}
57+
svc := middleware(
58+
mergeRequestRebaserService{testProjectData, fakeClient},
59+
withMr(testProjectData, fakeMergeRequestLister{}),
60+
withPayloadValidation(methodToPayload{
61+
http.MethodPost: newPayload[RebaseMrRequest],
62+
}),
63+
withMethodCheck(http.MethodPost),
64+
)
65+
data := getSuccessData(t, svc, request)
66+
assert(t, data.Message, "MR rebased on server")
67+
assert(t, fakeClient.getMergeRequestCalls, 2)
68+
})
69+
var testRebaseMrPayloadSkipCI = RebaseMrRequest{SkipCI: true}
70+
t.Run("Rebases merge request and skips CI", func(t *testing.T) {
71+
request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayloadSkipCI)
72+
fakeClient := &fakeMergeRequestRebaserClient{}
73+
svc := middleware(
74+
mergeRequestRebaserService{testProjectData, fakeClient},
75+
withMr(testProjectData, fakeMergeRequestLister{}),
76+
withPayloadValidation(methodToPayload{
77+
http.MethodPost: newPayload[RebaseMrRequest],
78+
}),
79+
withMethodCheck(http.MethodPost),
80+
)
81+
data := getSuccessData(t, svc, request)
82+
assert(t, data.Message, "MR rebased on server (skipping CI)")
83+
})
84+
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
85+
request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload)
86+
svc := middleware(
87+
mergeRequestRebaserService{testProjectData, &fakeMergeRequestRebaserClient{testBase: testBase{errFromGitlab: true}}},
88+
withMr(testProjectData, fakeMergeRequestLister{}),
89+
withPayloadValidation(methodToPayload{
90+
http.MethodPost: newPayload[RebaseMrRequest],
91+
}),
92+
withMethodCheck(http.MethodPost),
93+
)
94+
data, _ := getFailData(t, svc, request)
95+
checkErrorFromGitlab(t, data, "Could not rebase MR")
96+
})
97+
t.Run("Handles non-200s from Gitlab", func(t *testing.T) {
98+
request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload)
99+
svc := middleware(
100+
mergeRequestRebaserService{testProjectData, &fakeMergeRequestRebaserClient{testBase: testBase{status: http.StatusSeeOther}}},
101+
withMr(testProjectData, fakeMergeRequestLister{}),
102+
withPayloadValidation(methodToPayload{
103+
http.MethodPost: newPayload[RebaseMrRequest],
104+
}),
105+
withMethodCheck(http.MethodPost),
106+
)
107+
data, _ := getFailData(t, svc, request)
108+
checkNon200(t, data, "Could not rebase MR", "/mr/rebase")
109+
})
110+
}

cmd/app/server.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s *shutdownSer
117117
withPayloadValidation(methodToPayload{http.MethodPost: newPayload[AcceptMergeRequestRequest]}),
118118
withMethodCheck(http.MethodPost),
119119
))
120+
m.HandleFunc("/mr/rebase", middleware(
121+
mergeRequestRebaserService{d, gitlabClient},
122+
withMr(d, gitlabClient),
123+
withPayloadValidation(methodToPayload{http.MethodPost: newPayload[RebaseMrRequest]}),
124+
withMethodCheck(http.MethodPost),
125+
))
120126
m.HandleFunc("/mr/discussions/list", middleware(
121127
discussionsListerService{d, gitlabClient},
122128
withMr(d, gitlabClient),

doc/gitlab.nvim.txt

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ OVERVIEW *gitlab.nvim.overview*
3333
This Neovim plugin is designed to make it easy to review Gitlab MRs from within
3434
the editor. This means you can do things like:
3535

36-
- Create, approve, and merge MRs for the current branch
36+
- Create, approve, rebase, and merge MRs for the current branch
3737
- Read and edit an MR description
3838
- Add or remove reviewers and assignees
3939
- Resolve, reply to, and unresolve discussion threads
@@ -183,9 +183,13 @@ you call this function with no values the defaults will be used:
183183
revoke = "glR", -- Revoke MR approval
184184
merge = "glM", -- Merge the feature branch to the target branch and close MR
185185
set_auto_merge = "glm", -- Set MR to merge automatically when the pipeline succeeds
186+
rebase = "glrr", -- Rebase the feature branch of the MR on the server and pull the new state
187+
rebase_skip_ci = "glrs", -- Same as `rebase`, but skip the CI pipeline
188+
rebase_force = "glrf", -- Same as `rebase`, but rebase even if MR already is rebased
186189
create_mr = "glC", -- Create a new MR for currently checked-out feature branch
187190
choose_merge_request = "glc", -- Chose MR for review (if necessary check out the feature branch)
188191
start_review = "glS", -- Start review for the currently checked-out branch
192+
reload_review = "gl<C-R>", -- Load new MR state from Gitlab and apply new diff refs to the diff view
189193
summary = "gls", -- Show the editable summary of the MR
190194
copy_mr_url = "glu", -- Copy the URL of the MR to the system clipboard
191195
open_in_browser = "glo", -- Openthe URL of the MR in the default Internet browser
@@ -391,6 +395,10 @@ you call this function with no values the defaults will be used:
391395
border = "rounded",
392396
},
393397
},
398+
rebase_mr = {
399+
skip_ci = false, -- If true, a CI pipeline is not created. Can be overridden in gitlab.rebase call.
400+
force = false, -- If true, MR is rebased even if MR already is rebased. Can be overridden in gitlab.rebase call.
401+
},
394402
colors = {
395403
discussion_tree = {
396404
username = "Keyword",
@@ -614,6 +622,19 @@ this command to work.
614622
See |gitlab.nvim.merge| for more help on this function.
615623

616624

625+
REBASING AN MR *gitlab.nvim.rebasing-an-mr*
626+
627+
The `rebase` action will rebase an MR on the server, wait for the rebase to
628+
take effect and then update the local reviewer state. The MR must be in a
629+
"rebasable" state for this command to work, i.e., the worktree must be clean
630+
and there must be no conflicts.
631+
>lua
632+
require("gitlab").rebase()
633+
require("gitlab").rebase({ skip_ci = true, force = true })
634+
<
635+
See |gitlab.nvim.rebase| for more help on this function.
636+
637+
617638
CREATING AN MR *gitlab.nvim.creating-an-mr*
618639

619640
To create an MR for the current branch, make sure you have the branch checked
@@ -838,6 +859,25 @@ Opens the reviewer pane. Can be used from anywhere within Neovim after the
838859
plugin is loaded. If run twice, will open a second reviewer pane.
839860
>lua
840861
require("gitlab").review()
862+
<
863+
*gitlab.nvim.reload_review*
864+
gitlab.reload_review() ~
865+
866+
Loads new MR state from Gitlab. Then if diffview.api is available (with the
867+
https://github.com/dlyongemallo/diffview.nvim fork) applies the new diff refs
868+
to the existing diffview, otherwise (with
869+
https://github.com/sindrets/diffview.nvim) closes and re-opens the reviewer.
870+
871+
>lua
872+
require("gitlab").reload_review()
873+
<
874+
*gitlab.nvim.close_review*
875+
gitlab.close_review() ~
876+
877+
Closes the reviewer tab and discussion tree and cleans up (e.g., removes
878+
winbar timer).
879+
>lua
880+
require("gitlab").close_review()
841881
<
842882
*gitlab.nvim.summary*
843883
gitlab.summary() ~
@@ -1091,6 +1131,23 @@ request" page). You can see the current settings in the Summary view, see
10911131
Use the `keymaps.popup.perform_action` to merge the MR
10921132
with your message.
10931133

1134+
*gitlab.nvim.rebase*
1135+
gitlab.rebase({opts}) ~
1136+
1137+
Rebases the feature branch of the MR on the server and pulls the new state of
1138+
the target branch.
1139+
>lua
1140+
require("gitlab").rebase()
1141+
require("gitlab").rebase({ skip_ci = true, force = true })
1142+
<
1143+
Parameters: ~
1144+
{opts}: (table|nil) Keyword arguments that can be used to override
1145+
default behavior.
1146+
• {skip_ci}: (bool) If true, a CI pipeline is not created (this
1147+
may lead to a failed Mergeability Check (CI_MUST_PASS).
1148+
{force}: (bool) If true, MR is rebased even if MR already is
1149+
rebased (this may run an unnecessary CI pipeline).
1150+
10941151
*gitlab.nvim.data*
10951152
gitlab.data({resources}, {cb}) ~
10961153

lua/gitlab/actions/comment.lua

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,14 +300,14 @@ end
300300
---@return boolean
301301
M.can_create_comment = function(must_be_visual)
302302
-- Check that diffview is initialized
303-
if reviewer.tabnr == nil then
303+
if reviewer.tabid == nil then
304304
u.notify("Reviewer must be initialized first", vim.log.levels.ERROR)
305305
return false
306306
end
307307

308308
-- Check that we are in the Diffview tab
309-
local tabnr = vim.api.nvim_get_current_tabpage()
310-
if tabnr ~= reviewer.tabnr then
309+
local tabid = vim.api.nvim_get_current_tabpage()
310+
if tabid ~= reviewer.tabid then
311311
u.notify("Comments can only be left in the reviewer pane", vim.log.levels.ERROR)
312312
return false
313313
end

lua/gitlab/actions/discussions/init.lua

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,13 @@ end
5656
---@param callback function|nil
5757
M.load_discussions = function(callback)
5858
local git = require("gitlab.git")
59-
local ahead, behind = git.get_ahead_behind(git.get_current_branch(), git.get_remote_branch())
60-
state.ahead_behind = { ahead, behind }
59+
require("gitlab.git_async").get_ahead_behind(
60+
git.get_current_branch(),
61+
git.get_remote_branch(),
62+
function(ahead, behind)
63+
state.ahead_behind = { ahead, behind }
64+
end
65+
)
6166
state.discussion_tree.last_updated = nil
6267
state.load_new_state("discussion_data", function(data)
6368
if not state.DISCUSSION_DATA then

0 commit comments

Comments
 (0)