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] 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 30e9c82..0663d2d 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 0000000..b64db6e --- /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 0000000..289bc1c --- /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 5fff24e..f143904 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 2db9f63..1040def 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 55aa794..1959bf5 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 87afc83..778f85d 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 e140324..2448be2 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 8c52c73..da066e6 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 d67e0c0..c1fdb67 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",