gitlab-branch-triage 1.0.0 → 1.0.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -8
- data/README.md +17 -63
- data/gitlab-branch-triage.gemspec +3 -3
- data/lib/gitlab/branch_triage/actions/delete.rb +28 -3
- data/lib/gitlab/branch_triage/client.rb +16 -39
- data/lib/gitlab/branch_triage/policy_loader.rb +0 -4
- data/lib/gitlab/branch_triage/runner.rb +38 -78
- data/lib/gitlab/branch_triage/version.rb +1 -1
- data/lib/gitlab-branch-triage.rb +0 -15
- metadata +5 -8
- data/lib/gitlab/branch_triage/actions/mr_actions.rb +0 -187
- data/lib/gitlab/branch_triage/conditions/mr_conditions.rb +0 -141
- data/lib/gitlab/branch_triage/resource/merge_request.rb +0 -150
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 728dd7248ca6212ced7a37dbef04c41fa8e658459132662ac6200b941a239464
|
|
4
|
+
data.tar.gz: 3462129e927e1fdb8f3f81611986f472e434dae2fc6d4ba4f234dc9767cce7b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7500ba0c06ff8503a130b9cd18b91287bf16709214556323622f7352dcac18cf96d8ad18a984a9e89198a9d2f3de31ad5eed04b623da71c35d5c0f475d789e05
|
|
7
|
+
data.tar.gz: a589b89e9757956bcf5749ddfc70787a14576909053f50605b3382930657c5c71cda6aa3da82e83bbf7abde3cb661b81af47fc66bf64b2e22fbf33471abcea28
|
data/CHANGELOG.md
CHANGED
|
@@ -5,15 +5,11 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [1.0.
|
|
9
|
-
|
|
10
|
-
### Added
|
|
8
|
+
## [1.0.1] - 2026-03-16
|
|
11
9
|
|
|
12
|
-
|
|
10
|
+
### Fixed
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- Initial commit (123cd8b)
|
|
12
|
+
- inconsistent configurations and next v1.0.1 (622f2f2)
|
|
17
13
|
|
|
18
14
|
## [1.0.0] - 2026-03-11
|
|
19
15
|
|
|
@@ -35,4 +31,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
35
31
|
|
|
36
32
|
[1.0.0]: https://github.com/solucteam/gitlab-branch-triage/releases/tag/v1.0.0
|
|
37
33
|
|
|
38
|
-
[1.0.
|
|
34
|
+
[1.0.1]: https://github.com/solucteam/gitlab-branch-triage/releases/tag/v1.0.1
|
data/README.md
CHANGED
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
-
**gitlab-branch-triage** automates branch
|
|
15
|
+
**gitlab-branch-triage** automates branch cleanup on GitLab using YAML-driven policies. Notify stale branch authors, auto-delete merged branches, detect inactive authors, and automatically close notification issues when branches are removed — all from a single configuration file.
|
|
16
16
|
|
|
17
17
|
## Features
|
|
18
18
|
|
|
19
19
|
- **Policy-driven** — define triage rules in a simple YAML file
|
|
20
20
|
- **Branch triage** — detect stale, merged, or abandoned branches and act on them
|
|
21
|
-
- **
|
|
21
|
+
- **Automatic issue lifecycle** — notification issues are closed automatically when the branch is deleted
|
|
22
22
|
- **Inactive author detection** — automatically handles branches from blocked/deleted users
|
|
23
23
|
- **Group-wide** — triage all projects in a GitLab group (subgroups included recursively)
|
|
24
24
|
- **Dry-run by default** — safe to test before executing real actions
|
|
@@ -66,7 +66,7 @@ gitlab-branch-triage --source-id my-group/my-project --no-dry-run
|
|
|
66
66
|
|
|
67
67
|
## Configuration
|
|
68
68
|
|
|
69
|
-
Policies are defined in `.branch-triage-policies.yml`.
|
|
69
|
+
Policies are defined in `.branch-triage-policies.yml`.
|
|
70
70
|
|
|
71
71
|
### Branch Rules
|
|
72
72
|
|
|
@@ -130,72 +130,30 @@ date:
|
|
|
130
130
|
|
|
131
131
|
| Action | Config | Description |
|
|
132
132
|
|--------|--------|-------------|
|
|
133
|
-
| `notify` | `{title, body, labels}` | Create an issue to notify the author |
|
|
134
|
-
| `delete` | `true` | Delete the branch |
|
|
133
|
+
| `notify` | `{title, body, labels}` | Create an issue to notify the author. Closed automatically when the branch is deleted. |
|
|
134
|
+
| `delete` | `true` | Delete the branch and close the associated notification issue. |
|
|
135
135
|
| `print` | `"template string"` | Log a message |
|
|
136
136
|
| `comment` | `{issue_iid, body}` | Comment on an existing issue |
|
|
137
137
|
|
|
138
|
-
###
|
|
138
|
+
### Template Variables
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
resource_rules:
|
|
142
|
-
merge_requests:
|
|
143
|
-
rules:
|
|
144
|
-
- name: Warn abandoned MRs (30+ days)
|
|
145
|
-
conditions:
|
|
146
|
-
date:
|
|
147
|
-
attribute: updated_at
|
|
148
|
-
condition: older_than
|
|
149
|
-
interval_type: days
|
|
150
|
-
interval: 30
|
|
151
|
-
forbidden_labels:
|
|
152
|
-
- do-not-close
|
|
153
|
-
actions:
|
|
154
|
-
label_mr:
|
|
155
|
-
- stale
|
|
156
|
-
comment_mr: |
|
|
157
|
-
@{{author_username}}, this MR has had no activity for **{{days_since_update}} days**.
|
|
140
|
+
`{{name}}`, `{{author_name}}`, `{{author_email}}`, `{{author_username}}`, `{{committed_date}}`, `{{days_inactive}}`, `{{delete_date}}`, `{{commit_title}}`, `{{short_sha}}`, `{{project_path}}`, `{{today}}`
|
|
158
141
|
|
|
159
|
-
|
|
160
|
-
conditions:
|
|
161
|
-
date:
|
|
162
|
-
attribute: updated_at
|
|
163
|
-
condition: older_than
|
|
164
|
-
interval_type: days
|
|
165
|
-
interval: 60
|
|
166
|
-
actions:
|
|
167
|
-
close_mr: true
|
|
168
|
-
```
|
|
142
|
+
## Issue Lifecycle
|
|
169
143
|
|
|
170
|
-
|
|
144
|
+
gitlab-branch-triage manages the full lifecycle of notification issues automatically.
|
|
171
145
|
|
|
172
|
-
|
|
173
|
-
|-----------|---------|-------------|
|
|
174
|
-
| `date` | `{attribute: updated_at, ...}` | Filter by `updated_at` or `created_at` |
|
|
175
|
-
| `draft` | `true` / `false` | Draft/WIP status |
|
|
176
|
-
| `assigned` | `true` / `false` | Has assignees |
|
|
177
|
-
| `has_reviewer` | `true` / `false` | Has reviewers |
|
|
178
|
-
| `pipeline_status` | `"failed"` | Pipeline state (`success`, `failed`, `running`, `pending`) |
|
|
179
|
-
| `labels` | `["label1"]` | All labels must match (AND) |
|
|
180
|
-
| `forbidden_labels` | `["on-hold"]` | None must match |
|
|
181
|
-
| `target_branch` | `"main"` or `{matches: "regex"}` | Target branch filter |
|
|
182
|
-
| `title` | `{contains: "hotfix"}` | Title pattern matching |
|
|
183
|
-
|
|
184
|
-
### MR Actions
|
|
146
|
+
**When `notify` runs**, an issue is created and assigned to the branch author with a deletion deadline (`{{delete_date}}`). The issue has the label `branch-cleanup`.
|
|
185
147
|
|
|
186
|
-
|
|
187
|
-
|--------|--------|-------------|
|
|
188
|
-
| `comment_mr` | `"template"` | Post a comment on the MR |
|
|
189
|
-
| `close_mr` | `true` | Close the MR |
|
|
190
|
-
| `label_mr` | `["stale"]` | Add labels |
|
|
191
|
-
| `notify_mr` | `{title, body, labels}` | Create a notification issue |
|
|
192
|
-
| `print` | `"template"` | Log a message |
|
|
148
|
+
**When the branch is deleted by a `delete` rule**, the tool searches for any open `branch-cleanup` issue whose title contains the branch name and closes it automatically.
|
|
193
149
|
|
|
194
|
-
|
|
150
|
+
**When the author deletes the branch themselves** (before the scheduled date), the next triage run detects that the branch no longer exists and closes the orphaned issue automatically.
|
|
195
151
|
|
|
196
|
-
|
|
152
|
+
This means issues are never left open after the branch is gone, regardless of who or what deleted it.
|
|
153
|
+
|
|
154
|
+
## Inactive Author Detection
|
|
197
155
|
|
|
198
|
-
|
|
156
|
+
When the `notify` action detects that a branch author is inactive (blocked, deactivated, or deleted from GitLab), it automatically deletes the branch and creates a cleanup issue assigned to a project maintainer. This prevents stale notifications to users who can no longer act on them.
|
|
199
157
|
|
|
200
158
|
## CLI Options
|
|
201
159
|
|
|
@@ -250,17 +208,13 @@ branch-triage:
|
|
|
250
208
|
|
|
251
209
|
Generate the snippet with `gitlab-branch-triage --init-ci`.
|
|
252
210
|
|
|
253
|
-
## Inactive Author Detection
|
|
254
|
-
|
|
255
|
-
When the `notify` action detects that a branch author is inactive (blocked, deactivated, or deleted from GitLab), it automatically deletes the branch and creates a cleanup issue assigned to a project maintainer. This prevents stale notifications to users who can no longer act on them.
|
|
256
|
-
|
|
257
211
|
## Contributing
|
|
258
212
|
|
|
259
213
|
Bug reports and pull requests are welcome on [GitHub](https://github.com/solucteam/gitlab-branch-triage).
|
|
260
214
|
|
|
261
215
|
1. Fork it
|
|
262
216
|
2. Create your feature branch (`git checkout -b feature/my-feature`)
|
|
263
|
-
3. Commit your changes (`git commit -am '
|
|
217
|
+
3. Commit your changes (`git commit -am 'feat: add my feature'`)
|
|
264
218
|
4. Push to the branch (`git push origin feature/my-feature`)
|
|
265
219
|
5. Open a Pull Request
|
|
266
220
|
|
|
@@ -8,11 +8,11 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.authors = ["SolucTeam"]
|
|
9
9
|
spec.email = ["contact@solucteam.com"]
|
|
10
10
|
|
|
11
|
-
spec.summary = "Automated branch
|
|
11
|
+
spec.summary = "Automated branch triage for GitLab, driven by YAML policies"
|
|
12
12
|
spec.description = <<~DESC
|
|
13
13
|
gitlab-branch-triage enables project maintainers to automatically triage
|
|
14
|
-
GitLab branches
|
|
15
|
-
Notify stale branch authors, auto-delete merged branches,
|
|
14
|
+
GitLab branches based on policies defined in a YAML file.
|
|
15
|
+
Notify stale branch authors, auto-delete merged branches,
|
|
16
16
|
and detect inactive authors — all configurable via simple rules.
|
|
17
17
|
DESC
|
|
18
18
|
spec.homepage = "https://github.com/solucteam/gitlab-branch-triage"
|
|
@@ -5,27 +5,52 @@ module Gitlab
|
|
|
5
5
|
module Actions
|
|
6
6
|
# delete: true
|
|
7
7
|
#
|
|
8
|
-
# Deletes the branch
|
|
9
|
-
#
|
|
10
|
-
# the notify action handles the "inactive author" special case.
|
|
8
|
+
# Deletes the branch and closes any open branch-cleanup issue
|
|
9
|
+
# that was created by the notify action for this branch.
|
|
11
10
|
class Delete < Base
|
|
12
11
|
def execute
|
|
13
12
|
return unless config
|
|
14
13
|
|
|
15
14
|
if dry_run
|
|
16
15
|
log_dry("Would delete branch: #{branch.name.inspect}")
|
|
16
|
+
log_dry("Would close open branch-cleanup issue for: #{branch.name.inspect}")
|
|
17
17
|
return
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
ok = client.delete_branch(project_id, branch.name)
|
|
21
21
|
if ok
|
|
22
22
|
log_ok("Branch deleted: #{branch.name.inspect}")
|
|
23
|
+
close_notify_issue
|
|
23
24
|
else
|
|
24
25
|
log_err("Failed to delete branch: #{branch.name.inspect}")
|
|
25
26
|
end
|
|
26
27
|
rescue => e
|
|
27
28
|
log_err("Error deleting branch #{branch.name}: #{e.message}")
|
|
28
29
|
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def close_notify_issue
|
|
34
|
+
issues = client.project_issues(
|
|
35
|
+
project_id,
|
|
36
|
+
labels: "branch-cleanup",
|
|
37
|
+
search: branch.name
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
matching = issues.select { |i| i["title"].to_s.include?(branch.name) }
|
|
41
|
+
|
|
42
|
+
if matching.empty?
|
|
43
|
+
logger.debug(" No open branch-cleanup issue found for #{branch.name.inspect}")
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
matching.each do |issue|
|
|
48
|
+
client.close_issue(project_id, issue["iid"])
|
|
49
|
+
log_ok("Closed issue ##{issue["iid"]}: #{issue["title"]}")
|
|
50
|
+
end
|
|
51
|
+
rescue => e
|
|
52
|
+
log_err("Could not close issue for #{branch.name}: #{e.message}")
|
|
53
|
+
end
|
|
29
54
|
end
|
|
30
55
|
end
|
|
31
56
|
end
|
|
@@ -31,53 +31,38 @@ module Gitlab
|
|
|
31
31
|
true
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
#
|
|
35
|
-
|
|
34
|
+
# Returns the set of branch names that have an open MR (used by has_open_mr condition)
|
|
36
35
|
def open_mr_source_branches(project_id)
|
|
37
36
|
mrs = paginate("/projects/#{encode(project_id)}/merge_requests", state: "opened")
|
|
38
37
|
mrs.map { |mr| mr["source_branch"] }.to_set
|
|
39
38
|
end
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
paginate("/projects/#{encode(project_id)}/merge_requests", state: state)
|
|
43
|
-
end
|
|
40
|
+
# ── Issue endpoints ────────────────────────────────────────────────────
|
|
44
41
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
42
|
+
def create_issue(project_id, title:, description:, labels: [], assignee_id: nil)
|
|
43
|
+
payload = { title: title, description: description, labels: labels.join(",") }
|
|
44
|
+
payload[:assignee_id] = assignee_id if assignee_id
|
|
45
|
+
|
|
46
|
+
resp = self.class.post("/projects/#{encode(project_id)}/issues", body: payload.to_json)
|
|
50
47
|
handle_response!(resp, expect: 201)
|
|
51
48
|
resp.parsed_response
|
|
52
49
|
end
|
|
53
50
|
|
|
54
|
-
def
|
|
51
|
+
def close_issue(project_id, issue_iid)
|
|
55
52
|
resp = self.class.put(
|
|
56
|
-
"/projects/#{encode(project_id)}/
|
|
53
|
+
"/projects/#{encode(project_id)}/issues/#{issue_iid}",
|
|
57
54
|
body: { state_event: "close" }.to_json
|
|
58
55
|
)
|
|
59
56
|
handle_response!(resp)
|
|
60
57
|
resp.parsed_response
|
|
61
58
|
end
|
|
62
59
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
resp.parsed_response
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# ── Issue endpoints ────────────────────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
def create_issue(project_id, title:, description:, labels: [], assignee_id: nil)
|
|
75
|
-
payload = { title: title, description: description, labels: labels.join(",") }
|
|
76
|
-
payload[:assignee_id] = assignee_id if assignee_id
|
|
77
|
-
|
|
78
|
-
resp = self.class.post("/projects/#{encode(project_id)}/issues", body: payload.to_json)
|
|
79
|
-
handle_response!(resp, expect: 201)
|
|
80
|
-
resp.parsed_response
|
|
60
|
+
# Returns open issues with the given label, optionally filtered by a search string.
|
|
61
|
+
# Used to find branch-cleanup issues and close them when the branch is gone.
|
|
62
|
+
def project_issues(project_id, labels:, state: "opened", search: nil)
|
|
63
|
+
params = { labels: Array(labels).join(","), state: state }
|
|
64
|
+
params[:search] = search if search
|
|
65
|
+
paginate("/projects/#{encode(project_id)}/issues", params)
|
|
81
66
|
end
|
|
82
67
|
|
|
83
68
|
def add_note(project_id, issue_iid, body)
|
|
@@ -91,8 +76,6 @@ module Gitlab
|
|
|
91
76
|
|
|
92
77
|
# ── Group endpoints ───────────────────────────────────────────────────
|
|
93
78
|
|
|
94
|
-
# Returns all projects in a group, including subgroups recursively.
|
|
95
|
-
# The GitLab API supports include_subgroups=true natively.
|
|
96
79
|
def group_projects(group_id, include_subgroups: true)
|
|
97
80
|
paginate(
|
|
98
81
|
"/groups/#{encode(group_id)}/projects",
|
|
@@ -103,21 +86,16 @@ module Gitlab
|
|
|
103
86
|
|
|
104
87
|
# ── User endpoints ─────────────────────────────────────────────────────
|
|
105
88
|
|
|
106
|
-
# Search users — includes blocked users if token has admin scope,
|
|
107
|
-
# otherwise only returns active accounts.
|
|
108
89
|
def find_users(search, per_page: 5)
|
|
109
90
|
resp = self.class.get("/users", query: { search: search, per_page: per_page })
|
|
110
91
|
data = resp.parsed_response
|
|
111
92
|
data.is_a?(Array) ? data : []
|
|
112
93
|
end
|
|
113
94
|
|
|
114
|
-
# Kept for backward compat
|
|
115
95
|
def find_user(search)
|
|
116
96
|
find_users(search, per_page: 1).first
|
|
117
97
|
end
|
|
118
98
|
|
|
119
|
-
# Returns project members with at least min_access_level.
|
|
120
|
-
# access_level: 40 = Maintainer, 50 = Owner
|
|
121
99
|
def project_members(project_id, min_access_level: 40)
|
|
122
100
|
paginate(
|
|
123
101
|
"/projects/#{encode(project_id)}/members/all",
|
|
@@ -137,7 +115,6 @@ module Gitlab
|
|
|
137
115
|
loop do
|
|
138
116
|
resp = self.class.get(path, query: { per_page: 100, page: page }.merge(extra_params))
|
|
139
117
|
|
|
140
|
-
# Handle rate limiting with max retries
|
|
141
118
|
if resp.code == 429
|
|
142
119
|
retries += 1
|
|
143
120
|
raise "Rate limited by GitLab API after #{MAX_RETRIES} retries" if retries > MAX_RETRIES
|
|
@@ -148,7 +125,7 @@ module Gitlab
|
|
|
148
125
|
next
|
|
149
126
|
end
|
|
150
127
|
|
|
151
|
-
retries = 0
|
|
128
|
+
retries = 0
|
|
152
129
|
|
|
153
130
|
handle_response!(resp)
|
|
154
131
|
data = resp.parsed_response
|
|
@@ -17,17 +17,15 @@ module Gitlab
|
|
|
17
17
|
@dry_run = dry_run
|
|
18
18
|
@logger = logger
|
|
19
19
|
@options = options
|
|
20
|
-
@stats = { projects: 0, branches_matched: 0,
|
|
21
|
-
skipped: 0, errors: 0 }
|
|
20
|
+
@stats = { projects: 0, branches_matched: 0, skipped: 0, errors: 0 }
|
|
22
21
|
end
|
|
23
22
|
|
|
24
23
|
def run
|
|
25
24
|
print_header
|
|
26
25
|
|
|
27
26
|
branch_rules = policy_loader.branch_rules
|
|
28
|
-
mr_rules = policy_loader.mr_rules
|
|
29
27
|
|
|
30
|
-
if branch_rules.empty?
|
|
28
|
+
if branch_rules.empty?
|
|
31
29
|
logger.warn("No rules found in policies file.")
|
|
32
30
|
return
|
|
33
31
|
end
|
|
@@ -38,9 +36,9 @@ module Gitlab
|
|
|
38
36
|
return
|
|
39
37
|
end
|
|
40
38
|
|
|
41
|
-
projects.each { |project| run_project(project, branch_rules
|
|
39
|
+
projects.each { |project| run_project(project, branch_rules) }
|
|
42
40
|
|
|
43
|
-
print_summary(branch_rules.size
|
|
41
|
+
print_summary(branch_rules.size)
|
|
44
42
|
end
|
|
45
43
|
|
|
46
44
|
private
|
|
@@ -62,7 +60,7 @@ module Gitlab
|
|
|
62
60
|
|
|
63
61
|
# ── Per-project processing ────────────────────────────────────────────
|
|
64
62
|
|
|
65
|
-
def run_project(project, branch_rules
|
|
63
|
+
def run_project(project, branch_rules)
|
|
66
64
|
project_id = project["id"] || project["path_with_namespace"]
|
|
67
65
|
project_path = project["path_with_namespace"] || project_id.to_s
|
|
68
66
|
|
|
@@ -73,13 +71,43 @@ module Gitlab
|
|
|
73
71
|
|
|
74
72
|
@stats[:projects] += 1
|
|
75
73
|
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
close_orphaned_issues(project_id)
|
|
75
|
+
run_branch_rules(branch_rules, project_id, project_path)
|
|
78
76
|
rescue => e
|
|
79
77
|
logger.error(" ERROR processing #{project_path}: #{e.message}")
|
|
80
78
|
@stats[:errors] += 1
|
|
81
79
|
end
|
|
82
80
|
|
|
81
|
+
# ── Orphaned issue cleanup ────────────────────────────────────────────
|
|
82
|
+
#
|
|
83
|
+
# Finds open branch-cleanup issues whose branch no longer exists
|
|
84
|
+
# (deleted manually by the author or via another process) and closes them.
|
|
85
|
+
|
|
86
|
+
def close_orphaned_issues(project_id)
|
|
87
|
+
issues = client.project_issues(project_id, labels: "branch-cleanup")
|
|
88
|
+
return if issues.empty?
|
|
89
|
+
|
|
90
|
+
existing_branches = client.branches(project_id).map { |b| b["name"] }.to_set
|
|
91
|
+
|
|
92
|
+
issues.each do |issue|
|
|
93
|
+
# Extract branch name from issue title — matches patterns like:
|
|
94
|
+
# "🔔 Stale branch: `feature/foo`"
|
|
95
|
+
branch_name = issue["title"].to_s[/`([^`]+)`/, 1]
|
|
96
|
+
next unless branch_name
|
|
97
|
+
next if existing_branches.include?(branch_name)
|
|
98
|
+
|
|
99
|
+
if dry_run
|
|
100
|
+
logger.info(" [DRY-RUN] Would close issue ##{issue["iid"]} — branch #{branch_name.inspect} no longer exists")
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
client.close_issue(project_id, issue["iid"])
|
|
105
|
+
logger.info(" ✅ Closed issue ##{issue["iid"]} — branch #{branch_name.inspect} no longer exists")
|
|
106
|
+
rescue => e
|
|
107
|
+
logger.error(" ❌ Could not close issue ##{issue["iid"]}: #{e.message}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
83
111
|
# ── Branch rules ──────────────────────────────────────────────────────
|
|
84
112
|
|
|
85
113
|
def run_branch_rules(rules, project_id, project_path)
|
|
@@ -143,72 +171,6 @@ module Gitlab
|
|
|
143
171
|
end
|
|
144
172
|
end
|
|
145
173
|
|
|
146
|
-
# ── MR rules ──────────────────────────────────────────────────────────
|
|
147
|
-
|
|
148
|
-
def run_mr_rules(rules, project_id, project_path)
|
|
149
|
-
logger.info("")
|
|
150
|
-
logger.info("-- Merge Requests --")
|
|
151
|
-
|
|
152
|
-
raw_mrs = client.merge_requests(project_id, state: "opened")
|
|
153
|
-
logger.info(" #{raw_mrs.size} open MR(s) found")
|
|
154
|
-
|
|
155
|
-
mrs = raw_mrs.map do |raw|
|
|
156
|
-
mr = Resource::MergeRequest.new(raw)
|
|
157
|
-
mr.project_path = project_path
|
|
158
|
-
mr
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
rules.each { |rule| process_mr_rule(rule, mrs, project_id, project_path) }
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
def process_mr_rule(rule, mrs, project_id, project_path)
|
|
165
|
-
name = rule["name"] || "(unnamed)"
|
|
166
|
-
conditions = rule["conditions"] || {}
|
|
167
|
-
actions = rule["actions"] || {}
|
|
168
|
-
limits = rule["limits"] || {}
|
|
169
|
-
|
|
170
|
-
logger.info("")
|
|
171
|
-
logger.info(" Rule: #{name}")
|
|
172
|
-
|
|
173
|
-
matched = mrs.select do |mr|
|
|
174
|
-
Conditions::MrEvaluator.new(mr, conditions).satisfied?
|
|
175
|
-
rescue => e
|
|
176
|
-
logger.error(" ERROR evaluating MR !#{mr.iid}: #{e.message}")
|
|
177
|
-
@stats[:errors] += 1
|
|
178
|
-
false
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
if limits["most_recent"]
|
|
182
|
-
matched = matched.sort_by(&:updated_at).last(limits["most_recent"].to_i)
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
if matched.empty?
|
|
186
|
-
logger.info(" No MRs matched.")
|
|
187
|
-
return
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
@stats[:skipped] += mrs.size - matched.size
|
|
191
|
-
|
|
192
|
-
logger.info(" #{matched.size} matched:")
|
|
193
|
-
|
|
194
|
-
close_threshold = conditions.dig("date", "interval") || 30
|
|
195
|
-
|
|
196
|
-
matched.each do |mr|
|
|
197
|
-
mr.close_in_days = [close_threshold.to_i - mr.days_since_update, 0].max
|
|
198
|
-
|
|
199
|
-
logger.info(" MR !#{mr.iid} : #{mr.title.slice(0, 55)}")
|
|
200
|
-
logger.info(" Author : @#{mr.author_username}")
|
|
201
|
-
logger.info(" Updated : #{mr.days_since_update}d ago | Draft: #{mr.draft?} | Labels: #{mr.labels.join(", ")}")
|
|
202
|
-
|
|
203
|
-
Actions::MrExecutor.new(
|
|
204
|
-
client: client, project_id: project_id, mr: mr,
|
|
205
|
-
actions: actions, dry_run: dry_run, logger: logger
|
|
206
|
-
).execute!
|
|
207
|
-
|
|
208
|
-
@stats[:mrs_matched] += 1
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
|
|
212
174
|
# ── Helpers ───────────────────────────────────────────────────────────
|
|
213
175
|
|
|
214
176
|
def print_header
|
|
@@ -225,15 +187,13 @@ module Gitlab
|
|
|
225
187
|
logger.info(SEPARATOR)
|
|
226
188
|
end
|
|
227
189
|
|
|
228
|
-
def print_summary(branch_rule_count
|
|
190
|
+
def print_summary(branch_rule_count)
|
|
229
191
|
logger.info("")
|
|
230
192
|
logger.info(LINE)
|
|
231
193
|
logger.info("Summary")
|
|
232
194
|
logger.info(" Projects processed : #{@stats[:projects]}")
|
|
233
195
|
logger.info(" Branch rules : #{branch_rule_count}")
|
|
234
|
-
logger.info(" MR rules : #{mr_rule_count}")
|
|
235
196
|
logger.info(" Branches matched : #{@stats[:branches_matched]}")
|
|
236
|
-
logger.info(" MRs matched : #{@stats[:mrs_matched]}")
|
|
237
197
|
logger.info(" Skipped : #{@stats[:skipped]}")
|
|
238
198
|
logger.info(" Errors : #{@stats[:errors]}") if @stats[:errors] > 0
|
|
239
199
|
if dry_run
|
data/lib/gitlab-branch-triage.rb
CHANGED
|
@@ -19,7 +19,6 @@ module Gitlab
|
|
|
19
19
|
|
|
20
20
|
module Resource
|
|
21
21
|
autoload :Branch, "gitlab/branch_triage/resource/branch"
|
|
22
|
-
autoload :MergeRequest, "gitlab/branch_triage/resource/merge_request"
|
|
23
22
|
end
|
|
24
23
|
|
|
25
24
|
module Conditions
|
|
@@ -32,15 +31,6 @@ module Gitlab
|
|
|
32
31
|
autoload :AuthorCondition, "gitlab/branch_triage/conditions/author_condition"
|
|
33
32
|
autoload :ForbiddenAuthor, "gitlab/branch_triage/conditions/author_condition"
|
|
34
33
|
autoload :Evaluator, "gitlab/branch_triage/conditions/evaluator"
|
|
35
|
-
autoload :MrEvaluator, "gitlab/branch_triage/conditions/mr_conditions"
|
|
36
|
-
autoload :MrDateCondition, "gitlab/branch_triage/conditions/mr_conditions"
|
|
37
|
-
autoload :MrDraft, "gitlab/branch_triage/conditions/mr_conditions"
|
|
38
|
-
autoload :MrAssigned, "gitlab/branch_triage/conditions/mr_conditions"
|
|
39
|
-
autoload :MrHasReviewer, "gitlab/branch_triage/conditions/mr_conditions"
|
|
40
|
-
autoload :MrPipelineStatus, "gitlab/branch_triage/conditions/mr_conditions"
|
|
41
|
-
autoload :MrLabels, "gitlab/branch_triage/conditions/mr_conditions"
|
|
42
|
-
autoload :MrForbiddenLabels,"gitlab/branch_triage/conditions/mr_conditions"
|
|
43
|
-
autoload :MrTargetBranch, "gitlab/branch_triage/conditions/mr_conditions"
|
|
44
34
|
end
|
|
45
35
|
|
|
46
36
|
module Actions
|
|
@@ -50,11 +40,6 @@ module Gitlab
|
|
|
50
40
|
autoload :Print, "gitlab/branch_triage/actions/print"
|
|
51
41
|
autoload :Comment, "gitlab/branch_triage/actions/comment"
|
|
52
42
|
autoload :Executor, "gitlab/branch_triage/actions/executor"
|
|
53
|
-
autoload :CommentMr, "gitlab/branch_triage/actions/mr_actions"
|
|
54
|
-
autoload :CloseMr, "gitlab/branch_triage/actions/mr_actions"
|
|
55
|
-
autoload :LabelMr, "gitlab/branch_triage/actions/mr_actions"
|
|
56
|
-
autoload :NotifyMr, "gitlab/branch_triage/actions/mr_actions"
|
|
57
|
-
autoload :MrExecutor, "gitlab/branch_triage/actions/mr_actions"
|
|
58
43
|
end
|
|
59
44
|
end
|
|
60
45
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gitlab-branch-triage
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- SolucTeam
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
11
|
+
date: 2026-03-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: httparty
|
|
@@ -40,8 +40,8 @@ dependencies:
|
|
|
40
40
|
version: '7.0'
|
|
41
41
|
description: |
|
|
42
42
|
gitlab-branch-triage enables project maintainers to automatically triage
|
|
43
|
-
GitLab branches
|
|
44
|
-
Notify stale branch authors, auto-delete merged branches,
|
|
43
|
+
GitLab branches based on policies defined in a YAML file.
|
|
44
|
+
Notify stale branch authors, auto-delete merged branches,
|
|
45
45
|
and detect inactive authors — all configurable via simple rules.
|
|
46
46
|
email:
|
|
47
47
|
- contact@solucteam.com
|
|
@@ -60,7 +60,6 @@ files:
|
|
|
60
60
|
- lib/gitlab/branch_triage/actions/comment.rb
|
|
61
61
|
- lib/gitlab/branch_triage/actions/delete.rb
|
|
62
62
|
- lib/gitlab/branch_triage/actions/executor.rb
|
|
63
|
-
- lib/gitlab/branch_triage/actions/mr_actions.rb
|
|
64
63
|
- lib/gitlab/branch_triage/actions/notify.rb
|
|
65
64
|
- lib/gitlab/branch_triage/actions/print.rb
|
|
66
65
|
- lib/gitlab/branch_triage/client.rb
|
|
@@ -69,13 +68,11 @@ files:
|
|
|
69
68
|
- lib/gitlab/branch_triage/conditions/date_condition.rb
|
|
70
69
|
- lib/gitlab/branch_triage/conditions/evaluator.rb
|
|
71
70
|
- lib/gitlab/branch_triage/conditions/inactive_days.rb
|
|
72
|
-
- lib/gitlab/branch_triage/conditions/mr_conditions.rb
|
|
73
71
|
- lib/gitlab/branch_triage/conditions/name_condition.rb
|
|
74
72
|
- lib/gitlab/branch_triage/conditions/state_condition.rb
|
|
75
73
|
- lib/gitlab/branch_triage/group_resolver.rb
|
|
76
74
|
- lib/gitlab/branch_triage/policy_loader.rb
|
|
77
75
|
- lib/gitlab/branch_triage/resource/branch.rb
|
|
78
|
-
- lib/gitlab/branch_triage/resource/merge_request.rb
|
|
79
76
|
- lib/gitlab/branch_triage/runner.rb
|
|
80
77
|
- lib/gitlab/branch_triage/user_resolver.rb
|
|
81
78
|
- lib/gitlab/branch_triage/version.rb
|
|
@@ -107,5 +104,5 @@ requirements: []
|
|
|
107
104
|
rubygems_version: 3.4.19
|
|
108
105
|
signing_key:
|
|
109
106
|
specification_version: 4
|
|
110
|
-
summary: Automated branch
|
|
107
|
+
summary: Automated branch triage for GitLab, driven by YAML policies
|
|
111
108
|
test_files: []
|
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Gitlab
|
|
4
|
-
module BranchTriage
|
|
5
|
-
module Actions
|
|
6
|
-
# ── comment_mr ───────────────────────────────────────────────────────
|
|
7
|
-
# Posts a note directly on the MR thread.
|
|
8
|
-
#
|
|
9
|
-
# comment_mr: |
|
|
10
|
-
# @{{author_username}} this MR has been inactive for {{days_since_update}} days.
|
|
11
|
-
# Please update it or it will be closed on {{close_date}}.
|
|
12
|
-
class CommentMr < Base
|
|
13
|
-
def execute
|
|
14
|
-
body = render(config.to_s)
|
|
15
|
-
|
|
16
|
-
if dry_run
|
|
17
|
-
log_dry("Would comment on MR !#{mr.iid}: #{body[0, 80].inspect}...")
|
|
18
|
-
return
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
client.add_mr_note(project_id, mr.iid, body)
|
|
22
|
-
log_ok("Commented on MR !#{mr.iid}")
|
|
23
|
-
rescue => e
|
|
24
|
-
log_err("Failed to comment on MR !#{mr.iid}: #{e.message}")
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
private
|
|
28
|
-
|
|
29
|
-
def mr = @branch
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# ── close_mr ─────────────────────────────────────────────────────────
|
|
33
|
-
# Closes the MR via the API.
|
|
34
|
-
#
|
|
35
|
-
# close_mr: true
|
|
36
|
-
class CloseMr < Base
|
|
37
|
-
def execute
|
|
38
|
-
return unless config
|
|
39
|
-
|
|
40
|
-
if dry_run
|
|
41
|
-
log_dry("Would close MR !#{mr.iid}: #{mr.title.slice(0, 60).inspect}")
|
|
42
|
-
return
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
client.close_mr(project_id, mr.iid)
|
|
46
|
-
log_ok("Closed MR !#{mr.iid}: #{mr.title.slice(0, 60)}")
|
|
47
|
-
rescue => e
|
|
48
|
-
log_err("Failed to close MR !#{mr.iid}: #{e.message}")
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
private
|
|
52
|
-
|
|
53
|
-
def mr = @branch
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# ── label_mr ─────────────────────────────────────────────────────────
|
|
57
|
-
# Adds labels to the MR.
|
|
58
|
-
#
|
|
59
|
-
# label_mr:
|
|
60
|
-
# - stale
|
|
61
|
-
# - needs-attention
|
|
62
|
-
class LabelMr < Base
|
|
63
|
-
def execute
|
|
64
|
-
labels = Array(config)
|
|
65
|
-
return if labels.empty?
|
|
66
|
-
|
|
67
|
-
if dry_run
|
|
68
|
-
log_dry("Would add labels #{labels.inspect} to MR !#{mr.iid}")
|
|
69
|
-
return
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
client.add_mr_labels(project_id, mr.iid, labels)
|
|
73
|
-
log_ok("Added labels #{labels.inspect} to MR !#{mr.iid}")
|
|
74
|
-
rescue => e
|
|
75
|
-
log_err("Failed to label MR !#{mr.iid}: #{e.message}")
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
private
|
|
79
|
-
|
|
80
|
-
def mr = @branch
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# ── notify_mr ────────────────────────────────────────────────────────
|
|
84
|
-
# Creates a GitLab issue to notify the author of an abandoned MR.
|
|
85
|
-
#
|
|
86
|
-
# notify_mr:
|
|
87
|
-
# title: "Abandoned MR: {{title}}"
|
|
88
|
-
# body: |
|
|
89
|
-
# @{{author_username}}, MR !{{iid}} has been inactive for {{days_since_update}} days.
|
|
90
|
-
# labels:
|
|
91
|
-
# - mr-cleanup
|
|
92
|
-
class NotifyMr < Base
|
|
93
|
-
DEFAULT_TITLE = "Abandoned MR: !{{iid}} {{title}}"
|
|
94
|
-
DEFAULT_BODY = <<~BODY
|
|
95
|
-
Hi @{{author_username}} 👋
|
|
96
|
-
|
|
97
|
-
Merge Request [!{{iid}} {{title}}]({{web_url}}) in project `{{project_path}}`
|
|
98
|
-
has been inactive for **{{days_since_update}} days**
|
|
99
|
-
(last update: `{{updated_at}}`).
|
|
100
|
-
|
|
101
|
-
**Status:** {{state}}{{draft_label}}
|
|
102
|
-
|
|
103
|
-
Please take one of the following actions before **{{close_date}}**:
|
|
104
|
-
- ✅ Mark it as ready and request a review
|
|
105
|
-
- 🔄 Rebase/update if there are conflicts
|
|
106
|
-
- 🚫 Close it if the work is no longer needed
|
|
107
|
-
|
|
108
|
-
_This issue was created automatically by gitlab-branch-triage._
|
|
109
|
-
BODY
|
|
110
|
-
|
|
111
|
-
def execute
|
|
112
|
-
title_tpl = config.is_a?(Hash) ? config["title"] || DEFAULT_TITLE : DEFAULT_TITLE
|
|
113
|
-
body_tpl = config.is_a?(Hash) ? config["body"] || DEFAULT_BODY : DEFAULT_BODY
|
|
114
|
-
labels = config.is_a?(Hash) ? Array(config["labels"]) : ["mr-cleanup", "automated"]
|
|
115
|
-
|
|
116
|
-
close_in = mr.instance_variable_defined?(:@close_in_days) ? mr.instance_variable_get(:@close_in_days) : 30
|
|
117
|
-
close_date = (Time.now + close_in * 86_400).strftime("%Y-%m-%d")
|
|
118
|
-
draft_lbl = mr.draft? ? " (Draft/WIP)" : ""
|
|
119
|
-
|
|
120
|
-
extra = { "close_date" => close_date, "draft_label" => draft_lbl }
|
|
121
|
-
title = render(title_tpl, extra)
|
|
122
|
-
body = render(body_tpl, extra)
|
|
123
|
-
|
|
124
|
-
if dry_run
|
|
125
|
-
log_dry("Would create issue: #{title.inspect}")
|
|
126
|
-
return
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
issue = client.create_issue(
|
|
130
|
-
project_id,
|
|
131
|
-
title: title,
|
|
132
|
-
description: body,
|
|
133
|
-
labels: labels,
|
|
134
|
-
assignee_id: mr.author_id
|
|
135
|
-
)
|
|
136
|
-
log_ok("Issue created: #{issue["web_url"]}")
|
|
137
|
-
rescue => e
|
|
138
|
-
log_err("Failed to create issue for MR !#{mr.iid}: #{e.message}")
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
private
|
|
142
|
-
|
|
143
|
-
def mr = @branch
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
# ── MR Action Executor ────────────────────────────────────────────────
|
|
147
|
-
|
|
148
|
-
MR_REGISTRY = {
|
|
149
|
-
"comment_mr" => CommentMr,
|
|
150
|
-
"close_mr" => CloseMr,
|
|
151
|
-
"label_mr" => LabelMr,
|
|
152
|
-
"notify_mr" => NotifyMr,
|
|
153
|
-
"print" => Print, # shared with branch actions
|
|
154
|
-
}.freeze
|
|
155
|
-
|
|
156
|
-
class MrExecutor
|
|
157
|
-
def initialize(client:, project_id:, mr:, actions:, dry_run: true, logger: Logger.new($stdout))
|
|
158
|
-
@client = client
|
|
159
|
-
@project_id = project_id
|
|
160
|
-
@mr = mr
|
|
161
|
-
@actions = actions || {}
|
|
162
|
-
@dry_run = dry_run
|
|
163
|
-
@logger = logger
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def execute!
|
|
167
|
-
@actions.each do |key, config|
|
|
168
|
-
klass = MR_REGISTRY[key]
|
|
169
|
-
if klass.nil?
|
|
170
|
-
@logger.warn(" Unknown MR action '#{key}' — ignored")
|
|
171
|
-
next
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
klass.new(
|
|
175
|
-
client: @client,
|
|
176
|
-
project_id: @project_id,
|
|
177
|
-
branch: @mr, # reuse :branch slot for MR
|
|
178
|
-
config: config,
|
|
179
|
-
dry_run: @dry_run,
|
|
180
|
-
logger: @logger
|
|
181
|
-
).execute
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
end
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Gitlab
|
|
4
|
-
module BranchTriage
|
|
5
|
-
module Conditions
|
|
6
|
-
# ── MR-specific conditions ────────────────────────────────────────────
|
|
7
|
-
|
|
8
|
-
# date:
|
|
9
|
-
# attribute: updated_at | created_at
|
|
10
|
-
# condition: older_than | more_recent_than
|
|
11
|
-
# interval_type: days | weeks | months | years
|
|
12
|
-
# interval: 30
|
|
13
|
-
class MrDateCondition < Base
|
|
14
|
-
MULTIPLIERS = { "days" => 1, "weeks" => 7, "months" => 30, "years" => 365 }.freeze
|
|
15
|
-
|
|
16
|
-
def satisfied?
|
|
17
|
-
threshold = config["interval"].to_i * MULTIPLIERS.fetch(config["interval_type"] || "days", 1)
|
|
18
|
-
age = case config["attribute"]
|
|
19
|
-
when "created_at" then resource.days_since_creation
|
|
20
|
-
else resource.days_since_update
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
case config["condition"]
|
|
24
|
-
when "older_than" then age >= threshold
|
|
25
|
-
when "more_recent_than" then age < threshold
|
|
26
|
-
else false
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
private
|
|
31
|
-
|
|
32
|
-
def resource = @branch # reuse Base's @branch ivar (holds the MR here)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# draft: true | false
|
|
36
|
-
class MrDraft < Base
|
|
37
|
-
def satisfied?
|
|
38
|
-
@branch.draft? == config
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# assigned: true | false
|
|
43
|
-
class MrAssigned < Base
|
|
44
|
-
def satisfied?
|
|
45
|
-
@branch.assigned? == config
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# has_reviewer: true | false
|
|
50
|
-
class MrHasReviewer < Base
|
|
51
|
-
def satisfied?
|
|
52
|
-
@branch.has_reviewer? == config
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# pipeline_status: "failed" | "success" | "running" | ""
|
|
57
|
-
class MrPipelineStatus < Base
|
|
58
|
-
def satisfied?
|
|
59
|
-
@branch.pipeline_status == config.to_s
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# labels:
|
|
64
|
-
# - needs-review
|
|
65
|
-
class MrLabels < Base
|
|
66
|
-
def satisfied?
|
|
67
|
-
Array(config).all? { |l| @branch.has_label?(l) }
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# forbidden_labels:
|
|
72
|
-
# - do-not-close
|
|
73
|
-
class MrForbiddenLabels < Base
|
|
74
|
-
def satisfied?
|
|
75
|
-
Array(config).none? { |l| @branch.has_label?(l) }
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# target_branch: main
|
|
80
|
-
class MrTargetBranch < Base
|
|
81
|
-
def satisfied?
|
|
82
|
-
case config
|
|
83
|
-
when String then @branch.target_branch == config
|
|
84
|
-
when Hash
|
|
85
|
-
return Regexp.new(config["matches"]).match?(@branch.target_branch) if config["matches"]
|
|
86
|
-
true
|
|
87
|
-
else true
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# ── MR Evaluator ─────────────────────────────────────────────────────
|
|
93
|
-
|
|
94
|
-
MR_REGISTRY = {
|
|
95
|
-
"date" => MrDateCondition,
|
|
96
|
-
"draft" => MrDraft,
|
|
97
|
-
"assigned" => MrAssigned,
|
|
98
|
-
"has_reviewer" => MrHasReviewer,
|
|
99
|
-
"pipeline_status" => MrPipelineStatus,
|
|
100
|
-
"labels" => MrLabels,
|
|
101
|
-
"forbidden_labels"=> MrForbiddenLabels,
|
|
102
|
-
"target_branch" => MrTargetBranch,
|
|
103
|
-
# title/name reuse existing conditions via alias
|
|
104
|
-
"title" => nil,
|
|
105
|
-
}.freeze
|
|
106
|
-
|
|
107
|
-
class MrEvaluator
|
|
108
|
-
def initialize(mr, conditions)
|
|
109
|
-
@mr = mr
|
|
110
|
-
@conditions = conditions || {}
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def satisfied?
|
|
114
|
-
@conditions.all? do |key, value|
|
|
115
|
-
evaluate(key, value)
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
private
|
|
120
|
-
|
|
121
|
-
def evaluate(key, value)
|
|
122
|
-
klass = MR_REGISTRY[key]
|
|
123
|
-
|
|
124
|
-
# title: reuse NameCondition logic
|
|
125
|
-
if key == "title"
|
|
126
|
-
return Conditions::NameCondition.new(@mr, value).tap do |c|
|
|
127
|
-
c.instance_variable_set(:@branch, OpenStruct.new(name: @mr.title))
|
|
128
|
-
end.satisfied?
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
if klass.nil?
|
|
132
|
-
warn " Unknown MR condition '#{key}' — ignored"
|
|
133
|
-
return true
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
klass.new(@mr, value).satisfied?
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
end
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Gitlab
|
|
4
|
-
module BranchTriage
|
|
5
|
-
module Resource
|
|
6
|
-
# Wraps a raw GitLab MR API hash and exposes computed helpers.
|
|
7
|
-
class MergeRequest
|
|
8
|
-
attr_reader :raw
|
|
9
|
-
attr_accessor :author_user, :project_path, :close_in_days
|
|
10
|
-
|
|
11
|
-
def initialize(raw)
|
|
12
|
-
@raw = raw
|
|
13
|
-
@author_user = nil
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# ── Identity ──────────────────────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
def iid
|
|
19
|
-
raw["iid"]
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def id
|
|
23
|
-
raw["id"]
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def title
|
|
27
|
-
raw["title"].to_s
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def state
|
|
31
|
-
raw["state"].to_s # opened | closed | merged | locked
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def draft?
|
|
35
|
-
raw["draft"] == true || title.match?(/\A\s*(Draft|WIP)\s*:/i)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def source_branch
|
|
39
|
-
raw["source_branch"].to_s
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def target_branch
|
|
43
|
-
raw["target_branch"].to_s
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def web_url
|
|
47
|
-
raw["web_url"].to_s
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def labels
|
|
51
|
-
Array(raw["labels"])
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def has_label?(name)
|
|
55
|
-
labels.any? { |l| l.casecmp?(name) }
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# ── Author ────────────────────────────────────────────────────────────
|
|
59
|
-
|
|
60
|
-
def author_username
|
|
61
|
-
raw.dig("author", "username").to_s
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def author_name
|
|
65
|
-
raw.dig("author", "name").to_s
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def author_id
|
|
69
|
-
raw.dig("author", "id")
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# ── Assignees ─────────────────────────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
def assignees
|
|
75
|
-
Array(raw["assignees"])
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def assigned?
|
|
79
|
-
assignees.any?
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# ── Reviewers ─────────────────────────────────────────────────────────
|
|
83
|
-
|
|
84
|
-
def reviewers
|
|
85
|
-
Array(raw["reviewers"])
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def has_reviewer?
|
|
89
|
-
reviewers.any?
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# ── Dates ─────────────────────────────────────────────────────────────
|
|
93
|
-
|
|
94
|
-
def created_at
|
|
95
|
-
@created_at ||= Time.parse(raw["created_at"]) rescue Time.now
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def updated_at
|
|
99
|
-
@updated_at ||= Time.parse(raw["updated_at"]) rescue Time.now
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def days_since_update
|
|
103
|
-
@days_since_update ||= ((Time.now - updated_at) / 86_400).to_i
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def days_since_creation
|
|
107
|
-
@days_since_creation ||= ((Time.now - created_at) / 86_400).to_i
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# ── Pipeline ──────────────────────────────────────────────────────────
|
|
111
|
-
|
|
112
|
-
def pipeline_status
|
|
113
|
-
raw.dig("head_pipeline", "status").to_s # success|failed|running|pending|""
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def pipeline_failed?
|
|
117
|
-
pipeline_status == "failed"
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
# ── Template context ──────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
def template_context(extra = {})
|
|
123
|
-
{
|
|
124
|
-
"iid" => iid.to_s,
|
|
125
|
-
"title" => title,
|
|
126
|
-
"web_url" => web_url,
|
|
127
|
-
"source_branch" => source_branch,
|
|
128
|
-
"target_branch" => target_branch,
|
|
129
|
-
"author_username" => author_username,
|
|
130
|
-
"author_name" => author_name,
|
|
131
|
-
"labels" => labels.join(", "),
|
|
132
|
-
"state" => state,
|
|
133
|
-
"draft" => draft?.to_s,
|
|
134
|
-
"days_since_update" => days_since_update.to_s,
|
|
135
|
-
"days_since_creation"=> days_since_creation.to_s,
|
|
136
|
-
"updated_at" => updated_at.strftime("%Y-%m-%d"),
|
|
137
|
-
"created_at" => created_at.strftime("%Y-%m-%d"),
|
|
138
|
-
"pipeline_status" => pipeline_status,
|
|
139
|
-
"today" => Time.now.strftime("%Y-%m-%d"),
|
|
140
|
-
"project_path" => @project_path.to_s,
|
|
141
|
-
}.merge(extra)
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def to_s
|
|
145
|
-
"#<MR !#{iid} #{title.slice(0, 40)} state=#{state} draft=#{draft?} updated=#{days_since_update}d ago>"
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
end
|