gitlab_mr_check 1.5.0__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.5.0 → gitlab_mr_check-1.6.0}/PKG-INFO +2 -2
- {gitlab_mr_check-1.5.0 → gitlab_mr_check-1.6.0}/README.md +1 -1
- {gitlab_mr_check-1.5.0 → gitlab_mr_check-1.6.0}/pyproject.toml +2 -2
- {gitlab_mr_check-1.5.0 → gitlab_mr_check-1.6.0}/src/gitlab_mr_check/__init__.py +4 -0
- {gitlab_mr_check-1.5.0 → gitlab_mr_check-1.6.0}/src/gitlab_mr_check/cli.py +7 -1
- {gitlab_mr_check-1.5.0 → gitlab_mr_check-1.6.0}/src/gitlab_mr_check/gitlab_mr_check.py +91 -17
- {gitlab_mr_check-1.5.0 → gitlab_mr_check-1.6.0}/LICENSE +0 -0
- {gitlab_mr_check-1.5.0 → gitlab_mr_check-1.6.0}/src/gitlab_mr_check/helpers/__init__.py +0 -0
- {gitlab_mr_check-1.5.0 → gitlab_mr_check-1.6.0}/src/gitlab_mr_check/helpers/config.py +0 -0
- {gitlab_mr_check-1.5.0 → 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 = [
|
|
@@ -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__.",
|
|
@@ -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
|
]
|
|
@@ -39,6 +39,11 @@ LOGGER_BASENAME = 'gitlab-mr-check'
|
|
|
39
39
|
LOGGER = logging.getLogger(LOGGER_BASENAME)
|
|
40
40
|
LOGGER.addHandler(logging.NullHandler())
|
|
41
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
|
+
|
|
42
47
|
|
|
43
48
|
@dataclass
|
|
44
49
|
class MRApprovalResult:
|
|
@@ -52,6 +57,7 @@ class MRApprovalResult:
|
|
|
52
57
|
state: str = ''
|
|
53
58
|
created_at: str = ''
|
|
54
59
|
updated_at: str = ''
|
|
60
|
+
indirect_merge: bool = False
|
|
55
61
|
|
|
56
62
|
|
|
57
63
|
@dataclass
|
|
@@ -63,29 +69,37 @@ class ProjectMRAuditResult:
|
|
|
63
69
|
|
|
64
70
|
@property
|
|
65
71
|
def passed(self) -> bool:
|
|
66
|
-
"""Return True if all MRs in this project passed the 4-eyes check."""
|
|
67
|
-
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)
|
|
68
74
|
|
|
69
75
|
@property
|
|
70
76
|
def mrs_passed(self) -> list[MRApprovalResult]:
|
|
71
|
-
"""Return the list of MRs that passed."""
|
|
72
|
-
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]
|
|
73
79
|
|
|
74
80
|
@property
|
|
75
81
|
def mrs_failed(self) -> list[MRApprovalResult]:
|
|
76
|
-
"""Return the list of MRs that failed."""
|
|
77
|
-
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]
|
|
78
89
|
|
|
79
90
|
@property
|
|
80
91
|
def percentage(self) -> float:
|
|
81
|
-
"""Return the pass percentage across all MRs."""
|
|
82
|
-
total = len(self.
|
|
92
|
+
"""Return the pass percentage across all non-indirect MRs."""
|
|
93
|
+
total = len(self.mrs_passed) + len(self.mrs_failed)
|
|
83
94
|
return (len(self.mrs_passed) / total * 100) if total else 0
|
|
84
95
|
|
|
85
96
|
@property
|
|
86
97
|
def summary(self) -> str:
|
|
87
98
|
"""Return a human-readable summary line."""
|
|
88
|
-
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
|
+
)
|
|
89
103
|
|
|
90
104
|
def to_dict(self) -> dict:
|
|
91
105
|
"""Serialise this result including all computed properties."""
|
|
@@ -132,6 +146,28 @@ def mr_is_merged(mr: gitlab.v4.objects.ProjectMergeRequest) -> bool:
|
|
|
132
146
|
return mr.state == 'merged'
|
|
133
147
|
|
|
134
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
|
+
|
|
135
171
|
def mr_updated_in_years(mr: gitlab.v4.objects.ProjectMergeRequest, years: list[int]) -> bool:
|
|
136
172
|
"""Return True if the MR was last updated in one of the given calendar years."""
|
|
137
173
|
return datetime.fromisoformat(mr.updated_at).year in years
|
|
@@ -147,12 +183,48 @@ def get_mrs_by_projects(projects: list[tuple[str, Any]], **list_kwargs: Any) ->
|
|
|
147
183
|
return {name: get_mrs_by_project(project, **list_kwargs) for name, project in projects}
|
|
148
184
|
|
|
149
185
|
|
|
150
|
-
def
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
156
228
|
|
|
157
229
|
|
|
158
230
|
def filter_empty_results_by_field(items: list[Any], field_name: str) -> list[Any]:
|
|
@@ -188,11 +260,13 @@ def audit(url: str, token: str, config: Config) -> list[ProjectMRAuditResult]:
|
|
|
188
260
|
|
|
189
261
|
LOGGER.info('Getting the projects under the groups')
|
|
190
262
|
gl_projects = []
|
|
263
|
+
default_branches: dict[str, str | None] = {}
|
|
191
264
|
for group in gl_groups:
|
|
192
265
|
group_projects = group.projects.list(all=True, archived=False)
|
|
193
266
|
LOGGER.info(' Group %s: %d project(s)', group.full_path, len(group_projects))
|
|
194
267
|
for project in group_projects:
|
|
195
|
-
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
|
|
196
270
|
gl_projects.append((project.path_with_namespace, gl.projects.get(project.id, lazy=True)))
|
|
197
271
|
LOGGER.info('Found %d project(s)', len(gl_projects))
|
|
198
272
|
|
|
@@ -208,6 +282,6 @@ def audit(url: str, token: str, config: Config) -> list[ProjectMRAuditResult]:
|
|
|
208
282
|
)
|
|
209
283
|
|
|
210
284
|
LOGGER.info('Evaluating merge requests for 4-eyes approval')
|
|
211
|
-
results = evaluate_mrs_4eyes_per_project(gl_project_mrs)
|
|
285
|
+
results = evaluate_mrs_4eyes_per_project(gl_project_mrs, default_branches)
|
|
212
286
|
results = filter_empty_results_by_field(results, 'mr_results')
|
|
213
287
|
return sort_results_by_field(results, 'passed')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|