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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlab_mr_check
3
- Version: 1.4.2
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
  [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org)
31
31
  [![Changelog](https://img.shields.io/badge/changelog-Keep%20a%20Changelog%201.1.0-orange)](https://keepachangelog.com/en/1.1.0/)
32
32
  [![Build](https://img.shields.io/badge/build-passing-brightgreen)]()
33
- [![Coverage](https://img.shields.io/badge/coverage-35%25-red)](https://coverage.readthedocs.io/)
33
+ [![Coverage](https://img.shields.io/badge/coverage-45%25-red)](https://coverage.readthedocs.io/)
34
34
  [![pyscn quality](https://img.shields.io/badge/pyscn-not%20rated-lightgrey)](https://pyscn.ludo-tech.org)
35
35
 
36
36
  GitLab Merge Request 4-Eyes Approval Audit Tool
@@ -15,7 +15,7 @@
15
15
  [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org)
16
16
  [![Changelog](https://img.shields.io/badge/changelog-Keep%20a%20Changelog%201.1.0-orange)](https://keepachangelog.com/en/1.1.0/)
17
17
  [![Build](https://img.shields.io/badge/build-passing-brightgreen)]()
18
- [![Coverage](https://img.shields.io/badge/coverage-35%25-red)](https://coverage.readthedocs.io/)
18
+ [![Coverage](https://img.shields.io/badge/coverage-45%25-red)](https://coverage.readthedocs.io/)
19
19
  [![pyscn quality](https://img.shields.io/badge/pyscn-not%20rated-lightgrey)](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.4.2"
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.7",
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.7"]
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 = 35
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.7"
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.7-python3.13-trixie-slim"
298
- uv-image = "ghcr.io/astral-sh/uv:0.11.7"
299
- alpine-image = "ghcr.io/astral-sh/uv:0.11.7-alpine3.22"
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
- {'project': r.name, 'iid': mr.iid, 'passed': mr.passed, 'reasoning': mr.reasoning}
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) if self.mr_results else True
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.mr_results)
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 f'Passed: {len(self.mrs_passed)}, Failed: {len(self.mrs_failed)}, Percentage: {self.percentage:.2f}%'
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, filters: list[Any]) -> list[Any]:
142
- """Return all MRs from a project that pass every filter predicate."""
143
- return [mr for mr in project.mergerequests.list(all=True) if all(f(mr) for f in filters)]
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]], filters: list[Any]) -> dict[str, list[Any]]:
147
- """Return a mapping of project name to filtered MR list for each (name, project) pair."""
148
- return {name: get_mrs_by_project(project, filters) for name, project in projects}
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 evaluate_mrs_4eyes_per_project(project_mrs: dict[str, list[Any]]) -> list[ProjectMRAuditResult]:
152
- """Evaluate 4-eyes approval for every MR in every project."""
153
- return [
154
- ProjectMRAuditResult(name=name, mr_results=[has_4eyes_approval(mr) for mr in mrs])
155
- for name, mrs in project_mrs.items()
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
- filters=[mr_is_merged, partial(mr_updated_in_years, years=config.gitlab.audit.years)],
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