From d26ab8488d4ca6d3ab8f19a254ac408f51ce082f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Fri, 6 Feb 2026 09:17:40 +0100 Subject: [PATCH 01/31] fix: revert removing on_error_callback --- lua/gitlab/job.lua | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/job.lua b/lua/gitlab/job.lua index 128591be..33308210 100644 --- a/lua/gitlab/job.lua +++ b/lua/gitlab/job.lua @@ -4,7 +4,7 @@ local Job = require("plenary.job") local u = require("gitlab.utils") local M = {} -M.run_job = function(endpoint, method, body, callback) +M.run_job = function(endpoint, method, body, callback, on_error_callback) local state = require("gitlab.state") local args = { "-s", "-X", (method or "POST"), string.format("localhost:%s", state.settings.port) .. endpoint } @@ -16,7 +16,8 @@ M.run_job = function(endpoint, method, body, callback) -- This handler will handle all responses from the Go server. Anything with a successful -- status will call the callback (if it is supplied for the job). Otherwise, it will print out the - -- success message or error message and details from the Go server. + -- success message or error message and details from the Go server and run the on_error_callback + -- (if supplied for the job). local stderr = {} Job:new({ command = "curl", @@ -53,6 +54,9 @@ M.run_job = function(endpoint, method, body, callback) -- Handle error case local message = string.format("%s: %s", data.message, data.details) u.notify(message, vim.log.levels.ERROR) + if on_error_callback then + on_error_callback(data) + end end end, 0) end, From d71bdbbbf15c44c9875dd35f6fbc8ecbf8fc12d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Tue, 10 Feb 2026 09:25:32 +0100 Subject: [PATCH 02/31] fix: allow publishing drafts when local not in sync with remote --- lua/gitlab/actions/draft_notes/init.lua | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lua/gitlab/actions/draft_notes/init.lua b/lua/gitlab/actions/draft_notes/init.lua index 1f0e0e1d..9538678e 100755 --- a/lua/gitlab/actions/draft_notes/init.lua +++ b/lua/gitlab/actions/draft_notes/init.lua @@ -11,6 +11,8 @@ local List = require("gitlab.utils.list") local u = require("gitlab.utils") local state = require("gitlab.state") +local branch_not_in_sync_comment = " (even if local branch not in sync with remote)" + local M = {} ---Re-fetches all draft notes (and non-draft notes) and re-renders the relevant views @@ -64,8 +66,10 @@ end -- This function will trigger a popup prompting you to publish the current draft comment M.publish_draft = function(tree) + local branch_in_sync = git.check_current_branch_up_to_date_on_remote(vim.log.levels.ERROR) + local sync_comment = branch_in_sync and "" or branch_not_in_sync_comment vim.ui.select({ "Confirm", "Cancel" }, { - prompt = "Publish current draft comment?", + prompt = string.format("Publish current draft comment%s?", sync_comment), }, function(choice) if choice == "Confirm" then M.confirm_publish_draft(tree) @@ -75,8 +79,10 @@ end -- This function will trigger a popup prompting you to publish all draft notes M.publish_all_drafts = function() + local branch_in_sync = git.check_current_branch_up_to_date_on_remote(vim.log.levels.ERROR) + local sync_comment = branch_in_sync and "" or branch_not_in_sync_comment vim.ui.select({ "Confirm", "Cancel" }, { - prompt = "Publish all drafts?", + prompt = string.format("Publish all drafts%s?", sync_comment), }, function(choice) if choice == "Confirm" then M.confirm_publish_all_drafts() @@ -86,9 +92,6 @@ end ---Publishes all draft notes and comments. Re-renders all discussion views. M.confirm_publish_all_drafts = function() - if not git.check_current_branch_up_to_date_on_remote(vim.log.levels.ERROR) then - return - end local body = { publish_all = true } job.run_job("/mr/draft_notes/publish", "POST", body, function(data) u.notify(data.message, vim.log.levels.INFO) @@ -108,9 +111,6 @@ end ---and re-render it. ---@param tree NuiTree M.confirm_publish_draft = function(tree) - if not git.check_current_branch_up_to_date_on_remote(vim.log.levels.ERROR) then - return - end local current_node = tree:get_node() local note_node = common.get_note_node(tree, current_node) local root_node = common.get_root_node(tree, current_node) From f62bf6516fe8e3a28ad43ff4aaeb5c7301266d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 11 Feb 2026 14:28:03 +0100 Subject: [PATCH 03/31] fix: check window valid when refocusing after closing popup --- lua/gitlab/popup.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/gitlab/popup.lua b/lua/gitlab/popup.lua index ecee5331..caa55f6d 100644 --- a/lua/gitlab/popup.lua +++ b/lua/gitlab/popup.lua @@ -166,7 +166,9 @@ M.set_up_autocommands = function(popup, layout, previous_window, opts) if previous_window ~= nil then popup:on("BufHidden", function() vim.schedule(function() - vim.api.nvim_set_current_win(previous_window) + if vim.api.nvim_win_is_valid(previous_window) then + vim.api.nvim_set_current_win(previous_window) + end end) end) end From 129b83bef4985b0f4d35522152235e934b723d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Tue, 17 Feb 2026 18:31:05 +0100 Subject: [PATCH 04/31] feat: implement pagination for MR discussions This uses the gitlab.Scan function to iterate over all discussions and handle errors after the iterator is exhausted. The test for non-200s is no longer needed as the Scan function transforms such responses to standard errors. --- cmd/app/list_discussions.go | 13 ++++++------- cmd/app/list_discussions_test.go | 11 ----------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/cmd/app/list_discussions.go b/cmd/app/list_discussions.go index d0f3fe76..4b1848e0 100644 --- a/cmd/app/list_discussions.go +++ b/cmd/app/list_discussions.go @@ -2,6 +2,7 @@ package app import ( "net/http" + "slices" "sort" "sync" "time" @@ -90,18 +91,16 @@ func (a discussionsListerService) ServeHTTP(w http.ResponseWriter, r *http.Reque }, } - discussions, res, err := a.client.ListMergeRequestDiscussions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &mergeRequestDiscussionOptions) + it, hasErr := gitlab.Scan(func(p gitlab.PaginationOptionFunc) ([]*gitlab.Discussion, *gitlab.Response, error) { + return a.client.ListMergeRequestDiscussions(a.projectInfo.ProjectId, a.projectInfo.MergeId, &mergeRequestDiscussionOptions, p) + }) + discussions := slices.Collect(it) - if err != nil { + if err := hasErr(); err != nil { handleError(w, err, "Could not list discussions", http.StatusInternalServerError) return } - if res.StatusCode >= 300 { - handleError(w, GenericError{r.URL.Path}, "Could not list discussions", res.StatusCode) - return - } - /* Filter out any discussions started by a blacklisted user and system discussions, then return them sorted by created date */ var unlinkedDiscussions []*gitlab.Discussion diff --git a/cmd/app/list_discussions_test.go b/cmd/app/list_discussions_test.go index 5d43f1d9..ab142a4c 100644 --- a/cmd/app/list_discussions_test.go +++ b/cmd/app/list_discussions_test.go @@ -127,17 +127,6 @@ func TestListDiscussions(t *testing.T) { data, _ := getFailData(t, svc, request) checkErrorFromGitlab(t, data, "Could not list discussions") }) - t.Run("Handles non-200s from Gitlab client", func(t *testing.T) { - request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{}}) - svc := middleware( - discussionsListerService{testProjectData, fakeDiscussionsLister{testBase: testBase{status: http.StatusSeeOther}}}, - withMr(testProjectData, fakeMergeRequestLister{}), - withPayloadValidation(methodToPayload{http.MethodPost: newPayload[DiscussionsRequest]}), - withMethodCheck(http.MethodPost), - ) - data, _ := getFailData(t, svc, request) - checkNon200(t, data, "Could not list discussions", "/mr/discussions/list") - }) t.Run("Handles error from emoji service", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/discussions/list", DiscussionsRequest{Blacklist: []string{}}) svc := middleware( From 946b7182b0b95e6119ffbae6e841bc5ed8be819e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Tue, 17 Feb 2026 18:35:35 +0100 Subject: [PATCH 05/31] perf: only fetch emojis for filtered discussions --- cmd/app/list_discussions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/app/list_discussions.go b/cmd/app/list_discussions.go index 4b1848e0..74a9a333 100644 --- a/cmd/app/list_discussions.go +++ b/cmd/app/list_discussions.go @@ -123,7 +123,7 @@ func (a discussionsListerService) ServeHTTP(w http.ResponseWriter, r *http.Reque /* Collect IDs in order to fetch emojis */ var noteIds []int64 - for _, discussion := range discussions { + for _, discussion := range slices.Concat(linkedDiscussions, unlinkedDiscussions) { for _, note := range discussion.Notes { noteIds = append(noteIds, note.ID) } From 250ba35a49d06f81efcefd17db56c0ffbf1338c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Thu, 26 Feb 2026 10:14:57 +0100 Subject: [PATCH 06/31] feat: add mergeability checks to summary view --- cmd/app/client.go | 2 + cmd/app/mergeability_checks.go | 83 +++++++++++++++++++ cmd/app/mergeability_checks_test.go | 119 ++++++++++++++++++++++++++++ cmd/app/server.go | 5 ++ doc/gitlab.nvim.txt | 33 ++++++++ lua/gitlab/actions/data.lua | 2 + lua/gitlab/actions/summary.lua | 46 ++++++++--- lua/gitlab/annotations.lua | 33 +++++++- lua/gitlab/init.lua | 2 + lua/gitlab/state.lua | 35 ++++++++ 10 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 cmd/app/mergeability_checks.go create mode 100644 cmd/app/mergeability_checks_test.go diff --git a/cmd/app/client.go b/cmd/app/client.go index 30e9c827..0663d2d0 100644 --- a/cmd/app/client.go +++ b/cmd/app/client.go @@ -32,6 +32,7 @@ type Client struct { gitlab.UsersServiceInterface gitlab.DraftNotesServiceInterface gitlab.ProjectMarkdownUploadsServiceInterface + gitlab.GraphQLInterface } /* NewClient parses and validates the project settings and initializes the Gitlab client. */ @@ -100,6 +101,7 @@ func NewClient() (*Client, error) { client.Users, client.DraftNotes, client.ProjectMarkdownUploads, + client.GraphQL, }, nil } diff --git a/cmd/app/mergeability_checks.go b/cmd/app/mergeability_checks.go new file mode 100644 index 00000000..b64db6ef --- /dev/null +++ b/cmd/app/mergeability_checks.go @@ -0,0 +1,83 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http" + + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +type MergeabilityCheck struct { + Identifier string `json:"identifier"` + Status string `json:"status"` +} + +type MergeabilityChecksResponse struct { + SuccessResponse + MergeabilityChecks []*MergeabilityCheck `json:"mergeability_checks"` +} + +type mergeabilityChecksGraphQLResponse struct { + Data struct { + Project struct { + MergeRequest struct { + MergeabilityChecks []*MergeabilityCheck `json:"mergeabilityChecks"` + } `json:"mergeRequest"` + } `json:"project"` + } `json:"data"` +} + +const mergeabilityChecksQuery = ` +query GetMergeabilityChecks($projectPath: ID!, $iid: String!) { + project(fullPath: $projectPath) { + mergeRequest(iid: $iid) { + mergeabilityChecks { + identifier + status + } + } + } +} +` + +type mergeabilityChecksService struct { + data + client gitlab.GraphQLInterface +} + +func (a mergeabilityChecksService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + checks, err := a.fetchMergeabilityChecks() + if err != nil { + handleError(w, err, "Could not get mergeability checks", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + response := MergeabilityChecksResponse{ + SuccessResponse: SuccessResponse{Message: "Mergeability checks retrieved"}, + MergeabilityChecks: checks, + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} + +func (a mergeabilityChecksService) fetchMergeabilityChecks() ([]*MergeabilityCheck, error) { + var response mergeabilityChecksGraphQLResponse + + _, err := a.client.Do(gitlab.GraphQLQuery{ + Query: mergeabilityChecksQuery, + Variables: map[string]any{ + "projectPath": a.gitInfo.ProjectPath(), + "iid": fmt.Sprintf("%d", a.projectInfo.MergeId), + }, + }, &response) + if err != nil { + return nil, fmt.Errorf("failed to fetch mergeability checks: %w", err) + } + + return response.Data.Project.MergeRequest.MergeabilityChecks, nil +} diff --git a/cmd/app/mergeability_checks_test.go b/cmd/app/mergeability_checks_test.go new file mode 100644 index 00000000..289bc1cd --- /dev/null +++ b/cmd/app/mergeability_checks_test.go @@ -0,0 +1,119 @@ +package app + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/harrisoncramer/gitlab.nvim/cmd/app/git" + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +type fakeGraphQLClient struct { + err error + jsonData []byte +} + +func (f fakeGraphQLClient) Do(query gitlab.GraphQLQuery, response any, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + if f.err != nil { + return nil, f.err + } + + // Actually unmarshal JSON into the response struct + if err := json.Unmarshal(f.jsonData, response); err != nil { + return nil, err + } + + // if resp, ok := response.(mergeabilityChecksGraphQLResponse); ok { + // resp.Data.Project.MergeRequest.MergeabilityChecks = f.checks + // } + + return makeResponse(http.StatusOK), nil +} + +var testMergeabilityData = data{ + projectInfo: &ProjectInfo{MergeId: 123}, + gitInfo: &git.GitData{ + BranchName: "feature-branch", + Namespace: "test-namespace", + ProjectName: "test-project", + }, +} + +func TestMergeabilityChecksHandler(t *testing.T) { + t.Run("Returns mergeability checks", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/mergeability_checks", nil) + client := fakeGraphQLClient{ + jsonData: []byte(`{ + "data": { + "project": { + "mergeRequest": { + "mergeabilityChecks": [ + {"identifier": "CI_MUST_PASS", "status": "SUCCESS"}, + {"identifier": "CONFLICT", "status": "FAILED"} + ] + } + } + } + }`), + } + svc := middleware( + mergeabilityChecksService{testMergeabilityData, client}, + withMethodCheck(http.MethodGet), + ) + + res := httptest.NewRecorder() + svc.ServeHTTP(res, request) + + var data MergeabilityChecksResponse + json.Unmarshal(res.Body.Bytes(), &data) + + assert(t, data.Message, "Mergeability checks retrieved") + assert(t, len(data.MergeabilityChecks), 2) + assert(t, data.MergeabilityChecks[0].Identifier, "CI_MUST_PASS") + assert(t, data.MergeabilityChecks[0].Status, "SUCCESS") + assert(t, data.MergeabilityChecks[1].Identifier, "CONFLICT") + assert(t, data.MergeabilityChecks[1].Status, "FAILED") + }) + + t.Run("Returns empty list when there are no checks", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/mergeability_checks", nil) + client := fakeGraphQLClient{ + jsonData: []byte(`{ + "data": { + "project": { + "mergeRequest": { + "mergeabilityChecks": [] + } + } + } + }`), + } + svc := middleware( + mergeabilityChecksService{testMergeabilityData, client}, + withMethodCheck(http.MethodGet), + ) + + res := httptest.NewRecorder() + svc.ServeHTTP(res, request) + + var data MergeabilityChecksResponse + json.Unmarshal(res.Body.Bytes(), &data) + + assert(t, data.Message, "Mergeability checks retrieved") + assert(t, len(data.MergeabilityChecks), 0) + }) + + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodGet, "/mr/mergeability_checks", nil) + client := fakeGraphQLClient{err: errorFromGitlab} + svc := middleware( + mergeabilityChecksService{testMergeabilityData, client}, + withMethodCheck(http.MethodGet), + ) + data, _ := getFailData(t, svc, request) + assert(t, data.Message, "Could not get mergeability checks") + assert(t, data.Details, "failed to fetch mergeability checks: "+errorFromGitlab.Error()) + }) +} diff --git a/cmd/app/server.go b/cmd/app/server.go index 5fff24ed..f143904a 100644 --- a/cmd/app/server.go +++ b/cmd/app/server.go @@ -134,6 +134,11 @@ func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s *shutdownSer withMr(d, gitlabClient), withMethodCheck(http.MethodGet), )) + m.HandleFunc("/mr/info/mergeability", middleware( + mergeabilityChecksService{d, gitlabClient}, + withMr(d, gitlabClient), + withMethodCheck(http.MethodGet), + )) m.HandleFunc("/mr/assignee", middleware( assigneesService{d, gitlabClient}, withMr(d, gitlabClient), diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 2db9f633..1040deff 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -312,6 +312,39 @@ you call this function with no values the defaults will be used: "squash", "labels", "web_url", + "mergeability_checks", -- See more detailed configuration below + }, + -- Settings for the mergeability checks in the summary view + -- https://docs.gitlab.com/api/graphql/reference/#mergeabilitycheckidentifier + mergeability_checks = { + -- Symbols for individual check statuses. Set values to `false` to hide checks with given status from summary + statuses = { + SUCCESS = "✅", + CHECKING = "🔁", + FAILED = "❌", + WARNING = "⚠️", + INACTIVE = "💤", + }, + -- Descriptions for individual checks. Set values to `false` to hide given checks from summary + checks = { + CI_MUST_PASS = "Pipeline must succeed", + COMMITS_STATUS = "Source branch exists and contains commits", + CONFLICT = "Merge conflicts must be resolved", + DISCUSSIONS_NOT_RESOLVED = "Open threads must be resolved", + DRAFT_STATUS = "Merge request must not be draft", + JIRA_ASSOCIATION_MISSING = "Title or description references a Jira issue", + LOCKED_LFS_FILES = "All LFS files must be unlocked", + LOCKED_PATHS = "All paths must be unlocked", + MERGE_REQUEST_BLOCKED = "Merge request is not blocked", + MERGE_TIME = "Merge is not blocked due to a scheduled merge time", + NEED_REBASE = "Merge request must be rebased, fast-forward merge is not possible", + NOT_APPROVED = "All required approvals must be given", + NOT_OPEN = "Merge request must be open", + REQUESTED_CHANGES = "Change requests must be approved by the requesting user", + SECURITY_POLICY_VIOLATIONS = "Security policies are satisfied", + STATUS_CHECKS_MUST_PASS = "External status checks pass", + TITLE_REGEX = "Title matches the expected regex", + }, }, }, discussion_signs = { diff --git a/lua/gitlab/actions/data.lua b/lua/gitlab/actions/data.lua index 55aa7948..1959bf52 100644 --- a/lua/gitlab/actions/data.lua +++ b/lua/gitlab/actions/data.lua @@ -6,6 +6,7 @@ local M = {} local user = state.dependencies.user local info = state.dependencies.info local labels = state.dependencies.labels +local mergeability = state.dependencies.mergeability local project_members = state.dependencies.project_members local revisions = state.dependencies.revisions local latest_pipeline = state.dependencies.latest_pipeline @@ -21,6 +22,7 @@ M.data = function(resources, cb) info = info, user = user, labels = labels, + mergeability = mergeability, project_members = project_members, revisions = revisions, pipeline = latest_pipeline, diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 87afc832..778f85df 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -8,7 +8,6 @@ local job = require("gitlab.job") local common = require("gitlab.actions.common") local u = require("gitlab.utils") local popup = require("gitlab.popup") -local List = require("gitlab.utils.list") local state = require("gitlab.state") local miscellaneous = require("gitlab.actions.miscellaneous") @@ -108,6 +107,28 @@ M.update_details_popup = function(bufnr, info_lines) M.color_details(bufnr) -- Color values in details popup end +---Return the mergeability checks statuses and descriptions +---@return string[] +local make_mergeability_checks = function() + local lines = {} + for _, check in ipairs(state.MERGEABILITY.mergeability_checks) do + local status = state.settings.mergeability_checks.statuses[check.status] + if status == nil then + u.notify(string.format("Unknown mergeability check status: %s", check.status), vim.log.levels.ERROR) + end + if status then + local description = state.settings.mergeability_checks.checks[check.identifier] + if description == nil then + u.notify(string.format("Unknown mergeability check identifier: %s", check.identifier), vim.log.levels.ERROR) + end + if description then + table.insert(lines, status .. " " .. description) + end + end + end + return lines +end + -- Builds a lua list of strings that contain metadata about the current MR. Only builds the -- lines that users include in their state.settings.info.fields list. M.build_info_lines = function() @@ -140,6 +161,7 @@ M.build_info_lines = function() end, }, web_url = { title = "MR URL", content = info.web_url }, + mergeability_checks = { title = "Mergeability checks", content = make_mergeability_checks }, } local longest_used = "" @@ -158,22 +180,26 @@ M.build_info_lines = function() return string.rep(" ", offset + 3) end - return List.new(state.settings.info.fields):map(function(v) + local result = {} + 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 - local content = row.content() - if content ~= nil then - line = line .. row.content() + local title_prefix = "* " .. row.title .. row_offset(row.title) + local content = type(row.content) == "function" and row.content() or row.content + if type(content) == "table" then + -- Multi-line content + local padding = string.rep(" ", #title_prefix) + for i, line in ipairs(#content > 0 and content or { "" }) do + table.insert(result, (i == 1 and title_prefix or padding) .. line) end else - line = line .. row.content + -- Single-line content + table.insert(result, title_prefix .. (content or "")) end - return line - end) + end + return result end -- This function will PUT the new description to the Go server diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index e1403249..2448be22 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -159,6 +159,7 @@ ---@field discussion_tree? DiscussionSettings -- Settings for the popup windows ---@field choose_merge_request? ChooseMergeRequestSettings -- Default settings when choosing a merge request ---@field info? InfoSettings -- Settings for the "info" or "summary" view +---@field mergeability_checks? MergeabilityChecksSettings -- Settings for the mergeability checks in the "summary" view ---@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 @@ -252,7 +253,37 @@ ---@class InfoSettings ---@field horizontal? boolean -- Display metadata to the left of the summary rather than underneath ----@field fields? ("author" | "created_at" | "updated_at" | "merge_status" | "draft" | "conflicts" | "assignees" | "reviewers" | "pipeline" | "branch" | "target_branch" | "delete_branch" | "squash" | "labels")[] +---@field fields? ("author" | "created_at" | "updated_at" | "merge_status" | "draft" | "conflicts" | "assignees" | "reviewers" | "pipeline" | "branch" | "target_branch" | "delete_branch" | "squash" | "labels" | "mergeability_checks")[] + +---@class MergeabilityChecksSettings +---@field statuses MergeabilityStatuses +---@field checks MergeabilityChecks + +---@class MergeabilityStatuses +---@field SUCCESS string|false +---@field CHECKING string|false +---@field FAILED string|false +---@field WARNING string|false +---@field INACTIVE string|false + +---@class MergeabilityChecks +---@field CI_MUST_PASS string|false +---@field COMMITS_STATUS string|false +---@field CONFLICT string|false +---@field DISCUSSIONS_NOT_RESOLVED string|false +---@field DRAFT_STATUS string|false +---@field JIRA_ASSOCIATION_MISSING string|false +---@field LOCKED_LFS_FILES string|false +---@field LOCKED_PATHS string|false +---@field MERGE_REQUEST_BLOCKED string|false +---@field MERGE_TIME string|false +---@field NEED_REBASE string|false +---@field NOT_APPROVED string|false +---@field NOT_OPEN string|false +---@field REQUESTED_CHANGES string|false +---@field SECURITY_POLICY_VIOLATIONS string|false +---@field STATUS_CHECKS_MUST_PASS string|false +---@field TITLE_REGEX string|false ---@class DiscussionSettings: table ---@field expanders? ExpanderOpts -- Customize the expander icons in the discussion tree diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 8c52c73b..da066e6b 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -22,6 +22,7 @@ local health = require("gitlab.health") local user = state.dependencies.user local info = state.dependencies.info +local mergeability = state.dependencies.mergeability local labels_dep = state.dependencies.labels local project_members = state.dependencies.project_members local latest_pipeline = state.dependencies.latest_pipeline @@ -62,6 +63,7 @@ return { setup = setup, summary = async.sequence({ u.merge(info, { refresh = true }), + u.merge(mergeability, { refresh = true }), labels_dep, }, summary.summary), approve = async.sequence({ info }, approvals.approve), diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index d67e0c06..c1fdb673 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -218,6 +218,35 @@ M.settings = { "squash", "labels", "web_url", + "mergeability_checks", + }, + }, + mergeability_checks = { + statuses = { + SUCCESS = "✅", + CHECKING = "🔁", + FAILED = "❌", + WARNING = "⚠️", + INACTIVE = "💤", + }, + checks = { + CI_MUST_PASS = "Pipeline must succeed", + COMMITS_STATUS = "Source branch exists and contains commits", + CONFLICT = "Merge conflicts must be resolved", + DISCUSSIONS_NOT_RESOLVED = "Open threads must be resolved", + DRAFT_STATUS = "Merge request must not be draft", + JIRA_ASSOCIATION_MISSING = "Title or description references a Jira issue", + LOCKED_LFS_FILES = "All LFS files must be unlocked", + LOCKED_PATHS = "All paths must be unlocked", + MERGE_REQUEST_BLOCKED = "Merge request is not blocked", + MERGE_TIME = "Merge is not blocked due to a scheduled merge time", + NEED_REBASE = "Merge request must be rebased, fast-forward merge is not possible", + NOT_APPROVED = "All required approvals must be given", + NOT_OPEN = "Merge request must be open", + REQUESTED_CHANGES = "Change requests must be approved by the requesting user", + SECURITY_POLICY_VIOLATIONS = "Security policies are satisfied", + STATUS_CHECKS_MUST_PASS = "External status checks pass", + TITLE_REGEX = "Title matches the expected regex", }, }, discussion_signs = { @@ -467,6 +496,12 @@ M.dependencies = { state = "INFO", refresh = false, }, + mergeability = { + endpoint = "/mr/info/mergeability", + key = "MergeabilityChecks", + state = "MERGEABILITY", + refresh = false, + }, latest_pipeline = { endpoint = "/pipeline", key = "latest_pipeline", From d193bcdb19c6998b7f1dcfb28a043e400c82303a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Thu, 26 Feb 2026 10:27:34 +0100 Subject: [PATCH 07/31] test: add error return value check --- cmd/app/mergeability_checks_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/app/mergeability_checks_test.go b/cmd/app/mergeability_checks_test.go index 289bc1cd..45ccef55 100644 --- a/cmd/app/mergeability_checks_test.go +++ b/cmd/app/mergeability_checks_test.go @@ -67,7 +67,8 @@ func TestMergeabilityChecksHandler(t *testing.T) { svc.ServeHTTP(res, request) var data MergeabilityChecksResponse - json.Unmarshal(res.Body.Bytes(), &data) + err := json.Unmarshal(res.Body.Bytes(), &data) + assert(t, err, nil) assert(t, data.Message, "Mergeability checks retrieved") assert(t, len(data.MergeabilityChecks), 2) @@ -99,7 +100,8 @@ func TestMergeabilityChecksHandler(t *testing.T) { svc.ServeHTTP(res, request) var data MergeabilityChecksResponse - json.Unmarshal(res.Body.Bytes(), &data) + err := json.Unmarshal(res.Body.Bytes(), &data) + assert(t, err, nil) assert(t, data.Message, "Mergeability checks retrieved") assert(t, len(data.MergeabilityChecks), 0) From 5069212993010e6ccf4b9aa53ad11f121fea81cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Thu, 26 Feb 2026 21:52:06 +0100 Subject: [PATCH 08/31] docs: add missing value in fields annotation --- lua/gitlab/annotations.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index 2448be22..ba0bcb23 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -253,7 +253,7 @@ ---@class InfoSettings ---@field horizontal? boolean -- Display metadata to the left of the summary rather than underneath ----@field fields? ("author" | "created_at" | "updated_at" | "merge_status" | "draft" | "conflicts" | "assignees" | "reviewers" | "pipeline" | "branch" | "target_branch" | "delete_branch" | "squash" | "labels" | "mergeability_checks")[] +---@field fields? ("author" | "created_at" | "updated_at" | "merge_status" | "draft" | "conflicts" | "assignees" | "reviewers" | "pipeline" | "branch" | "target_branch" | "delete_branch" | "squash" | "labels" | "web_url" | "mergeability_checks")[] ---@class MergeabilityChecksSettings ---@field statuses MergeabilityStatuses From d7ddf1cb5f7abf991a18f1c55da2ae0245f7574d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Thu, 26 Feb 2026 21:53:34 +0100 Subject: [PATCH 09/31] fix: add nil check --- lua/gitlab/actions/summary.lua | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 778f85df..dc672207 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -169,9 +169,13 @@ M.build_info_lines = function() 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 + if options[v] == nil then + u.notify(string.format("Invalid field in settings.info.fields: '%s'", v), vim.log.levels.ERROR) + else + local title = options[v].title + if string.len(title) > string.len(longest_used) then + longest_used = title + end end end From c2e76d0937b578c9af7b8d08c4d9f344ce59e360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Fri, 27 Feb 2026 14:40:16 +0100 Subject: [PATCH 10/31] fix: highlight individual lines --- lua/gitlab/actions/summary.lua | 68 ++++++++++++++++++++++++++-------- lua/gitlab/utils/init.lua | 4 +- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index dc672207..42b52d4b 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -11,6 +11,9 @@ local popup = require("gitlab.popup") local state = require("gitlab.state") local miscellaneous = require("gitlab.actions.miscellaneous") +-- No-break space used in summary details to make matching different parts of the line more robust +local nbsp = " " + local M = { layout_visible = false, layout = nil, @@ -173,15 +176,15 @@ M.build_info_lines = function() u.notify(string.format("Invalid field in settings.info.fields: '%s'", v), vim.log.levels.ERROR) else local title = options[v].title - if string.len(title) > string.len(longest_used) then + if vim.fn.strcharlen(title) > vim.fn.strcharlen(longest_used) then longest_used = title end end end local function row_offset(row) - local offset = string.len(longest_used) - string.len(row) - return string.rep(" ", offset + 3) + local offset = vim.fn.strcharlen(longest_used) - vim.fn.strcharlen(row) + return string.rep(nbsp, offset + 3) end local result = {} @@ -194,7 +197,7 @@ M.build_info_lines = function() local content = type(row.content) == "function" and row.content() or row.content if type(content) == "table" then -- Multi-line content - local padding = string.rep(" ", #title_prefix) + local padding = string.rep(nbsp, vim.fn.strcharlen(title_prefix)) -- no-break space for i, line in ipairs(#content > 0 and content or { "" }) do table.insert(result, (i == 1 and title_prefix or padding) .. line) end @@ -290,24 +293,57 @@ end M.color_details = function(bufnr) local details_namespace = vim.api.nvim_create_namespace("Details") - for i, v in ipairs(state.settings.info.fields) do - if v == "labels" then - local line_content = u.get_line_content(bufnr, i) + for i, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) do + if line:match("^* Labels") then for j, label in ipairs(state.LABELS) do - local start_idx, end_idx = line_content:find(label.Name) + local start_idx, end_idx = line:find(label.Name, 1, true) if start_idx ~= nil and end_idx ~= nil then vim.cmd("highlight " .. "label" .. j .. " guifg=white") vim.api.nvim_set_hl(0, ("label" .. j), { fg = label.Color }) - vim.api.nvim_buf_add_highlight(bufnr, details_namespace, ("label" .. j), i - 1, start_idx - 1, end_idx) + vim.hl.range(bufnr, details_namespace, ("label" .. j), { i - 1, start_idx - 1 }, { i - 1, end_idx }) end end - elseif v == "delete_branch" or v == "squash" or v == "draft" or v == "conflicts" then - local line_content = u.get_line_content(bufnr, i) - local start_idx, end_idx = line_content:find("%S-$") - if start_idx ~= nil and end_idx ~= nil then - vim.api.nvim_set_hl(0, "boolean", { link = "Constant" }) - vim.api.nvim_buf_add_highlight(bufnr, details_namespace, "boolean", i - 1, start_idx - 1, end_idx) - end + elseif line:match("^* Status") then + local status = line:match("[^" .. nbsp .. "]-$") + local hl = ({ + blocked_status = "DiagnosticError", + broken_status = "DiagnosticError", + checking = "DiagnosticInfo", + ci_must_pass = "DiagnosticWarn", + ci_still_running = "DiagnosticInfo", + discussions_not_resolved = "DiagnosticWarn", + draft_status = "Comment", + external_status_checks = "DiagnosticHint", + mergeable = "DiagnosticOK", + not_approved = "DiagnosticWarn", + not_open = "NonText", + policies_denied = "DiagnosticError", + unchecked = "NonText", + })[status] or "Normal" + local start_idx, end_idx = line:find("[^" .. nbsp .. "]-$") + vim.hl.range(bufnr, details_namespace, hl, { i - 1, start_idx - 1 }, { i - 1, end_idx }) + elseif line:match("^* Branch") or line:match("^* Target Branch") then + local start_idx, end_idx = line:find("[^" .. nbsp .. "]-$") + vim.hl.range(bufnr, details_namespace, "Title", { i - 1, start_idx - 1 }, { i - 1, end_idx }) + elseif line:match("^* Pipeline") then + local status = line:match("[^" .. nbsp .. "]-$") + local hl = ({ + canceled = "DiagnosticWarn", + created = "DiagnosticInfo", + failed = "DiagnosticError", + manual = "DiagnosticHint", + pending = "DiagnosticWarn", + running = "DiagnosticInfo", + skipped = "Comment", + success = "DiagnosticOK", + unknown = "NonText", + })[status] or "Normal" + local start_idx, end_idx = line:find("[^" .. nbsp .. "]-$") + vim.hl.range(bufnr, details_namespace, hl, { i - 1, start_idx - 1 }, { i - 1, end_idx }) + elseif line:match(nbsp .. "No$") or line:match(nbsp .. "Yes$") then + local start_idx, end_idx = line:find("[^" .. nbsp .. "]-$") + vim.api.nvim_set_hl(0, "boolean", { link = "Constant" }) + vim.hl.range(bufnr, details_namespace, "boolean", { i - 1, start_idx - 1 }, { i - 1, end_idx }) end end end diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 09054c9c..a1e61a90 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -309,8 +309,8 @@ end M.get_longest_string = function(list) local longest = 0 for _, v in pairs(list) do - if string.len(v) > longest then - longest = string.len(v) + if vim.fn.strcharlen(v) > longest then + longest = vim.fn.strcharlen(v) end end return longest From 8b282b7758f02c1b1b57168b89c14cc9501e05bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Fri, 27 Feb 2026 17:44:59 +0100 Subject: [PATCH 11/31] fix: use head_pipeline if available --- lua/gitlab/actions/summary.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 42b52d4b..5a8662aa 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -156,7 +156,7 @@ M.build_info_lines = function() pipeline = { title = "Pipeline Status", content = function() - local pipeline = state.INFO.pipeline + local pipeline = info.head_pipeline ~= vim.NIL and info.head_pipeline or info.pipeline if type(pipeline) ~= "table" or (type(pipeline) == "table" and u.table_size(pipeline) == 0) then return "" end From d67484fe87d423ace0b0f7b4590a80f8bfc8cee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sat, 28 Feb 2026 07:44:58 +0100 Subject: [PATCH 12/31] fix: check local branch up-to-date on remote before creating comment --- lua/gitlab/actions/comment.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 03202a2b..086cd1e6 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -349,6 +349,10 @@ M.can_create_comment = function(must_be_visual) return false end + if not git.check_current_branch_up_to_date_on_remote(vim.log.levels.ERROR) then + return false + end + -- Check we're in visual mode for code suggestions and multiline comments if must_be_visual and not u.check_visual_mode() then return false From f7c7a93da4cc9acf97f43e88f55f29940c79ccc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Mon, 2 Mar 2026 12:41:28 +0100 Subject: [PATCH 13/31] feat: show ahead and behind commits in winbar --- lua/gitlab/actions/discussions/init.lua | 3 + lua/gitlab/actions/discussions/winbar.lua | 19 +++++- lua/gitlab/annotations.lua | 2 + lua/gitlab/git.lua | 75 +++++++++++------------ lua/gitlab/state.lua | 9 +-- 5 files changed, 64 insertions(+), 44 deletions(-) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index dc0526e3..eb1d6c4c 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -55,6 +55,9 @@ end ---Makes API call to get the discussion data, stores it in the state, and calls the callback ---@param callback function|nil M.load_discussions = function(callback) + local git = require("gitlab.git") + local ahead, behind = git.get_ahead_behind(git.get_current_branch(), git.get_remote_branch()) + state.ahead_behind = { ahead, behind } state.discussion_tree.last_updated = nil state.load_new_state("discussion_data", function(data) if not state.DISCUSSION_DATA then diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index 719c48f7..6dcd6011 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -94,6 +94,8 @@ local function content() resolved_notes = resolved_notes, non_resolvable_notes = non_resolvable_notes, help_keymap = state.settings.keymaps.help, + ahead = state.ahead_behind[1], + behind = state.ahead_behind[2], updated = updated, } @@ -158,7 +160,7 @@ end M.make_winbar = function(t) local discussions_focused = M.current_view_type == "discussions" local discussion_text = add_drafts_and_resolvable( - "Inline Comments:", + "Comments:", t.resolvable_discussions, t.resolved_discussions, t.inline_draft_notes, @@ -190,15 +192,18 @@ M.make_winbar = function(t) local separator = "%#Comment#|" local end_section = "%=" local updated = "%#Text#" .. t.updated + local ahead_behind = M.get_ahead_behind(t.ahead, t.behind) local help = "%#Comment#Help: " .. (t.help_keymap and t.help_keymap:gsub(" ", "") .. " " or "unmapped") return string.format( - " %s %s %s %s %s %s %s %s %s %s %s", + " %s %s %s %s %s %s %s %s %s %s %s %s %s", discussion_text, separator, notes_text, end_section, updated, separator, + ahead_behind, + separator, sort_method, separator, mode, @@ -254,6 +259,16 @@ M.get_mode = function() end end +---@param ahead number|nil +---@param behind number|nil +M.get_ahead_behind = function(ahead, behind) + local a = ahead == nil and "?" or tostring(ahead) + local b = behind == nil and "?" or tostring(behind) + a = ((a == "?" or a == "0") and "%#Comment#" or "%#WarningMsg#") .. a + b = ((b == "?" or b == "0") and "%#Comment#" or "%#WarningMsg#") .. b + return a .. "↑ " .. b .. "↓" +end + ---Toggles the current view type (or sets it to `override`) and then updates the view. ---@param override "discussions"|"notes" Defines the view type to select. M.switch_view_type = function(override) diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index e1403249..cccd4608 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -92,6 +92,8 @@ ---@field resolved_notes number ---@field non_resolvable_notes number ---@field help_keymap string +---@field ahead number|nil -- Number of commits local is ahead of remote +---@field behind number|nil -- Number of commits local is behind remote ---@field updated string --- ---@class SignTable diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index ba42546e..ae139df5 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -74,14 +74,17 @@ M.fetch_remote_branch = function(remote_branch) return true end ----Determines whether the tracking branch is ahead of or behind the current branch, and warns the user if so ----@param current_branch string ----@param remote_branch string ----@param log_level number ----@return boolean -M.get_ahead_behind = function(current_branch, remote_branch, log_level) +---Determines whether the tracking branch is ahead of or behind the current branch and returns the +---number of ahead and behind commits or nil values in case of errors. +---@param current_branch string|nil +---@param remote_branch string|nil +---@return integer|nil ahead, integer|nil behind +M.get_ahead_behind = function(current_branch, remote_branch) + if current_branch == nil or remote_branch == nil then + return nil, nil + end if not M.fetch_remote_branch(remote_branch) then - return false + return nil, nil end local u = require("gitlab.utils") @@ -89,39 +92,16 @@ M.get_ahead_behind = function(current_branch, remote_branch, log_level) run_system({ "git", "rev-list", "--left-right", "--count", current_branch .. "..." .. remote_branch }) if err ~= nil or result == nil then u.notify("Could not determine if branch is up-to-date: " .. err, vim.log.levels.ERROR) - return false + return nil, nil end local ahead, behind = result:match("(%d+)%s+(%d+)") if ahead == nil or behind == nil then - u.notify("Error parsing ahead/behind information.", vim.log.levels.ERROR) - return false - end - - ahead = tonumber(ahead) - behind = tonumber(behind) - - if ahead > 0 and behind == 0 then - u.notify(string.format("There are local changes that haven't been pushed to %s yet", remote_branch), log_level) - return false - end - if behind > 0 and ahead == 0 then - u.notify(string.format("There are remote changes on %s that haven't been pulled yet", remote_branch), log_level) - return false - end - - if ahead > 0 and behind > 0 then - u.notify( - string.format( - "Your branch and the remote %s have diverged. You need to pull, possibly rebase, and then push.", - remote_branch - ), - log_level - ) - return false + u.notify("Error parsing ahead/behind information", vim.log.levels.ERROR) + return nil, nil end - return true -- Checks passed, branch is up-to-date + return tonumber(ahead), tonumber(behind) end ---Return the name of the current branch or nil if it can't be retrieved @@ -184,16 +164,35 @@ end ---@return boolean M.check_current_branch_up_to_date_on_remote = function(log_level) local current_branch = M.get_current_branch() - if current_branch == nil then + local remote_branch = M.get_remote_branch() + local ahead, behind = M.get_ahead_behind(current_branch, remote_branch) + if ahead == nil or behind == nil then return false end - local remote_branch = M.get_remote_branch() - if remote_branch == nil then + local u = require("gitlab.utils") + + if ahead > 0 and behind == 0 then + u.notify(string.format("There are local changes that haven't been pushed to %s yet", remote_branch), log_level) + return false + end + if behind > 0 and ahead == 0 then + u.notify(string.format("There are remote changes on %s that haven't been pulled yet", remote_branch), log_level) return false end - return M.get_ahead_behind(current_branch, remote_branch, log_level) + if ahead > 0 and behind > 0 then + u.notify( + string.format( + "Your branch and the remote %s have diverged. You need to pull, possibly rebase, and then push.", + remote_branch + ), + log_level + ) + return false + end + + return true -- Checks passed, branch is up-to-date end ---Warns user if the current MR is in a bad state (closed, has conflicts, merged) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index d67e0c06..7a655a72 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -3,16 +3,17 @@ -- This module is also responsible for ensuring that the state of the plugin -- is valid via dependencies -local git = require("gitlab.git") local u = require("gitlab.utils") local List = require("gitlab.utils.list") -local M = {} - -M.emoji_map = nil +local M = { + emoji_map = nil, + ahead_behind = { nil, nil }, +} ---Returns a gitlab token, and a gitlab URL. Used to connect to gitlab. ---@return string|nil, string|nil, string|nil M.default_auth_provider = function() + local git = require("gitlab.git") local base_path, err = M.settings.config_path, nil if base_path == nil then base_path, err = git.base_dir() From 6ed09564b70a65750281c5540729a30f87f4ea13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Tue, 3 Mar 2026 00:16:57 +0100 Subject: [PATCH 14/31] fix: use correct dependency key --- lua/gitlab/actions/approvals.lua | 6 ++++-- lua/gitlab/actions/summary.lua | 2 +- lua/gitlab/state.lua | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lua/gitlab/actions/approvals.lua b/lua/gitlab/actions/approvals.lua index 12b3e09d..739f00df 100644 --- a/lua/gitlab/actions/approvals.lua +++ b/lua/gitlab/actions/approvals.lua @@ -6,8 +6,10 @@ local M = {} local refresh_status_state = function(data) u.notify(data.message, vim.log.levels.INFO) - state.load_new_state("info", function() - require("gitlab.actions.summary").update_summary_details() + state.load_new_state("mergeability", function() + state.load_new_state("info", function() + require("gitlab.actions.summary").update_summary_details() + end) end) end diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 5a8662aa..f9feee03 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -114,7 +114,7 @@ end ---@return string[] local make_mergeability_checks = function() local lines = {} - for _, check in ipairs(state.MERGEABILITY.mergeability_checks) do + for _, check in ipairs(state.MERGEABILITY) do local status = state.settings.mergeability_checks.statuses[check.status] if status == nil then u.notify(string.format("Unknown mergeability check status: %s", check.status), vim.log.levels.ERROR) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index c1fdb673..5df9c1c3 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -498,7 +498,7 @@ M.dependencies = { }, mergeability = { endpoint = "/mr/info/mergeability", - key = "MergeabilityChecks", + key = "mergeability_checks", state = "MERGEABILITY", refresh = false, }, From 094738fc74963fa8d56393af41374014c29514c6 Mon Sep 17 00:00:00 2001 From: SlayerOfTheBad Date: Wed, 25 Mar 2026 09:48:51 +0100 Subject: [PATCH 15/31] Prevent usage of external diff tools Makes `parse_hunks_and_diff` pass `--no-ext-diff` to `git diff` to prevent an external diff tool configured in `~/.gitconfig` clobering the diff output it depends on to parse the hunks. --- lua/gitlab/hunks.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/gitlab/hunks.lua b/lua/gitlab/hunks.lua index cbedf0f4..a8231a98 100644 --- a/lua/gitlab/hunks.lua +++ b/lua/gitlab/hunks.lua @@ -102,6 +102,7 @@ local parse_hunks_and_diff = function(base_sha) "--minimal", "--unified=0", "--no-color", + "--no-ext-diff", base_sha, "--", reviewer.get_current_file_oldpath(), From ffe34357f3a3680e9a3300968ab4631579c00f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 18 Feb 2026 01:31:01 +0100 Subject: [PATCH 16/31] style: fix indentation --- doc/gitlab.nvim.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 1040deff..3f6ab0d9 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -1117,7 +1117,7 @@ execute and passed the data as an argument. with each resource as a key-value pair, with the key being it's type. - *gitlab.nvim.refresh_data* + *gitlab.nvim.refresh_data* gitlab.refresh_data() ~ Fetches discussion tree data from Gitlab and refreshes the tree views. It can From 863e1e716b3b53baf0c8402c5cfe8374c85422ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 18 Feb 2026 01:17:28 +0100 Subject: [PATCH 17/31] feat!: implement AutoMerge option to AcceptMergeRequest #518 This PR also fixes a bug when the `Delete source branch` setting of the MR was not respected due to incorrect reference in `lua/gitlab/actions/merge.lua` to `state.INFO.delete_branch` instead of `state.INFO.force_remove_source_branch`. This PR also changes the behaviour of how the `opts` in `gitlab.merge(opts)` behave: - Before this PR: - when `lua require("gitlab").merge()` was run without parameters, the values visible in the Summary view were used (except for the bug mentioned above). - specifying one of `squash` or `delete_branch` in `opts` caused the other parameter to default to `false` even if Summary view would show it's set to `true`. - After this PR: - running `lua require("gitlab").merge()` without parameters doesn't change. - specifying any of the `opts` values (`auto_merge`, `squash`, `delete_branch`), has no effect on the other options - if not specified in the `gitlab.merge()` call, the values from the Summary are used. This is a breaking change, since theoretically, before, a user could rely on the fact that ignoring one of the options would set it to `false` which now is not guaranteed (the value depends on the existing MR settings). --- cmd/app/merge_mr.go | 10 +++++++++- doc/gitlab.nvim.txt | 26 +++++++++++++++++--------- lua/gitlab/actions/merge.lua | 14 +++++++++----- lua/gitlab/actions/summary.lua | 1 + lua/gitlab/annotations.lua | 2 +- lua/gitlab/state.lua | 1 + 6 files changed, 38 insertions(+), 16 deletions(-) diff --git a/cmd/app/merge_mr.go b/cmd/app/merge_mr.go index f4f2e878..eb839622 100644 --- a/cmd/app/merge_mr.go +++ b/cmd/app/merge_mr.go @@ -8,6 +8,7 @@ import ( ) type AcceptMergeRequestRequest struct { + AutoMerge bool `json:"auto_merge"` DeleteBranch bool `json:"delete_branch"` SquashMessage string `json:"squash_message"` Squash bool `json:"squash"` @@ -27,6 +28,7 @@ func (a mergeRequestAccepterService) ServeHTTP(w http.ResponseWriter, r *http.Re payload := r.Context().Value(payload("payload")).(*AcceptMergeRequestRequest) opts := gitlab.AcceptMergeRequestOptions{ + AutoMerge: &payload.AutoMerge, Squash: &payload.Squash, ShouldRemoveSourceBranch: &payload.DeleteBranch, } @@ -47,7 +49,13 @@ func (a mergeRequestAccepterService) ServeHTTP(w http.ResponseWriter, r *http.Re return } - response := SuccessResponse{Message: "MR merged successfully"} + var message string + if payload.AutoMerge { + message = "MR set to be merged when all checks pass" + } else { + message = "MR merged successfully" + } + response := SuccessResponse{Message: message} w.WriteHeader(http.StatusOK) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 3f6ab0d9..90d6939c 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -9,7 +9,7 @@ Table of Contents *gitlab.nvim.table-of-contents* - Connecting to Gitlab |gitlab.nvim.connecting-to-gitlab| - Configuring the Plugin |gitlab.nvim.configuring-the-plugin| - Usage |gitlab.nvim.usage| - - The Summary view |gitlab.nvim.the-summary-view| + - The Summary view |gitlab.nvim.summary-view| - Reviewing an MR |gitlab.nvim.reviewing-an-mr| - Temporary registers |gitlab.nvim.temp-registers| - Discussions and Notes |gitlab.nvim.discussions-and-notes| @@ -308,6 +308,7 @@ you call this function with no values the defaults will be used: "pipeline", "branch", "target_branch", + "auto_merge", "delete_branch", "squash", "labels", @@ -415,7 +416,7 @@ Then open Neovim. To begin, try running the `summary` command or the `review` command. -THE SUMMARY VIEW *gitlab.nvim.the-summary-view* +THE SUMMARY VIEW *gitlab.nvim.summary-view* The `summary` action will open the MR title and description: >lua @@ -1058,18 +1059,25 @@ Copies the URL of the current MR to system clipboard. *gitlab.nvim.merge* gitlab.merge({opts}) ~ -Merges the merge request into the target branch. When run without any -arguments, the `merge` action will respect the "Squash commits" and "Delete -source branch" settings set by `require("gitlab").create_mr()` or set in -Gitlab online. You can see the current settings in the Summary view, see -|gitlab.nvim.the-summary-view|. +Merges a mergeable merge request into the target branch. The behaviour can be +configured with the `opts` parameter. By default, the `merge` action respects +the "Auto-merge" setting, and the "Squash commits" and "Delete source branch" +settings set by `require("gitlab").create_mr()` (or in Gitlab's "Edit merge +request" page). You can see the current settings in the Summary view, see +|gitlab.nvim.summary-view|. >lua require("gitlab").merge() - require("gitlab").merge({ squash = false, delete_branch = true }) + require("gitlab").merge({ + auto_merge = true, squash = false, delete_branch = false + }) < Parameters: ~ • {opts}: (table|nil) Keyword arguments that can be used to override - default behavior. + individual current settings. + • {auto_merge}: (bool) If true, the merge request is set to merge + automatically when the pipeline succeeds. If false, an immediate + merge is attempted. Currently, the plugin doesn't allow + cancelling the auto-merge. • {delete_branch}: (bool) If true, the source branch will be deleted. • {squash}: (bool) If true, the commits will be squashed. If diff --git a/lua/gitlab/actions/merge.lua b/lua/gitlab/actions/merge.lua index 81eed3f6..5e0e5478 100644 --- a/lua/gitlab/actions/merge.lua +++ b/lua/gitlab/actions/merge.lua @@ -12,19 +12,23 @@ local function create_squash_message_popup() end ---@class MergeOpts +---@field auto_merge boolean? ---@field delete_branch boolean? ---@field squash boolean? ---@field squash_message string? ---@param opts MergeOpts M.merge = function(opts) - local merge_body = { squash = state.INFO.squash, delete_branch = state.INFO.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 + local merge_body = { + auto_merge = state.INFO.merge_when_pipeline_succeeds, + squash = state.INFO.squash, + delete_branch = state.INFO.force_remove_source_branch, + } + for key, val in pairs(opts or {}) do + merge_body[key] = val end - if state.INFO.detailed_merge_status ~= "mergeable" then + if state.INFO.detailed_merge_status ~= "mergeable" and not merge_body.auto_merge then u.notify(string.format("MR not mergeable, currently '%s'", state.INFO.detailed_merge_status), vim.log.levels.ERROR) return end diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index f9feee03..651b7a47 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -148,6 +148,7 @@ M.build_info_lines = function() branch = { title = "Branch", content = info.source_branch }, labels = { title = "Labels", content = table.concat(info.labels, ", ") }, target_branch = { title = "Target Branch", content = info.target_branch }, + auto_merge = { title = "Auto-merge", content = (info.merge_when_pipeline_succeeds and "Yes" or "No") }, delete_branch = { title = "Delete Source Branch", content = (info.force_remove_source_branch and "Yes" or "No"), diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index a3fcb3ac..a19edb1e 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -255,7 +255,7 @@ ---@class InfoSettings ---@field horizontal? boolean -- Display metadata to the left of the summary rather than underneath ----@field fields? ("author" | "created_at" | "updated_at" | "merge_status" | "draft" | "conflicts" | "assignees" | "reviewers" | "pipeline" | "branch" | "target_branch" | "delete_branch" | "squash" | "labels" | "web_url" | "mergeability_checks")[] +---@field fields? ("author" | "created_at" | "updated_at" | "merge_status" | "draft" | "conflicts" | "assignees" | "reviewers" | "pipeline" | "branch" | "target_branch" | "auto_merge" | "delete_branch" | "squash" | "labels" | "web_url" | "mergeability_checks")[] ---@class MergeabilityChecksSettings ---@field statuses MergeabilityStatuses diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 5db54a11..20e5a18f 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -215,6 +215,7 @@ M.settings = { "pipeline", "branch", "target_branch", + "auto_merge", "delete_branch", "squash", "labels", From 32043384a34ead6e8ca17a2527af913471bc898b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 18 Feb 2026 01:51:41 +0100 Subject: [PATCH 18/31] feat: add default global keymap for auto-merge --- README.md | 1 + doc/gitlab.nvim.txt | 1 + lua/gitlab/state.lua | 7 +++++++ 3 files changed, 9 insertions(+) diff --git a/README.md b/README.md index 12ca0cb7..a4485782 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ glrd Delete reviewer glA Approve MR glR Revoke MR approval glM Merge the feature branch to the target branch and close MR +glm Set MR to merge automatically when the pipeline succeeds 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/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 90d6939c..66c932a2 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -179,6 +179,7 @@ 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 + set_auto_merge = "glm", -- Set MR to merge automatically when the pipeline succeeds 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 diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 20e5a18f..ab552975 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -82,6 +82,7 @@ M.settings = { approve = "glA", revoke = "glR", merge = "glM", + set_auto_merge = "glm", create_mr = "glC", choose_merge_request = "glc", start_review = "glS", @@ -420,6 +421,12 @@ M.set_global_keymaps = function() end, { desc = "Merge MR", nowait = keymaps.global.merge_nowait }) end + if keymaps.global.set_auto_merge then + vim.keymap.set("n", keymaps.global.set_auto_merge, function() + require("gitlab").merge({auto_merge = true}) + end, { desc = "Set MR to auto-merge", nowait = keymaps.global.set_auto_merge_nowait }) + end + if keymaps.global.copy_mr_url then vim.keymap.set("n", keymaps.global.copy_mr_url, function() require("gitlab").copy_mr_url() From 8fbf5b4de0702b1dad813a163968acc0effa3413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 18 Feb 2026 09:47:40 +0100 Subject: [PATCH 19/31] style: apply stylua --- lua/gitlab/state.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index ab552975..a784d36c 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -423,7 +423,7 @@ M.set_global_keymaps = function() if keymaps.global.set_auto_merge then vim.keymap.set("n", keymaps.global.set_auto_merge, function() - require("gitlab").merge({auto_merge = true}) + require("gitlab").merge({ auto_merge = true }) end, { desc = "Set MR to auto-merge", nowait = keymaps.global.set_auto_merge_nowait }) end From 4b29e574e88d0092b2eab510bc4d3565fd53aa0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lan=20Cr=C3=ADstoffer?= Date: Thu, 12 Feb 2026 14:47:17 +0100 Subject: [PATCH 20/31] feat: better handling of server installation path The plugin should not assume its folder is writable. Instead, try to search for a writable folder by checking the data folder and the runtime paths. Also allows the user to give the path to the server's executable intead of always compiling it. All of this makes the plugin friendlier to restricted/read-only environments, like nix. --- doc/gitlab.nvim.txt | 7 +++-- flake.lock | 61 +++++++++++++++++++++++++++++++++++++++++++ flake.nix | 47 +++++++++++++++++++++++++++++++++ lua/gitlab/init.lua | 2 +- lua/gitlab/job.lua | 7 ++++- lua/gitlab/server.lua | 61 +++++++++++++++++++++++++++++++++++-------- lua/gitlab/state.lua | 5 +++- 7 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 1040deff..67a4439e 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -144,7 +144,10 @@ Here is the default setup function. All of these values are optional, and if you call this function with no values the defaults will be used: >lua require("gitlab").setup({ - port = nil, -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically + server = { + binary = nil, -- The path to the server binary. If omitted or nil, the server will be built + port = nil, -- The port of the Go server, which runs in the background. If omitted or `nil` the port will be chosen automatically + }, log_path = vim.fn.stdpath("cache") .. "/gitlab.nvim.log", -- Log path for the Go server config_path = nil, -- Custom path for `.gitlab.nvim` file, please read the "Connecting to Gitlab" section debug = { @@ -711,7 +714,7 @@ by a |motion|. Either the operator or the motion can be preceded by a count, so that `3sj` is equivalent to `s3j`, and they both create a comment for the current line and three more lines downwards. Similarly, both `2s`|ap| and `s2`|ap| create a suggestion -for two "outer" paragraphs. +for two "outer" paragraphs. The operators force |linewise| visual selection, so they work correctly even if the motion itself works |characterwise| (e.g., |i(| for selecting the inner diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..8f8b8f30 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1770770419, + "narHash": "sha256-iKZMkr6Cm9JzWlRYW/VPoL0A9jVKtZYiU4zSrVeetIs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6c5e707c6b5339359a9a9e215c5e66d6d802fd7a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..a414bbfd --- /dev/null +++ b/flake.nix @@ -0,0 +1,47 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, flake-utils, nixpkgs }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; config.allowUnfree = true; }; + gitlab-nvim-server = pkgs.buildGoModule { + pname = "gitlab.nvim-server"; + version = "git"; + src = ./.; + vendorHash = "sha256-OLAKTdzqynBDHqWV5RzIpfc3xZDm6uYyLD4rxbh0DMg="; + postInstall = '' + cp -r ${./cmd/config} $out/bin/config + mv $out/bin/cmd $out/bin/gitlab.nvim + ''; + }; + gitlab-nvim = pkgs.vimUtils.buildVimPlugin { + name = "gitlab.nvim"; + src = ./.; + doCheck = false; + }; + in + rec { + formatter = pkgs.nixpkgs-fmt; + packages.gitlab-nvim-server = gitlab-nvim-server; + packages.gitlab-nvim = gitlab-nvim; + packages.default = packages.gitlab-nvim; + devShell = pkgs.mkShell { + packages = with pkgs; [ + git + go + go-tools + golangci-lint + luajitPackages.busted + luajitPackages.luacheck + luajitPackages.luarocks + neovim + stylua + ]; + }; + } + ); +} diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index da066e6b..e788a518 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -45,8 +45,8 @@ local function setup(args) return end - server.build() -- Builds the Go binary if it doesn't exist state.merge_settings(args) -- Merges user settings with default settings + server.build() -- Builds the Go binary if it doesn't exist state.set_global_keymaps() -- Sets keymaps that are not bound to a specific buffer require("gitlab.colors") -- Sets colors reviewer.init() diff --git a/lua/gitlab/job.lua b/lua/gitlab/job.lua index 33308210..231e0110 100644 --- a/lua/gitlab/job.lua +++ b/lua/gitlab/job.lua @@ -6,7 +6,12 @@ local M = {} M.run_job = function(endpoint, method, body, callback, on_error_callback) local state = require("gitlab.state") - local args = { "-s", "-X", (method or "POST"), string.format("localhost:%s", state.settings.port) .. endpoint } + local port = state.settings.server and state.settings.server.port + if state.settings.port ~= nil then + port = state.settings.port + u.notify("The setting `port` has been renamed `server.port`", vim.log.levels.WARN) + end + local args = { "-s", "-X", (method or "POST"), string.format("localhost:%s%s", port, endpoint) } if body ~= nil then local encoded_body = vim.json.encode(body) diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua index d9ff6305..55f04de8 100644 --- a/lua/gitlab/server.lua +++ b/lua/gitlab/server.lua @@ -30,7 +30,7 @@ end -- Starts the Go server and call the callback provided M.start = function(callback) - local port = tonumber(state.settings.port) or 0 + local port = tonumber(state.settings.server.port) or 0 local parsed_port = nil local callback_called = false @@ -51,7 +51,7 @@ M.start = function(callback) settings = settings:gsub('"', '\\"') end - local command = string.format('"%s" "%s"', state.settings.bin, settings) + local command = string.format('"%s" "%s"', state.settings.server.binary, settings) local job_id = vim.fn.jobstart(command, { on_stdout = function(_, data) @@ -61,7 +61,7 @@ M.start = function(callback) port = line:match("Server started on port:%s+(%d+)") if port ~= nil then parsed_port = port - state.settings.port = port + state.settings.server.port = port break end end @@ -105,26 +105,59 @@ end -- Builds the Go binary with the current Git tag. M.build = function(override) local file_path = u.current_file_path() - local parent_dir = vim.fn.fnamemodify(file_path, ":h:h:h:h") + state.settings.root_path = vim.fn.fnamemodify(file_path, ":h:h:h:h") + + -- If the user provided a path to the server, don't build it. + if state.settings.server.binary ~= nil then + local binary_exists = vim.loop.fs_stat(state.settings.server.binary) + if binary_exists == nil then + u.notify( + string.format("The user-provided server path (%s) does not exist.", state.settings.server.binary), + vim.log.levels.ERROR + ) + end + return + end + + -- If the user did not provide a path, we build it and place it in either the data path, or the + -- first writable path we find in the runtime. + local datapath = vim.fn.stdpath("data") + local runtimepath = vim.api.nvim_list_runtime_paths() + table.insert(runtimepath, 1, datapath) + + local bin_folder + for _, path in ipairs(runtimepath) do + local ok, err = vim.loop.fs_access(path, "w") + if err == nil and ok ~= nil and ok then + bin_folder = path .. u.path_separator .. "gitlab.nvim" .. u.path_separator .. "bin" + if vim.fn.mkdir(bin_folder, "p") == 1 then + state.settings.server.binary = bin_folder .. u.path_separator .. "server" + break + end + end + end - local bin_name = u.is_windows() and "bin.exe" or "bin" - state.settings.root_path = parent_dir - state.settings.bin = parent_dir .. u.path_separator .. "cmd" .. u.path_separator .. bin_name + if state.settings.server.binary == nil then + u.notify("Could not find a writable folder in the runtime path to save the server to.", vim.log.levels.ERROR) + return + end if not override then - local binary_exists = vim.loop.fs_stat(state.settings.bin) + local binary_exists = vim.loop.fs_stat(state.settings.server.binary) if binary_exists ~= nil then return end end - local version_output = vim.system({ "git", "describe", "--tags", "--always" }, { cwd = parent_dir }):wait() + local version_output = vim + .system({ "git", "describe", "--tags", "--always" }, { cwd = state.settings.root_path }) + :wait() local version = version_output.code == 0 and vim.trim(version_output.stdout) or "unknown" local ldflags = string.format("-X main.Version=%s", version) local res = vim .system( - { "go", "build", "-ldflags", ldflags, "-o", bin_name }, + { "go", "build", "-buildvcs=false", "-ldflags", ldflags, "-o", state.settings.server.binary }, { cwd = state.settings.root_path .. u.path_separator .. "cmd" } ) :wait() @@ -133,6 +166,12 @@ M.build = function(override) u.notify(string.format("Failed to install with status code %d:\n%s", res.code, res.stderr), vim.log.levels.ERROR) return false end + + local Path = require("plenary.path") + local src = Path:new(state.settings.root_path .. u.path_separator .. "cmd" .. u.path_separator .. "config") + local dest = Path:new(bin_folder .. u.path_separator .. "config") + src:copy({ destination = dest, recursive = true, override = true }) + u.notify("Installed successfully!", vim.log.levels.INFO) return true end @@ -185,7 +224,7 @@ M.get_version = function(callback) local version_output = vim.system({ "git", "describe", "--tags", "--always" }, { cwd = parent_dir }):wait() local plugin_version = version_output.code == 0 and vim.trim(version_output.stdout) or "unknown" - local args = { "-s", "-X", "GET", string.format("localhost:%s/version", state.settings.port) } + local args = { "-s", "-X", "GET", string.format("localhost:%s/version", state.settings.server.port) } -- We call the "/version" endpoint here instead of through the regular jobs pattern because earlier versions of the plugin -- may not have it. We handle a 404 as an "unknown" version error. diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 5db54a11..ecac43e4 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -46,7 +46,10 @@ end M.settings = { auth_provider = M.default_auth_provider, file_separator = u.path_separator, - port = nil, -- choose random port + server = { + binary = nil, + port = nil, + }, debug = { request = false, response = false, From 04c1eae501225e247b7d74c02460d761e8bae518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lan=20Cr=C3=ADstoffer?= Date: Fri, 13 Feb 2026 08:57:05 +0100 Subject: [PATCH 21/31] fix: set LC_TIME when testing, since the test expects it to be English. --- lua-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua-test.sh b/lua-test.sh index 6cf83dc9..300530c2 100755 --- a/lua-test.sh +++ b/lua-test.sh @@ -41,4 +41,4 @@ done # Run tests echo "Running tests with Neovim..." -nvim -u NONE -U NONE -N -i NONE -l tests/init.lua "$@" +LC_TIME=en_US.UTF-8 nvim -u NONE -U NONE -N -i NONE -l tests/init.lua "$@" From 750e329a0d0769515f53063b6c91f0d93c0ed09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lan=20Cr=C3=ADstoffer?= Date: Mon, 30 Mar 2026 15:39:14 +0200 Subject: [PATCH 22/31] fixup! feat: better handling of server installation path --- flake.nix | 8 ++++---- lua/gitlab/job.lua | 4 ---- lua/gitlab/server.lua | 10 ++++++---- lua/gitlab/utils/init.lua | 6 ++++++ 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/flake.nix b/flake.nix index a414bbfd..2d502043 100644 --- a/flake.nix +++ b/flake.nix @@ -7,7 +7,7 @@ outputs = { self, flake-utils, nixpkgs }: flake-utils.lib.eachDefaultSystem (system: let - pkgs = import nixpkgs { inherit system; config.allowUnfree = true; }; + pkgs = import nixpkgs { inherit system; }; gitlab-nvim-server = pkgs.buildGoModule { pname = "gitlab.nvim-server"; version = "git"; @@ -24,12 +24,12 @@ doCheck = false; }; in - rec { + { formatter = pkgs.nixpkgs-fmt; packages.gitlab-nvim-server = gitlab-nvim-server; packages.gitlab-nvim = gitlab-nvim; - packages.default = packages.gitlab-nvim; - devShell = pkgs.mkShell { + packages.default = gitlab-nvim; + devShells.default = pkgs.mkShell { packages = with pkgs; [ git go diff --git a/lua/gitlab/job.lua b/lua/gitlab/job.lua index 231e0110..3b5c0e3d 100644 --- a/lua/gitlab/job.lua +++ b/lua/gitlab/job.lua @@ -7,10 +7,6 @@ local M = {} M.run_job = function(endpoint, method, body, callback, on_error_callback) local state = require("gitlab.state") local port = state.settings.server and state.settings.server.port - if state.settings.port ~= nil then - port = state.settings.port - u.notify("The setting `port` has been renamed `server.port`", vim.log.levels.WARN) - end local args = { "-s", "-X", (method or "POST"), string.format("localhost:%s%s", port, endpoint) } if body ~= nil then diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua index 55f04de8..3f4d1b6a 100644 --- a/lua/gitlab/server.lua +++ b/lua/gitlab/server.lua @@ -30,6 +30,10 @@ end -- Starts the Go server and call the callback provided M.start = function(callback) + if state.settings.port ~= nil and state.settings.server.port == nil then + state.settings.server.port = state.settings.port + u.notify("The setting `port` has been renamed `server.port`", vim.log.levels.WARN) + end local port = tonumber(state.settings.server.port) or 0 local parsed_port = nil local callback_called = false @@ -104,8 +108,7 @@ end -- Builds the Go binary with the current Git tag. M.build = function(override) - local file_path = u.current_file_path() - state.settings.root_path = vim.fn.fnamemodify(file_path, ":h:h:h:h") + state.settings.root_path = u.get_root_path() -- If the user provided a path to the server, don't build it. if state.settings.server.binary ~= nil then @@ -218,8 +221,7 @@ M.get_version = function(callback) u.notify("Gitlab server not running", vim.log.levels.ERROR) return nil end - local file_path = u.current_file_path() - local parent_dir = vim.fn.fnamemodify(file_path, ":h:h:h:h") + local parent_dir = u.get_root_path() local version_output = vim.system({ "git", "describe", "--tags", "--always" }, { cwd = parent_dir }):wait() local plugin_version = version_output.code == 0 and vim.trim(version_output.stdout) or "unknown" diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index a1e61a90..a4990c11 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -509,6 +509,12 @@ M.current_file_path = function() return vim.fn.fnamemodify(path, ":p") end +-- Returns the root path of the plugin (four levels up from this file: lua/gitlab/utils/init.lua) +M.get_root_path = function() + local path = debug.getinfo(1, "S").source:sub(2) + return vim.fn.fnamemodify(path, ":p:h:h:h:h") +end + local random = math.random M.uuid = function() local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" From 4b9d3f56fc6e6452fbf74beab4963f105e71c09e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lan=20Cr=C3=ADstoffer?= Date: Mon, 30 Mar 2026 16:08:02 +0200 Subject: [PATCH 23/31] fixup! feat: better handling of server installation path --- .github/workflows/update-gomod2nix.yaml | 30 +++++++++++ flake.nix | 17 +++++-- gomod2nix.toml | 66 +++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/update-gomod2nix.yaml create mode 100644 gomod2nix.toml diff --git a/.github/workflows/update-gomod2nix.yaml b/.github/workflows/update-gomod2nix.yaml new file mode 100644 index 00000000..952a45a8 --- /dev/null +++ b/.github/workflows/update-gomod2nix.yaml @@ -0,0 +1,30 @@ +name: Update gomod2nix + +on: + push: + branches: + - develop + paths: + - go.mod + - go.sum + +jobs: + update: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: cachix/install-nix-action@v27 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Generate gomod2nix.toml + run: nix run github:nix-community/gomod2nix -- generate + + - name: Commit updated gomod2nix.toml + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: update gomod2nix.toml" + file_pattern: gomod2nix.toml diff --git a/flake.nix b/flake.nix index 2d502043..2563bb7e 100644 --- a/flake.nix +++ b/flake.nix @@ -2,17 +2,25 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; flake-utils.url = "github:numtide/flake-utils"; + gomod2nix = { + url = "github:nix-community/gomod2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = { self, flake-utils, nixpkgs }: + outputs = { self, flake-utils, nixpkgs, gomod2nix }: flake-utils.lib.eachDefaultSystem (system: let - pkgs = import nixpkgs { inherit system; }; - gitlab-nvim-server = pkgs.buildGoModule { + pkgs = import nixpkgs { + inherit system; + overlays = [ gomod2nix.overlays.default ]; + }; + gitlab-nvim-server = pkgs.buildGoApplication { pname = "gitlab.nvim-server"; version = "git"; src = ./.; - vendorHash = "sha256-OLAKTdzqynBDHqWV5RzIpfc3xZDm6uYyLD4rxbh0DMg="; + modules = ./gomod2nix.toml; + subPackages = [ "cmd" ]; postInstall = '' cp -r ${./cmd/config} $out/bin/config mv $out/bin/cmd $out/bin/gitlab.nvim @@ -34,6 +42,7 @@ git go go-tools + gomod2nix golangci-lint luajitPackages.busted luajitPackages.luacheck diff --git a/gomod2nix.toml b/gomod2nix.toml new file mode 100644 index 00000000..cb1c49f7 --- /dev/null +++ b/gomod2nix.toml @@ -0,0 +1,66 @@ +schema = 3 + +[mod] + [mod.'github.com/gabriel-vasile/mimetype'] + version = 'v1.4.3' + hash = 'sha256-EDmlRi3av27dq/ISVTglv08z4yZzMQ/SxL1c46EJro0=' + + [mod.'github.com/go-playground/locales'] + version = 'v0.14.1' + hash = 'sha256-BMJGAexq96waZn60DJXZfByRHb8zA/JP/i6f/YrW9oQ=' + + [mod.'github.com/go-playground/universal-translator'] + version = 'v0.18.1' + hash = 'sha256-2/B2qP51zfiY+k8G0w0D03KXUc7XpWj6wKY7NjNP/9E=' + + [mod.'github.com/go-playground/validator/v10'] + version = 'v10.22.1' + hash = 'sha256-EsgeltH0ow6saxLvTFVtIyHVqWI3Fiu1AE2Qmnsmowg=' + + [mod.'github.com/google/go-cmp'] + version = 'v0.7.0' + hash = 'sha256-JbxZFBFGCh/Rj5XZ1vG94V2x7c18L8XKB0N9ZD5F2rM=' + + [mod.'github.com/google/go-querystring'] + version = 'v1.2.0' + hash = 'sha256-F/Ve4oDaEqho8RryvdGSRR22/DbYHWZQa6M60n6oSYM=' + + [mod.'github.com/hashicorp/go-cleanhttp'] + version = 'v0.5.2' + hash = 'sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ=' + + [mod.'github.com/hashicorp/go-retryablehttp'] + version = 'v0.7.8' + hash = 'sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80=' + + [mod.'github.com/leodido/go-urn'] + version = 'v1.4.0' + hash = 'sha256-Q6kplWkY37Tzy6GOme3Wut40jFK4Izun+ij/BJvcEu0=' + + [mod.'gitlab.com/gitlab-org/api/client-go'] + version = 'v1.17.0' + hash = 'sha256-PdVbuFXCp/TphltAkpJG3YNXhtqHnhhyel9KLUX/xz0=' + + [mod.'golang.org/x/crypto'] + version = 'v0.19.0' + hash = 'sha256-Vi6vY/eWNlYQ9l3Y+gA+X2+h2CmzEOrBRVFO/cnrPWc=' + + [mod.'golang.org/x/net'] + version = 'v0.21.0' + hash = 'sha256-LfiqMpPtqvW/eLkfx6Ebr5ksqKbQli6uq06c/+XrBsw=' + + [mod.'golang.org/x/oauth2'] + version = 'v0.34.0' + hash = 'sha256-5eqpGGxJ7FJsPmfRek6roeGmkWHBMJaWYXyz8gXJsS4=' + + [mod.'golang.org/x/sys'] + version = 'v0.39.0' + hash = 'sha256-dxTBu/JAWUkPbjFIXXRFdhQWyn+YyEpIC+tWqGo0Y6U=' + + [mod.'golang.org/x/text'] + version = 'v0.32.0' + hash = 'sha256-9PXtWBKKY9rG4AgjSP4N+I1DhepXhy8SF/vWSIDIoWs=' + + [mod.'golang.org/x/time'] + version = 'v0.14.0' + hash = 'sha256-fVjpq0ieUHVEOTSElDVleMWvfdcqojZchqdUXiC7NnY=' From 63e5b95a132e969cc4ac71dfdfd2901931d07025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lan=20Cr=C3=ADstoffer?= Date: Tue, 31 Mar 2026 07:57:48 +0200 Subject: [PATCH 24/31] fixup! feat: better handling of server installation path --- lua/gitlab/utils/init.lua | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index a4990c11..da24c16b 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -504,11 +504,6 @@ M.read_file = function(file_path, opts) return file_contents end -M.current_file_path = function() - local path = debug.getinfo(1, "S").source:sub(2) - return vim.fn.fnamemodify(path, ":p") -end - -- Returns the root path of the plugin (four levels up from this file: lua/gitlab/utils/init.lua) M.get_root_path = function() local path = debug.getinfo(1, "S").source:sub(2) From d9ed62ff35c165188118cbd45b43007b66c53bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lan=20Cr=C3=ADstoffer?= Date: Tue, 31 Mar 2026 19:02:18 +0200 Subject: [PATCH 25/31] fixup! feat: better handling of server installation path --- lua/gitlab/annotations.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index a3fcb3ac..99447a95 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -148,7 +148,7 @@ --- Plugin Settings --- ---@class Settings ----@field port? number -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically +---@field server ServerSettings ---@field remote_branch "origin" | string -- The remote, "origin" by default ---@field log_path? string -- Log path for the Go server ---@field string? any -- Custom path for `.gitlab.nvim` file, please read the "Connecting to Gitlab" section @@ -167,6 +167,10 @@ ---@field create_mr? CreateMrSettings -- The settings when creating an MR ---@field colors? ColorSettings --- Colors settings for the plugin +---@class ServerSettings +---@field port? number -- The port of the Go server, which runs in the background, if omitted or `nil` the port will be chosen automatically +---@field binary? string -- The path to the server binary. If omitted or nil, the server will be built + ---@class DiscussionSigns: table ---@field enabled? boolean -- Show diagnostics for gitlab comments in the reviewer ---@field skip_resolved_discussion? boolean -- Show diagnostics for resolved discussions From 810f5bd43a4d958b96b7587954678fb4b6904c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 8 Apr 2026 18:02:21 +0200 Subject: [PATCH 26/31] docs: recommend using dlyongemallo's fork of diffview --- README.md | 6 ++++-- doc/gitlab.nvim.txt | 4 ++-- lua-test.sh | 2 +- lua/gitlab/health.lua | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 12ca0cb7..9ab02063 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ With Lazy: dependencies = { "MunifTanjim/nui.nvim", "nvim-lua/plenary.nvim", - "sindrets/diffview.nvim", + "dlyongemallo/diffview.nvim", -- Maintained fork of "sindrets/diffview.nvim". "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. }, @@ -59,7 +59,7 @@ And with pckr.nvim: requires = { "MunifTanjim/nui.nvim", "nvim-lua/plenary.nvim", - "sindrets/diffview.nvim", + "dlyongemallo/diffview.nvim", -- Maintained fork of "sindrets/diffview.nvim". "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. }, @@ -73,6 +73,8 @@ And with pckr.nvim: Add `branch = "develop",` to your configuration if you want to use the (possibly unstable) development version of `gitlab.nvim`. +`gitlab.nvim` uses the `diffview.nvim` plugin for showing the diffs in a MR. We recommend using [dlyongemallo's](https://github.com/dlyongemallo/diffview.nvim) fork which is the de-facto maintained version of the plugin with many fixes and improvements (e.g., marking files as viewed). + ## Contributing Contributions to the plugin are welcome. Please read [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) before you start working on a pull request. diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 1040deff..6ee885fa 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -64,7 +64,7 @@ With Lazy: dependencies = { "MunifTanjim/nui.nvim", "nvim-lua/plenary.nvim", - "sindrets/diffview.nvim", + "dlyongemallo/diffview.nvim", -- Maintained fork of "sindrets/diffview.nvim". "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. }, @@ -81,7 +81,7 @@ And with pckr.nvim: requires = { "MunifTanjim/nui.nvim", "nvim-lua/plenary.nvim", - "sindrets/diffview.nvim", + "dlyongemallo/diffview.nvim", -- Maintained fork of "sindrets/diffview.nvim". "stevearc/dressing.nvim", -- Recommended but not required. Better UI for pickers. "nvim-tree/nvim-web-devicons", -- Recommended but not required. Icons in discussion tree. }, diff --git a/lua-test.sh b/lua-test.sh index 6cf83dc9..164ce7a8 100755 --- a/lua-test.sh +++ b/lua-test.sh @@ -10,7 +10,7 @@ PLUGINS_FOLDER="tests/plugins" PLUGINS=( "https://github.com/MunifTanjim/nui.nvim" "https://github.com/nvim-lua/plenary.nvim" - "https://github.com/sindrets/diffview.nvim" + "https://github.com/dlyongemallo/diffview.nvim" ) if ! command -v luarocks >/dev/null 2>&1; then diff --git a/lua/gitlab/health.lua b/lua/gitlab/health.lua index a1ae8be5..46f58e21 100644 --- a/lua/gitlab/health.lua +++ b/lua/gitlab/health.lua @@ -31,7 +31,7 @@ M.check = function(return_results) package = "plenary", }, { - name = "sindrets/diffview.nvim", + name = "dlyongemallo/diffview.nvim", package = "diffview", }, } From 5ca461ebf89c6204393c0d4be0d5f84068792d2c Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Sat, 18 Apr 2026 14:00:10 -0400 Subject: [PATCH 27/31] fix(build): allow user-supplied binaries --- lua/gitlab/server.lua | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua index 3f4d1b6a..33303f08 100644 --- a/lua/gitlab/server.lua +++ b/lua/gitlab/server.lua @@ -12,8 +12,19 @@ local M = {} -- tag than the Lua code (exposed via the /version endpoint) then shuts down the server, rebuilds it, and -- restarts the server again. M.build_and_start = function(callback) - M.build(false) + if M.build(false) == false then + return + end + + -- When the user provides their own binary, skip the version check and rebuild cycle. + -- The user is responsible for keeping their binary up to date. + local user_provided_binary = state.settings.server.binary_provided + M.start(function() + if user_provided_binary then + callback() + return + end M.get_version(function(version) if version.plugin_version ~= version.binary_version then M.shutdown(function() @@ -112,12 +123,14 @@ M.build = function(override) -- If the user provided a path to the server, don't build it. if state.settings.server.binary ~= nil then + state.settings.server.binary_provided = true local binary_exists = vim.loop.fs_stat(state.settings.server.binary) if binary_exists == nil then u.notify( string.format("The user-provided server path (%s) does not exist.", state.settings.server.binary), vim.log.levels.ERROR ) + return false end return end @@ -128,13 +141,14 @@ M.build = function(override) local runtimepath = vim.api.nvim_list_runtime_paths() table.insert(runtimepath, 1, datapath) + local bin_name = u.is_windows() and "server.exe" or "server" local bin_folder for _, path in ipairs(runtimepath) do local ok, err = vim.loop.fs_access(path, "w") if err == nil and ok ~= nil and ok then bin_folder = path .. u.path_separator .. "gitlab.nvim" .. u.path_separator .. "bin" if vim.fn.mkdir(bin_folder, "p") == 1 then - state.settings.server.binary = bin_folder .. u.path_separator .. "server" + state.settings.server.binary = bin_folder .. u.path_separator .. bin_name break end end From a8a72863269568452068f4b691af05368c712cdf Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Sat, 18 Apr 2026 14:06:08 -0400 Subject: [PATCH 28/31] fix: add SECURITY_POLICY_PIPELINE_CHECK mergeability identifier GitLab's API can return this identifier but it wasn't in the known checks list, causing an "Unknown mergeability check identifier" error in the summary view. --- doc/gitlab.nvim.txt | 1 + lua/gitlab/annotations.lua | 1 + lua/gitlab/state.lua | 1 + 3 files changed, 3 insertions(+) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 67b20ccf..b95b55aa 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -346,6 +346,7 @@ you call this function with no values the defaults will be used: NOT_APPROVED = "All required approvals must be given", NOT_OPEN = "Merge request must be open", REQUESTED_CHANGES = "Change requests must be approved by the requesting user", + SECURITY_POLICY_PIPELINE_CHECK = "Security policy pipeline must succeed", SECURITY_POLICY_VIOLATIONS = "Security policies are satisfied", STATUS_CHECKS_MUST_PASS = "External status checks pass", TITLE_REGEX = "Title matches the expected regex", diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index 27fc958b..4b8dfa6c 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -287,6 +287,7 @@ ---@field NOT_APPROVED string|false ---@field NOT_OPEN string|false ---@field REQUESTED_CHANGES string|false +---@field SECURITY_POLICY_PIPELINE_CHECK string|false ---@field SECURITY_POLICY_VIOLATIONS string|false ---@field STATUS_CHECKS_MUST_PASS string|false ---@field TITLE_REGEX string|false diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index ba884a8a..3a6df9b5 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -250,6 +250,7 @@ M.settings = { NOT_APPROVED = "All required approvals must be given", NOT_OPEN = "Merge request must be open", REQUESTED_CHANGES = "Change requests must be approved by the requesting user", + SECURITY_POLICY_PIPELINE_CHECK = "Security policy pipeline must succeed", SECURITY_POLICY_VIOLATIONS = "Security policies are satisfied", STATUS_CHECKS_MUST_PASS = "External status checks pass", TITLE_REGEX = "Title matches the expected regex", From 7ad65dfcc61024bd851b94190a9b9329d9b02694 Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Sat, 18 Apr 2026 14:13:51 -0400 Subject: [PATCH 29/31] fix(browser): add Windows support for opening URLs Co-Authored-By: Claude Opus 4.6 (1M context) --- lua/gitlab/utils/init.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index da24c16b..0b239630 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -667,6 +667,8 @@ end M.open_in_browser = function(url) if vim.fn.has("mac") == 1 then vim.fn.jobstart({ "open", url }) + elseif vim.fn.has("win32") == 1 then + vim.fn.jobstart({ "cmd", "/c", "start", url }) elseif vim.fn.has("unix") == 1 then vim.fn.jobstart({ "xdg-open", url }) else From 2fd25eb3169acc63c174eb9e94646a24f1b297e4 Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Sat, 18 Apr 2026 14:17:35 -0400 Subject: [PATCH 30/31] fix(proxy): bypass proxy for localhost requests to Go server Co-Authored-By: Claude Opus 4.6 (1M context) --- lua/gitlab/job.lua | 2 +- lua/gitlab/server.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/job.lua b/lua/gitlab/job.lua index 3b5c0e3d..b85952fc 100644 --- a/lua/gitlab/job.lua +++ b/lua/gitlab/job.lua @@ -7,7 +7,7 @@ local M = {} M.run_job = function(endpoint, method, body, callback, on_error_callback) local state = require("gitlab.state") local port = state.settings.server and state.settings.server.port - local args = { "-s", "-X", (method or "POST"), string.format("localhost:%s%s", port, endpoint) } + local args = { "--noproxy", "localhost", "-s", "-X", (method or "POST"), string.format("localhost:%s%s", port, endpoint) } if body ~= nil then local encoded_body = vim.json.encode(body) diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua index 33303f08..ae40535d 100644 --- a/lua/gitlab/server.lua +++ b/lua/gitlab/server.lua @@ -240,7 +240,7 @@ M.get_version = function(callback) local version_output = vim.system({ "git", "describe", "--tags", "--always" }, { cwd = parent_dir }):wait() local plugin_version = version_output.code == 0 and vim.trim(version_output.stdout) or "unknown" - local args = { "-s", "-X", "GET", string.format("localhost:%s/version", state.settings.server.port) } + local args = { "--noproxy", "localhost", "-s", "-X", "GET", string.format("localhost:%s/version", state.settings.server.port) } -- We call the "/version" endpoint here instead of through the regular jobs pattern because earlier versions of the plugin -- may not have it. We handle a 404 as an "unknown" version error. From 4a890ea41c7e05da6456890a7ad704bad5804685 Mon Sep 17 00:00:00 2001 From: Harrison Cramer Date: Sat, 18 Apr 2026 14:20:11 -0400 Subject: [PATCH 31/31] formatting --- lua/gitlab/job.lua | 3 ++- lua/gitlab/server.lua | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/job.lua b/lua/gitlab/job.lua index b85952fc..81cb5d66 100644 --- a/lua/gitlab/job.lua +++ b/lua/gitlab/job.lua @@ -7,7 +7,8 @@ local M = {} M.run_job = function(endpoint, method, body, callback, on_error_callback) local state = require("gitlab.state") local port = state.settings.server and state.settings.server.port - local args = { "--noproxy", "localhost", "-s", "-X", (method or "POST"), string.format("localhost:%s%s", port, endpoint) } + local args = + { "--noproxy", "localhost", "-s", "-X", (method or "POST"), string.format("localhost:%s%s", port, endpoint) } if body ~= nil then local encoded_body = vim.json.encode(body) diff --git a/lua/gitlab/server.lua b/lua/gitlab/server.lua index ae40535d..f4f99c99 100644 --- a/lua/gitlab/server.lua +++ b/lua/gitlab/server.lua @@ -240,7 +240,8 @@ M.get_version = function(callback) local version_output = vim.system({ "git", "describe", "--tags", "--always" }, { cwd = parent_dir }):wait() local plugin_version = version_output.code == 0 and vim.trim(version_output.stdout) or "unknown" - local args = { "--noproxy", "localhost", "-s", "-X", "GET", string.format("localhost:%s/version", state.settings.server.port) } + local args = + { "--noproxy", "localhost", "-s", "-X", "GET", string.format("localhost:%s/version", state.settings.server.port) } -- We call the "/version" endpoint here instead of through the regular jobs pattern because earlier versions of the plugin -- may not have it. We handle a 404 as an "unknown" version error.