gitlab_mr_check 1.4.2__tar.gz → 1.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitlab_mr_check-1.4.2 → gitlab_mr_check-1.6.0}/PKG-INFO +2 -2
- {gitlab_mr_check-1.4.2 → gitlab_mr_check-1.6.0}/README.md +1 -1
- {gitlab_mr_check-1.4.2 → gitlab_mr_check-1.6.0}/pyproject.toml +8 -8
- {gitlab_mr_check-1.4.2 → gitlab_mr_check-1.6.0}/src/gitlab_mr_check/__init__.py +4 -0
- {gitlab_mr_check-1.4.2 → gitlab_mr_check-1.6.0}/src/gitlab_mr_check/cli.py +7 -1
- {gitlab_mr_check-1.4.2 → gitlab_mr_check-1.6.0}/src/gitlab_mr_check/gitlab_mr_check.py +104 -26
- {gitlab_mr_check-1.4.2 → gitlab_mr_check-1.6.0}/LICENSE +0 -0
- {gitlab_mr_check-1.4.2 → gitlab_mr_check-1.6.0}/src/gitlab_mr_check/helpers/__init__.py +0 -0
- {gitlab_mr_check-1.4.2 → gitlab_mr_check-1.6.0}/src/gitlab_mr_check/helpers/config.py +0 -0
- {gitlab_mr_check-1.4.2 → gitlab_mr_check-1.6.0}/src/gitlab_mr_check/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitlab_mr_check
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.0
|
|
4
4
|
Summary: GitLab Merge Request 4-Eyes Approval Audit Tool
|
|
5
5
|
Author: Yorick Hoorneman
|
|
6
6
|
Author-email: Yorick Hoorneman <yhoorneman@schubergphilis.com>
|
|
@@ -30,7 +30,7 @@ Description-Content-Type: text/markdown
|
|
|
30
30
|
[](https://conventionalcommits.org)
|
|
31
31
|
[](https://keepachangelog.com/en/1.1.0/)
|
|
32
32
|
[]()
|
|
33
|
-
[](https://coverage.readthedocs.io/)
|
|
34
34
|
[](https://pyscn.ludo-tech.org)
|
|
35
35
|
|
|
36
36
|
GitLab Merge Request 4-Eyes Approval Audit Tool
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
[](https://conventionalcommits.org)
|
|
16
16
|
[](https://keepachangelog.com/en/1.1.0/)
|
|
17
17
|
[]()
|
|
18
|
-
[](https://coverage.readthedocs.io/)
|
|
19
19
|
[](https://pyscn.ludo-tech.org)
|
|
20
20
|
|
|
21
21
|
GitLab Merge Request 4-Eyes Approval Audit Tool
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "gitlab_mr_check"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.6.0"
|
|
4
4
|
description = "GitLab Merge Request 4-Eyes Approval Audit Tool"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -63,13 +63,13 @@ test = [
|
|
|
63
63
|
# Pin uv to match [tool.uv] required-version — tox-uv depends on the uv
|
|
64
64
|
# Python package, which installs a uv binary into .venv/bin that would
|
|
65
65
|
# otherwise shadow the image's /usr/local/bin/uv and break the check.
|
|
66
|
-
"uv==0.11.
|
|
66
|
+
"uv==0.11.16",
|
|
67
67
|
"tox>=4.21",
|
|
68
68
|
"tox-uv>=1.16",
|
|
69
69
|
]
|
|
70
70
|
|
|
71
71
|
[build-system]
|
|
72
|
-
requires = ["uv_build>=0.8.15,<=0.11.
|
|
72
|
+
requires = ["uv_build>=0.8.15,<=0.11.16"]
|
|
73
73
|
build-backend = "uv_build"
|
|
74
74
|
|
|
75
75
|
[tool.pyscn.complexity]
|
|
@@ -254,7 +254,7 @@ branch = true
|
|
|
254
254
|
[tool.coverage.report]
|
|
255
255
|
show_missing = true
|
|
256
256
|
skip_covered = true
|
|
257
|
-
fail_under =
|
|
257
|
+
fail_under = 45
|
|
258
258
|
exclude_lines = [
|
|
259
259
|
"pragma: no cover",
|
|
260
260
|
"if __name__ == .__main__.",
|
|
@@ -274,7 +274,7 @@ directory = "reports/coverage"
|
|
|
274
274
|
|
|
275
275
|
|
|
276
276
|
[tool.uv]
|
|
277
|
-
required-version = "==0.11.
|
|
277
|
+
required-version = "==0.11.16"
|
|
278
278
|
exclude-newer = "1 week"
|
|
279
279
|
|
|
280
280
|
[tool.tox]
|
|
@@ -294,6 +294,6 @@ commands = [["pytest", { replace = "posargs", default = [], extend = true }]]
|
|
|
294
294
|
# strings below are kept consistent with that version by hand (uv is mentioned in each).
|
|
295
295
|
# `alpine-image` is mirrored literally in .gitlab-ci.yml's variables: block — GitLab
|
|
296
296
|
# cannot evaluate `image:` from pyproject at pipeline-creation time.
|
|
297
|
-
base-image = "ghcr.io/astral-sh/uv:0.11.
|
|
298
|
-
uv-image = "ghcr.io/astral-sh/uv:0.11.
|
|
299
|
-
alpine-image = "ghcr.io/astral-sh/uv:0.11.
|
|
297
|
+
base-image = "ghcr.io/astral-sh/uv:0.11.16-python3.13-trixie-slim"
|
|
298
|
+
uv-image = "ghcr.io/astral-sh/uv:0.11.16"
|
|
299
|
+
alpine-image = "ghcr.io/astral-sh/uv:0.11.16-alpine3.22"
|
|
@@ -29,12 +29,14 @@ from .gitlab_mr_check import (
|
|
|
29
29
|
MRApprovalResult,
|
|
30
30
|
ProjectMRAuditResult,
|
|
31
31
|
audit,
|
|
32
|
+
evaluate_mr_4eyes,
|
|
32
33
|
evaluate_mrs_4eyes_per_project,
|
|
33
34
|
filter_empty_results_by_field,
|
|
34
35
|
get_groups_recursive,
|
|
35
36
|
get_mrs_by_project,
|
|
36
37
|
get_mrs_by_projects,
|
|
37
38
|
has_4eyes_approval,
|
|
39
|
+
mr_is_indirect_merge,
|
|
38
40
|
mr_is_merged,
|
|
39
41
|
mr_updated_in_years,
|
|
40
42
|
sort_results_by_field,
|
|
@@ -57,12 +59,14 @@ __all__ = [
|
|
|
57
59
|
'MRApprovalResult',
|
|
58
60
|
'ProjectMRAuditResult',
|
|
59
61
|
'audit',
|
|
62
|
+
'evaluate_mr_4eyes',
|
|
60
63
|
'evaluate_mrs_4eyes_per_project',
|
|
61
64
|
'filter_empty_results_by_field',
|
|
62
65
|
'get_groups_recursive',
|
|
63
66
|
'get_mrs_by_project',
|
|
64
67
|
'get_mrs_by_projects',
|
|
65
68
|
'has_4eyes_approval',
|
|
69
|
+
'mr_is_indirect_merge',
|
|
66
70
|
'mr_is_merged',
|
|
67
71
|
'mr_updated_in_years',
|
|
68
72
|
'parse_config_file',
|
|
@@ -94,7 +94,13 @@ def main() -> None:
|
|
|
94
94
|
sys.exit(f'Invalid config: {exc}')
|
|
95
95
|
results = audit(url=args.url, token=args.token, config=config)
|
|
96
96
|
rows = [
|
|
97
|
-
{
|
|
97
|
+
{
|
|
98
|
+
'project': r.name,
|
|
99
|
+
'iid': mr.iid,
|
|
100
|
+
'passed': mr.passed,
|
|
101
|
+
'indirect_merge': mr.indirect_merge,
|
|
102
|
+
'reasoning': mr.reasoning,
|
|
103
|
+
}
|
|
98
104
|
for r in results
|
|
99
105
|
for mr in r.mr_results
|
|
100
106
|
]
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
import logging
|
|
19
19
|
from dataclasses import asdict, dataclass, field
|
|
20
20
|
from datetime import datetime
|
|
21
|
-
from functools import partial
|
|
22
21
|
from typing import Any
|
|
23
22
|
|
|
24
23
|
import gitlab
|
|
@@ -40,6 +39,11 @@ LOGGER_BASENAME = 'gitlab-mr-check'
|
|
|
40
39
|
LOGGER = logging.getLogger(LOGGER_BASENAME)
|
|
41
40
|
LOGGER.addHandler(logging.NullHandler())
|
|
42
41
|
|
|
42
|
+
# System-note text GitLab attaches when an MR's commits reached the target branch
|
|
43
|
+
# by another route (sibling branch/MR, direct push, or a git merge outside the UI).
|
|
44
|
+
# Verify this against the target instance; the merge-field short-circuit is the fallback.
|
|
45
|
+
INDIRECT_MERGE_NOTE = 'merged manually'
|
|
46
|
+
|
|
43
47
|
|
|
44
48
|
@dataclass
|
|
45
49
|
class MRApprovalResult:
|
|
@@ -53,6 +57,7 @@ class MRApprovalResult:
|
|
|
53
57
|
state: str = ''
|
|
54
58
|
created_at: str = ''
|
|
55
59
|
updated_at: str = ''
|
|
60
|
+
indirect_merge: bool = False
|
|
56
61
|
|
|
57
62
|
|
|
58
63
|
@dataclass
|
|
@@ -64,29 +69,37 @@ class ProjectMRAuditResult:
|
|
|
64
69
|
|
|
65
70
|
@property
|
|
66
71
|
def passed(self) -> bool:
|
|
67
|
-
"""Return True if all MRs in this project passed the 4-eyes check."""
|
|
68
|
-
return all(mr.passed for mr in self.mr_results
|
|
72
|
+
"""Return True if all non-indirect MRs in this project passed the 4-eyes check."""
|
|
73
|
+
return all(mr.passed for mr in self.mr_results if not mr.indirect_merge)
|
|
69
74
|
|
|
70
75
|
@property
|
|
71
76
|
def mrs_passed(self) -> list[MRApprovalResult]:
|
|
72
|
-
"""Return the list of MRs that passed."""
|
|
73
|
-
return [mr for mr in self.mr_results if mr.passed]
|
|
77
|
+
"""Return the list of MRs that passed (excluding indirect merges)."""
|
|
78
|
+
return [mr for mr in self.mr_results if mr.passed and not mr.indirect_merge]
|
|
74
79
|
|
|
75
80
|
@property
|
|
76
81
|
def mrs_failed(self) -> list[MRApprovalResult]:
|
|
77
|
-
"""Return the list of MRs that failed."""
|
|
78
|
-
return [mr for mr in self.mr_results if not mr.passed]
|
|
82
|
+
"""Return the list of MRs that failed (excluding indirect merges)."""
|
|
83
|
+
return [mr for mr in self.mr_results if not mr.passed and not mr.indirect_merge]
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def mrs_indirect(self) -> list[MRApprovalResult]:
|
|
87
|
+
"""Return the list of MRs that were merged indirectly (informational, not pass/fail)."""
|
|
88
|
+
return [mr for mr in self.mr_results if mr.indirect_merge]
|
|
79
89
|
|
|
80
90
|
@property
|
|
81
91
|
def percentage(self) -> float:
|
|
82
|
-
"""Return the pass percentage across all MRs."""
|
|
83
|
-
total = len(self.
|
|
92
|
+
"""Return the pass percentage across all non-indirect MRs."""
|
|
93
|
+
total = len(self.mrs_passed) + len(self.mrs_failed)
|
|
84
94
|
return (len(self.mrs_passed) / total * 100) if total else 0
|
|
85
95
|
|
|
86
96
|
@property
|
|
87
97
|
def summary(self) -> str:
|
|
88
98
|
"""Return a human-readable summary line."""
|
|
89
|
-
return
|
|
99
|
+
return (
|
|
100
|
+
f'Passed: {len(self.mrs_passed)}, Failed: {len(self.mrs_failed)}, '
|
|
101
|
+
f'Indirect: {len(self.mrs_indirect)}, Percentage: {self.percentage:.2f}%'
|
|
102
|
+
)
|
|
90
103
|
|
|
91
104
|
def to_dict(self) -> dict:
|
|
92
105
|
"""Serialise this result including all computed properties."""
|
|
@@ -133,27 +146,85 @@ def mr_is_merged(mr: gitlab.v4.objects.ProjectMergeRequest) -> bool:
|
|
|
133
146
|
return mr.state == 'merged'
|
|
134
147
|
|
|
135
148
|
|
|
149
|
+
def mr_is_indirect_merge(mr: gitlab.v4.objects.ProjectMergeRequest) -> bool:
|
|
150
|
+
"""Return True if the MR was merged indirectly rather than through GitLab.
|
|
151
|
+
|
|
152
|
+
An indirect merge is one where the MR's commits reached the target branch by
|
|
153
|
+
another route (a sibling branch/MR with the same commits, a direct push, or a
|
|
154
|
+
git merge outside the UI). GitLab flips such an MR to ``merged`` without
|
|
155
|
+
recording a merge user or merge commit, and attaches a "merged manually"
|
|
156
|
+
system note. The MR therefore has no approvals of its own, so its 4-eyes
|
|
157
|
+
result is meaningless and should be reported as informational.
|
|
158
|
+
|
|
159
|
+
To keep API calls low we short-circuit on fields already present: if GitLab
|
|
160
|
+
recorded a merge user or merge/squash commit, it was a real merge and we skip
|
|
161
|
+
the extra notes call.
|
|
162
|
+
"""
|
|
163
|
+
if any(getattr(mr, name, None) for name in ('merge_user', 'merge_commit_sha', 'squash_commit_sha')):
|
|
164
|
+
return False
|
|
165
|
+
return any(
|
|
166
|
+
getattr(note, 'system', False) and INDIRECT_MERGE_NOTE in (getattr(note, 'body', '') or '').lower()
|
|
167
|
+
for note in mr.notes.list(all=True)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
136
171
|
def mr_updated_in_years(mr: gitlab.v4.objects.ProjectMergeRequest, years: list[int]) -> bool:
|
|
137
172
|
"""Return True if the MR was last updated in one of the given calendar years."""
|
|
138
173
|
return datetime.fromisoformat(mr.updated_at).year in years
|
|
139
174
|
|
|
140
175
|
|
|
141
|
-
def get_mrs_by_project(project: gitlab.v4.objects.Project,
|
|
142
|
-
"""Return all MRs from a project
|
|
143
|
-
return
|
|
176
|
+
def get_mrs_by_project(project: gitlab.v4.objects.Project, **list_kwargs: Any) -> list[Any]: # noqa: ANN401
|
|
177
|
+
"""Return all MRs from a project matching the given server-side list filters."""
|
|
178
|
+
return list(project.mergerequests.list(**list_kwargs))
|
|
144
179
|
|
|
145
180
|
|
|
146
|
-
def get_mrs_by_projects(projects: list[tuple[str, Any]],
|
|
147
|
-
"""Return a mapping of project name to
|
|
148
|
-
return {name: get_mrs_by_project(project,
|
|
181
|
+
def get_mrs_by_projects(projects: list[tuple[str, Any]], **list_kwargs: Any) -> dict[str, list[Any]]: # noqa: ANN401
|
|
182
|
+
"""Return a mapping of project name to MR list using the given server-side list filters."""
|
|
183
|
+
return {name: get_mrs_by_project(project, **list_kwargs) for name, project in projects}
|
|
149
184
|
|
|
150
185
|
|
|
151
|
-
def
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
186
|
+
def evaluate_mr_4eyes(
|
|
187
|
+
mr: gitlab.v4.objects.ProjectMergeRequest,
|
|
188
|
+
default_branch: str | None = None,
|
|
189
|
+
) -> MRApprovalResult | None:
|
|
190
|
+
"""Evaluate a single MR's 4-eyes approval.
|
|
191
|
+
|
|
192
|
+
Returns ``None`` when the MR should be excluded from the audit: a failed MR
|
|
193
|
+
that did not target the project's ``default_branch`` (the trunk) is not a
|
|
194
|
+
4-eyes concern and is dropped. Failed MRs that were merged indirectly are
|
|
195
|
+
kept but flagged as informational (see ``mr_is_indirect_merge``).
|
|
196
|
+
"""
|
|
197
|
+
result = has_4eyes_approval(mr)
|
|
198
|
+
if result.passed:
|
|
199
|
+
return result
|
|
200
|
+
if default_branch is not None and mr.target_branch != default_branch:
|
|
201
|
+
return None
|
|
202
|
+
if mr_is_indirect_merge(mr):
|
|
203
|
+
result.indirect_merge = True
|
|
204
|
+
result.reasoning = (
|
|
205
|
+
f'MR !{mr.iid} merged indirectly (commits reached the target branch '
|
|
206
|
+
f'via another branch/MR); its own approvals do not apply'
|
|
207
|
+
)
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def evaluate_mrs_4eyes_per_project(
|
|
212
|
+
project_mrs: dict[str, list[Any]],
|
|
213
|
+
default_branches: dict[str, str | None] | None = None,
|
|
214
|
+
) -> list[ProjectMRAuditResult]:
|
|
215
|
+
"""Evaluate 4-eyes approval for every MR in every project.
|
|
216
|
+
|
|
217
|
+
``default_branches`` maps a project name to its default branch; failed MRs
|
|
218
|
+
that did not target it are excluded. When omitted, no branch filtering is
|
|
219
|
+
applied.
|
|
220
|
+
"""
|
|
221
|
+
default_branches = default_branches or {}
|
|
222
|
+
results = []
|
|
223
|
+
for name, mrs in project_mrs.items():
|
|
224
|
+
default_branch = default_branches.get(name)
|
|
225
|
+
mr_results = [result for mr in mrs if (result := evaluate_mr_4eyes(mr, default_branch)) is not None]
|
|
226
|
+
results.append(ProjectMRAuditResult(name=name, mr_results=mr_results))
|
|
227
|
+
return results
|
|
157
228
|
|
|
158
229
|
|
|
159
230
|
def filter_empty_results_by_field(items: list[Any], field_name: str) -> list[Any]:
|
|
@@ -178,7 +249,7 @@ def audit(url: str, token: str, config: Config) -> list[ProjectMRAuditResult]:
|
|
|
178
249
|
List of per-project audit results, sorted by pass status, with empty projects excluded.
|
|
179
250
|
"""
|
|
180
251
|
LOGGER.info('Starting audit validation for GitLab Merge Requests 4-eyes approval')
|
|
181
|
-
gl = gitlab.Gitlab(url, token)
|
|
252
|
+
gl = gitlab.Gitlab(url, token, retry_transient_errors=True, timeout=60)
|
|
182
253
|
|
|
183
254
|
LOGGER.info('Getting the groups recursively')
|
|
184
255
|
gl_groups = []
|
|
@@ -189,21 +260,28 @@ def audit(url: str, token: str, config: Config) -> list[ProjectMRAuditResult]:
|
|
|
189
260
|
|
|
190
261
|
LOGGER.info('Getting the projects under the groups')
|
|
191
262
|
gl_projects = []
|
|
263
|
+
default_branches: dict[str, str | None] = {}
|
|
192
264
|
for group in gl_groups:
|
|
193
265
|
group_projects = group.projects.list(all=True, archived=False)
|
|
194
266
|
LOGGER.info(' Group %s: %d project(s)', group.full_path, len(group_projects))
|
|
195
267
|
for project in group_projects:
|
|
196
|
-
LOGGER.info(' Project: %s', project.path_with_namespace)
|
|
268
|
+
LOGGER.info(' Project: %s (default branch: %s)', project.path_with_namespace, project.default_branch)
|
|
269
|
+
default_branches[project.path_with_namespace] = project.default_branch
|
|
197
270
|
gl_projects.append((project.path_with_namespace, gl.projects.get(project.id, lazy=True)))
|
|
198
271
|
LOGGER.info('Found %d project(s)', len(gl_projects))
|
|
199
272
|
|
|
200
273
|
LOGGER.info('Getting the merge requests under the projects')
|
|
274
|
+
years = config.gitlab.audit.years
|
|
201
275
|
gl_project_mrs = get_mrs_by_projects(
|
|
202
276
|
projects=gl_projects,
|
|
203
|
-
|
|
277
|
+
state='merged',
|
|
278
|
+
updated_after=f'{min(years)}-01-01T00:00:00Z',
|
|
279
|
+
updated_before=f'{max(years) + 1}-01-01T00:00:00Z',
|
|
280
|
+
iterator=True,
|
|
281
|
+
per_page=50,
|
|
204
282
|
)
|
|
205
283
|
|
|
206
284
|
LOGGER.info('Evaluating merge requests for 4-eyes approval')
|
|
207
|
-
results = evaluate_mrs_4eyes_per_project(gl_project_mrs)
|
|
285
|
+
results = evaluate_mrs_4eyes_per_project(gl_project_mrs, default_branches)
|
|
208
286
|
results = filter_empty_results_by_field(results, 'mr_results')
|
|
209
287
|
return sort_results_by_field(results, 'passed')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|