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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlab_mr_check
3
- Version: 1.5.0
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.5.0"
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 = 35
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
- {'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
  ]
@@ -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) 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)
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.mr_results)
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 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
+ )
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 evaluate_mrs_4eyes_per_project(project_mrs: dict[str, list[Any]]) -> list[ProjectMRAuditResult]:
151
- """Evaluate 4-eyes approval for every MR in every project."""
152
- return [
153
- ProjectMRAuditResult(name=name, mr_results=[has_4eyes_approval(mr) for mr in mrs])
154
- for name, mrs in project_mrs.items()
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