Skip to content

Commit 250ba35

Browse files
committed
feat: add mergeability checks to summary view
1 parent 3d2828a commit 250ba35

10 files changed

Lines changed: 349 additions & 11 deletions

File tree

cmd/app/client.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type Client struct {
3232
gitlab.UsersServiceInterface
3333
gitlab.DraftNotesServiceInterface
3434
gitlab.ProjectMarkdownUploadsServiceInterface
35+
gitlab.GraphQLInterface
3536
}
3637

3738
/* NewClient parses and validates the project settings and initializes the Gitlab client. */
@@ -100,6 +101,7 @@ func NewClient() (*Client, error) {
100101
client.Users,
101102
client.DraftNotes,
102103
client.ProjectMarkdownUploads,
104+
client.GraphQL,
103105
}, nil
104106
}
105107

cmd/app/mergeability_checks.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package app
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
8+
gitlab "gitlab.com/gitlab-org/api/client-go"
9+
)
10+
11+
type MergeabilityCheck struct {
12+
Identifier string `json:"identifier"`
13+
Status string `json:"status"`
14+
}
15+
16+
type MergeabilityChecksResponse struct {
17+
SuccessResponse
18+
MergeabilityChecks []*MergeabilityCheck `json:"mergeability_checks"`
19+
}
20+
21+
type mergeabilityChecksGraphQLResponse struct {
22+
Data struct {
23+
Project struct {
24+
MergeRequest struct {
25+
MergeabilityChecks []*MergeabilityCheck `json:"mergeabilityChecks"`
26+
} `json:"mergeRequest"`
27+
} `json:"project"`
28+
} `json:"data"`
29+
}
30+
31+
const mergeabilityChecksQuery = `
32+
query GetMergeabilityChecks($projectPath: ID!, $iid: String!) {
33+
project(fullPath: $projectPath) {
34+
mergeRequest(iid: $iid) {
35+
mergeabilityChecks {
36+
identifier
37+
status
38+
}
39+
}
40+
}
41+
}
42+
`
43+
44+
type mergeabilityChecksService struct {
45+
data
46+
client gitlab.GraphQLInterface
47+
}
48+
49+
func (a mergeabilityChecksService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
50+
checks, err := a.fetchMergeabilityChecks()
51+
if err != nil {
52+
handleError(w, err, "Could not get mergeability checks", http.StatusInternalServerError)
53+
return
54+
}
55+
56+
w.WriteHeader(http.StatusOK)
57+
response := MergeabilityChecksResponse{
58+
SuccessResponse: SuccessResponse{Message: "Mergeability checks retrieved"},
59+
MergeabilityChecks: checks,
60+
}
61+
62+
err = json.NewEncoder(w).Encode(response)
63+
if err != nil {
64+
handleError(w, err, "Could not encode response", http.StatusInternalServerError)
65+
}
66+
}
67+
68+
func (a mergeabilityChecksService) fetchMergeabilityChecks() ([]*MergeabilityCheck, error) {
69+
var response mergeabilityChecksGraphQLResponse
70+
71+
_, err := a.client.Do(gitlab.GraphQLQuery{
72+
Query: mergeabilityChecksQuery,
73+
Variables: map[string]any{
74+
"projectPath": a.gitInfo.ProjectPath(),
75+
"iid": fmt.Sprintf("%d", a.projectInfo.MergeId),
76+
},
77+
}, &response)
78+
if err != nil {
79+
return nil, fmt.Errorf("failed to fetch mergeability checks: %w", err)
80+
}
81+
82+
return response.Data.Project.MergeRequest.MergeabilityChecks, nil
83+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package app
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/harrisoncramer/gitlab.nvim/cmd/app/git"
10+
gitlab "gitlab.com/gitlab-org/api/client-go"
11+
)
12+
13+
type fakeGraphQLClient struct {
14+
err error
15+
jsonData []byte
16+
}
17+
18+
func (f fakeGraphQLClient) Do(query gitlab.GraphQLQuery, response any, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) {
19+
if f.err != nil {
20+
return nil, f.err
21+
}
22+
23+
// Actually unmarshal JSON into the response struct
24+
if err := json.Unmarshal(f.jsonData, response); err != nil {
25+
return nil, err
26+
}
27+
28+
// if resp, ok := response.(mergeabilityChecksGraphQLResponse); ok {
29+
// resp.Data.Project.MergeRequest.MergeabilityChecks = f.checks
30+
// }
31+
32+
return makeResponse(http.StatusOK), nil
33+
}
34+
35+
var testMergeabilityData = data{
36+
projectInfo: &ProjectInfo{MergeId: 123},
37+
gitInfo: &git.GitData{
38+
BranchName: "feature-branch",
39+
Namespace: "test-namespace",
40+
ProjectName: "test-project",
41+
},
42+
}
43+
44+
func TestMergeabilityChecksHandler(t *testing.T) {
45+
t.Run("Returns mergeability checks", func(t *testing.T) {
46+
request := makeRequest(t, http.MethodGet, "/mr/mergeability_checks", nil)
47+
client := fakeGraphQLClient{
48+
jsonData: []byte(`{
49+
"data": {
50+
"project": {
51+
"mergeRequest": {
52+
"mergeabilityChecks": [
53+
{"identifier": "CI_MUST_PASS", "status": "SUCCESS"},
54+
{"identifier": "CONFLICT", "status": "FAILED"}
55+
]
56+
}
57+
}
58+
}
59+
}`),
60+
}
61+
svc := middleware(
62+
mergeabilityChecksService{testMergeabilityData, client},
63+
withMethodCheck(http.MethodGet),
64+
)
65+
66+
res := httptest.NewRecorder()
67+
svc.ServeHTTP(res, request)
68+
69+
var data MergeabilityChecksResponse
70+
json.Unmarshal(res.Body.Bytes(), &data)
71+
72+
assert(t, data.Message, "Mergeability checks retrieved")
73+
assert(t, len(data.MergeabilityChecks), 2)
74+
assert(t, data.MergeabilityChecks[0].Identifier, "CI_MUST_PASS")
75+
assert(t, data.MergeabilityChecks[0].Status, "SUCCESS")
76+
assert(t, data.MergeabilityChecks[1].Identifier, "CONFLICT")
77+
assert(t, data.MergeabilityChecks[1].Status, "FAILED")
78+
})
79+
80+
t.Run("Returns empty list when there are no checks", func(t *testing.T) {
81+
request := makeRequest(t, http.MethodGet, "/mr/mergeability_checks", nil)
82+
client := fakeGraphQLClient{
83+
jsonData: []byte(`{
84+
"data": {
85+
"project": {
86+
"mergeRequest": {
87+
"mergeabilityChecks": []
88+
}
89+
}
90+
}
91+
}`),
92+
}
93+
svc := middleware(
94+
mergeabilityChecksService{testMergeabilityData, client},
95+
withMethodCheck(http.MethodGet),
96+
)
97+
98+
res := httptest.NewRecorder()
99+
svc.ServeHTTP(res, request)
100+
101+
var data MergeabilityChecksResponse
102+
json.Unmarshal(res.Body.Bytes(), &data)
103+
104+
assert(t, data.Message, "Mergeability checks retrieved")
105+
assert(t, len(data.MergeabilityChecks), 0)
106+
})
107+
108+
t.Run("Handles errors from Gitlab client", func(t *testing.T) {
109+
request := makeRequest(t, http.MethodGet, "/mr/mergeability_checks", nil)
110+
client := fakeGraphQLClient{err: errorFromGitlab}
111+
svc := middleware(
112+
mergeabilityChecksService{testMergeabilityData, client},
113+
withMethodCheck(http.MethodGet),
114+
)
115+
data, _ := getFailData(t, svc, request)
116+
assert(t, data.Message, "Could not get mergeability checks")
117+
assert(t, data.Details, "failed to fetch mergeability checks: "+errorFromGitlab.Error())
118+
})
119+
}

cmd/app/server.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,11 @@ func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s *shutdownSer
134134
withMr(d, gitlabClient),
135135
withMethodCheck(http.MethodGet),
136136
))
137+
m.HandleFunc("/mr/info/mergeability", middleware(
138+
mergeabilityChecksService{d, gitlabClient},
139+
withMr(d, gitlabClient),
140+
withMethodCheck(http.MethodGet),
141+
))
137142
m.HandleFunc("/mr/assignee", middleware(
138143
assigneesService{d, gitlabClient},
139144
withMr(d, gitlabClient),

doc/gitlab.nvim.txt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,39 @@ you call this function with no values the defaults will be used:
312312
"squash",
313313
"labels",
314314
"web_url",
315+
"mergeability_checks", -- See more detailed configuration below
316+
},
317+
-- Settings for the mergeability checks in the summary view
318+
-- https://docs.gitlab.com/api/graphql/reference/#mergeabilitycheckidentifier
319+
mergeability_checks = {
320+
-- Symbols for individual check statuses. Set values to `false` to hide checks with given status from summary
321+
statuses = {
322+
SUCCESS = "✅",
323+
CHECKING = "🔁",
324+
FAILED = "❌",
325+
WARNING = "⚠️",
326+
INACTIVE = "💤",
327+
},
328+
-- Descriptions for individual checks. Set values to `false` to hide given checks from summary
329+
checks = {
330+
CI_MUST_PASS = "Pipeline must succeed",
331+
COMMITS_STATUS = "Source branch exists and contains commits",
332+
CONFLICT = "Merge conflicts must be resolved",
333+
DISCUSSIONS_NOT_RESOLVED = "Open threads must be resolved",
334+
DRAFT_STATUS = "Merge request must not be draft",
335+
JIRA_ASSOCIATION_MISSING = "Title or description references a Jira issue",
336+
LOCKED_LFS_FILES = "All LFS files must be unlocked",
337+
LOCKED_PATHS = "All paths must be unlocked",
338+
MERGE_REQUEST_BLOCKED = "Merge request is not blocked",
339+
MERGE_TIME = "Merge is not blocked due to a scheduled merge time",
340+
NEED_REBASE = "Merge request must be rebased, fast-forward merge is not possible",
341+
NOT_APPROVED = "All required approvals must be given",
342+
NOT_OPEN = "Merge request must be open",
343+
REQUESTED_CHANGES = "Change requests must be approved by the requesting user",
344+
SECURITY_POLICY_VIOLATIONS = "Security policies are satisfied",
345+
STATUS_CHECKS_MUST_PASS = "External status checks pass",
346+
TITLE_REGEX = "Title matches the expected regex",
347+
},
315348
},
316349
},
317350
discussion_signs = {

lua/gitlab/actions/data.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ local M = {}
66
local user = state.dependencies.user
77
local info = state.dependencies.info
88
local labels = state.dependencies.labels
9+
local mergeability = state.dependencies.mergeability
910
local project_members = state.dependencies.project_members
1011
local revisions = state.dependencies.revisions
1112
local latest_pipeline = state.dependencies.latest_pipeline
@@ -21,6 +22,7 @@ M.data = function(resources, cb)
2122
info = info,
2223
user = user,
2324
labels = labels,
25+
mergeability = mergeability,
2426
project_members = project_members,
2527
revisions = revisions,
2628
pipeline = latest_pipeline,

lua/gitlab/actions/summary.lua

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ local job = require("gitlab.job")
88
local common = require("gitlab.actions.common")
99
local u = require("gitlab.utils")
1010
local popup = require("gitlab.popup")
11-
local List = require("gitlab.utils.list")
1211
local state = require("gitlab.state")
1312
local miscellaneous = require("gitlab.actions.miscellaneous")
1413

@@ -108,6 +107,28 @@ M.update_details_popup = function(bufnr, info_lines)
108107
M.color_details(bufnr) -- Color values in details popup
109108
end
110109

110+
---Return the mergeability checks statuses and descriptions
111+
---@return string[]
112+
local make_mergeability_checks = function()
113+
local lines = {}
114+
for _, check in ipairs(state.MERGEABILITY.mergeability_checks) do
115+
local status = state.settings.mergeability_checks.statuses[check.status]
116+
if status == nil then
117+
u.notify(string.format("Unknown mergeability check status: %s", check.status), vim.log.levels.ERROR)
118+
end
119+
if status then
120+
local description = state.settings.mergeability_checks.checks[check.identifier]
121+
if description == nil then
122+
u.notify(string.format("Unknown mergeability check identifier: %s", check.identifier), vim.log.levels.ERROR)
123+
end
124+
if description then
125+
table.insert(lines, status .. " " .. description)
126+
end
127+
end
128+
end
129+
return lines
130+
end
131+
111132
-- Builds a lua list of strings that contain metadata about the current MR. Only builds the
112133
-- lines that users include in their state.settings.info.fields list.
113134
M.build_info_lines = function()
@@ -140,6 +161,7 @@ M.build_info_lines = function()
140161
end,
141162
},
142163
web_url = { title = "MR URL", content = info.web_url },
164+
mergeability_checks = { title = "Mergeability checks", content = make_mergeability_checks },
143165
}
144166

145167
local longest_used = ""
@@ -158,22 +180,26 @@ M.build_info_lines = function()
158180
return string.rep(" ", offset + 3)
159181
end
160182

161-
return List.new(state.settings.info.fields):map(function(v)
183+
local result = {}
184+
for _, v in ipairs(state.settings.info.fields) do
162185
if v == "merge_status" then
163186
v = "detailed_merge_status"
164187
end
165188
local row = options[v]
166-
local line = "* " .. row.title .. row_offset(row.title)
167-
if type(row.content) == "function" then
168-
local content = row.content()
169-
if content ~= nil then
170-
line = line .. row.content()
189+
local title_prefix = "* " .. row.title .. row_offset(row.title)
190+
local content = type(row.content) == "function" and row.content() or row.content
191+
if type(content) == "table" then
192+
-- Multi-line content
193+
local padding = string.rep(" ", #title_prefix)
194+
for i, line in ipairs(#content > 0 and content or { "" }) do
195+
table.insert(result, (i == 1 and title_prefix or padding) .. line)
171196
end
172197
else
173-
line = line .. row.content
198+
-- Single-line content
199+
table.insert(result, title_prefix .. (content or ""))
174200
end
175-
return line
176-
end)
201+
end
202+
return result
177203
end
178204

179205
-- This function will PUT the new description to the Go server

0 commit comments

Comments
 (0)