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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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