gitlab-branch-triage 1.0.0
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 +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE +21 -0
- data/README.md +269 -0
- data/bin/gitlab-branch-triage +488 -0
- data/gitlab-branch-triage.gemspec +38 -0
- data/lib/gitlab/branch_triage/actions/base.rb +44 -0
- data/lib/gitlab/branch_triage/actions/comment.rb +5 -0
- data/lib/gitlab/branch_triage/actions/delete.rb +32 -0
- data/lib/gitlab/branch_triage/actions/executor.rb +44 -0
- data/lib/gitlab/branch_triage/actions/mr_actions.rb +187 -0
- data/lib/gitlab/branch_triage/actions/notify.rb +145 -0
- data/lib/gitlab/branch_triage/actions/print.rb +40 -0
- data/lib/gitlab/branch_triage/client.rb +177 -0
- data/lib/gitlab/branch_triage/conditions/author_condition.rb +40 -0
- data/lib/gitlab/branch_triage/conditions/base.rb +21 -0
- data/lib/gitlab/branch_triage/conditions/date_condition.rb +34 -0
- data/lib/gitlab/branch_triage/conditions/evaluator.rb +52 -0
- data/lib/gitlab/branch_triage/conditions/inactive_days.rb +14 -0
- data/lib/gitlab/branch_triage/conditions/mr_conditions.rb +141 -0
- data/lib/gitlab/branch_triage/conditions/name_condition.rb +35 -0
- data/lib/gitlab/branch_triage/conditions/state_condition.rb +29 -0
- data/lib/gitlab/branch_triage/group_resolver.rb +47 -0
- data/lib/gitlab/branch_triage/policy_loader.rb +35 -0
- data/lib/gitlab/branch_triage/resource/branch.rb +89 -0
- data/lib/gitlab/branch_triage/resource/merge_request.rb +150 -0
- data/lib/gitlab/branch_triage/runner.rb +247 -0
- data/lib/gitlab/branch_triage/user_resolver.rb +103 -0
- data/lib/gitlab/branch_triage/version.rb +7 -0
- data/lib/gitlab-branch-triage.rb +60 -0
- data/logo.svg +87 -0
- metadata +111 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
$LOAD_PATH.unshift(File.join(__dir__, "..", "lib"))
|
|
5
|
+
require "gitlab-branch-triage"
|
|
6
|
+
|
|
7
|
+
EXAMPLE_POLICY = <<~YAML
|
|
8
|
+
# .branch-triage-policies.yml
|
|
9
|
+
# Run against a single project:
|
|
10
|
+
# gitlab-branch-triage --token $TOKEN --source-id my-group/my-project
|
|
11
|
+
#
|
|
12
|
+
# Run against an entire group (all subgroups included recursively):
|
|
13
|
+
# gitlab-branch-triage --token $TOKEN --source groups --source-id my-group
|
|
14
|
+
# gitlab-branch-triage --token $TOKEN --source groups --source-id my-group/subgroup
|
|
15
|
+
#
|
|
16
|
+
# Dry-run by default. Pass --no-dry-run to execute real actions.
|
|
17
|
+
#
|
|
18
|
+
# ── Conditions ──────────────────────────────────────────────────────
|
|
19
|
+
# inactive_days: 60
|
|
20
|
+
# date:
|
|
21
|
+
# attribute: committed_date
|
|
22
|
+
# condition: older_than | more_recent_than
|
|
23
|
+
# interval_type: days | weeks | months | years
|
|
24
|
+
# interval: 60
|
|
25
|
+
# name:
|
|
26
|
+
# matches: "^feature/.*"
|
|
27
|
+
# contains: "hotfix"
|
|
28
|
+
# starts_with: "release/"
|
|
29
|
+
# ends_with: "-wip"
|
|
30
|
+
# forbidden_name:
|
|
31
|
+
# matches: "^(main|master|develop)$"
|
|
32
|
+
# merged: true | false
|
|
33
|
+
# protected: true | false
|
|
34
|
+
# has_open_mr: true | false
|
|
35
|
+
# author:
|
|
36
|
+
# email_domain: "company.com"
|
|
37
|
+
# name_matches: "^Alice.*"
|
|
38
|
+
# forbidden_author:
|
|
39
|
+
# email_domain: "bot.com"
|
|
40
|
+
#
|
|
41
|
+
# ── Actions ─────────────────────────────────────────────────────────
|
|
42
|
+
# notify:
|
|
43
|
+
# title: "Stale branch: {{name}} in {{project_path}}"
|
|
44
|
+
# body: |
|
|
45
|
+
# @{{author_username}}, branch `{{name}}` in project `{{project_path}}`
|
|
46
|
+
# has been inactive for {{days_inactive}} days.
|
|
47
|
+
# Scheduled deletion: {{delete_date}}
|
|
48
|
+
# labels:
|
|
49
|
+
# - branch-cleanup
|
|
50
|
+
# delete: true
|
|
51
|
+
# print: "Branch {{name}} ({{project_path}}) — {{days_inactive}} days inactive."
|
|
52
|
+
#
|
|
53
|
+
# ── Template variables ───────────────────────────────────────────────
|
|
54
|
+
# {{name}} Branch name
|
|
55
|
+
# {{project_path}} Project path (e.g. my-group/my-project)
|
|
56
|
+
# {{author_name}} Git commit author name
|
|
57
|
+
# {{author_email}} Git commit author email
|
|
58
|
+
# {{author_username}} GitLab username (resolved from email)
|
|
59
|
+
# {{committed_date}} Last commit date (YYYY-MM-DD)
|
|
60
|
+
# {{days_inactive}} Days since last commit
|
|
61
|
+
# {{delete_date}} Scheduled deletion date
|
|
62
|
+
# {{today}} Today's date
|
|
63
|
+
|
|
64
|
+
resource_rules:
|
|
65
|
+
branches:
|
|
66
|
+
rules:
|
|
67
|
+
- name: Notify stale branches (60+ days)
|
|
68
|
+
conditions:
|
|
69
|
+
inactive_days: 60
|
|
70
|
+
merged: false
|
|
71
|
+
protected: false
|
|
72
|
+
has_open_mr: false
|
|
73
|
+
forbidden_name:
|
|
74
|
+
matches: "^(main|master|develop|staging|production)$"
|
|
75
|
+
limits:
|
|
76
|
+
most_recent: 50
|
|
77
|
+
actions:
|
|
78
|
+
notify:
|
|
79
|
+
title: "Stale branch: `{{name}}` in {{project_path}}"
|
|
80
|
+
body: |
|
|
81
|
+
Hi @{{author_username}},
|
|
82
|
+
|
|
83
|
+
Branch `{{name}}` in project `{{project_path}}` has been inactive
|
|
84
|
+
for **{{days_inactive}} days** (last commit: `{{committed_date}}`).
|
|
85
|
+
|
|
86
|
+
It will be deleted on **{{delete_date}}** unless you act.
|
|
87
|
+
|
|
88
|
+
- Open a Merge Request if this work needs to be merged
|
|
89
|
+
- Delete the branch if it is no longer needed
|
|
90
|
+
|
|
91
|
+
_Automated by gitlab-branch-triage._
|
|
92
|
+
labels:
|
|
93
|
+
- branch-cleanup
|
|
94
|
+
- automated
|
|
95
|
+
|
|
96
|
+
- name: Delete abandoned branches (90+ days)
|
|
97
|
+
conditions:
|
|
98
|
+
inactive_days: 90
|
|
99
|
+
merged: false
|
|
100
|
+
protected: false
|
|
101
|
+
has_open_mr: false
|
|
102
|
+
forbidden_name:
|
|
103
|
+
matches: "^(main|master|develop|staging|production)$"
|
|
104
|
+
actions:
|
|
105
|
+
print: "Deleting {{name}} in {{project_path}} ({{days_inactive}}d, {{author_name}})"
|
|
106
|
+
delete: true
|
|
107
|
+
|
|
108
|
+
- name: Delete stale merged branches (7+ days)
|
|
109
|
+
conditions:
|
|
110
|
+
date:
|
|
111
|
+
attribute: committed_date
|
|
112
|
+
condition: older_than
|
|
113
|
+
interval_type: days
|
|
114
|
+
interval: 7
|
|
115
|
+
merged: true
|
|
116
|
+
protected: false
|
|
117
|
+
actions:
|
|
118
|
+
print: "Deleting merged branch {{name}} in {{project_path}}"
|
|
119
|
+
delete: true
|
|
120
|
+
|
|
121
|
+
# ── MR rules ────────────────────────────────────────────────────────────
|
|
122
|
+
# Triage open merge requests (abandoned, draft, no reviewer, etc.)
|
|
123
|
+
#
|
|
124
|
+
# Available conditions:
|
|
125
|
+
# date:
|
|
126
|
+
# attribute: updated_at | created_at
|
|
127
|
+
# condition: older_than | more_recent_than
|
|
128
|
+
# interval_type: days | weeks | months | years
|
|
129
|
+
# interval: 30
|
|
130
|
+
# draft: true | false
|
|
131
|
+
# assigned: true | false
|
|
132
|
+
# has_reviewer: true | false
|
|
133
|
+
# pipeline_status: "failed" | "success" | "running" | ""
|
|
134
|
+
# labels:
|
|
135
|
+
# - some-label
|
|
136
|
+
# forbidden_labels:
|
|
137
|
+
# - do-not-close
|
|
138
|
+
# target_branch: main
|
|
139
|
+
# title:
|
|
140
|
+
# matches: ".*hotfix.*"
|
|
141
|
+
#
|
|
142
|
+
# Available actions:
|
|
143
|
+
# comment_mr: "Message with {{author_username}} and !{{iid}}"
|
|
144
|
+
# close_mr: true
|
|
145
|
+
# label_mr:
|
|
146
|
+
# - stale
|
|
147
|
+
# notify_mr:
|
|
148
|
+
# title: "Abandoned MR: !{{iid}} {{title}}"
|
|
149
|
+
# body: |
|
|
150
|
+
# @{{author_username}}, this MR has been inactive for {{days_since_update}} days.
|
|
151
|
+
# labels:
|
|
152
|
+
# - mr-cleanup
|
|
153
|
+
# print: "MR !{{iid}} {{title}} ({{days_since_update}}d)"
|
|
154
|
+
#
|
|
155
|
+
# Template variables:
|
|
156
|
+
# {{iid}} MR number
|
|
157
|
+
# {{title}} MR title
|
|
158
|
+
# {{web_url}} MR URL
|
|
159
|
+
# {{source_branch}} Source branch
|
|
160
|
+
# {{target_branch}} Target branch
|
|
161
|
+
# {{author_username}} Author GitLab username
|
|
162
|
+
# {{author_name}} Author display name
|
|
163
|
+
# {{labels}} Comma-separated labels
|
|
164
|
+
# {{state}} opened | closed | merged
|
|
165
|
+
# {{draft}} true | false
|
|
166
|
+
# {{days_since_update}} Days since last update
|
|
167
|
+
# {{days_since_creation}} Days since creation
|
|
168
|
+
# {{updated_at}} Last update date (YYYY-MM-DD)
|
|
169
|
+
# {{pipeline_status}} Pipeline status
|
|
170
|
+
# {{project_path}} Project path
|
|
171
|
+
# {{close_date}} Computed closing date
|
|
172
|
+
# {{today}} Today's date
|
|
173
|
+
|
|
174
|
+
merge_requests:
|
|
175
|
+
rules:
|
|
176
|
+
# ── 1. Warn about MRs with no activity for 30 days ──────────────────
|
|
177
|
+
- name: Warn abandoned MRs (30+ days without update)
|
|
178
|
+
conditions:
|
|
179
|
+
date:
|
|
180
|
+
attribute: updated_at
|
|
181
|
+
condition: older_than
|
|
182
|
+
interval_type: days
|
|
183
|
+
interval: 30
|
|
184
|
+
forbidden_labels:
|
|
185
|
+
- do-not-close
|
|
186
|
+
- on-hold
|
|
187
|
+
limits:
|
|
188
|
+
most_recent: 30
|
|
189
|
+
actions:
|
|
190
|
+
label_mr:
|
|
191
|
+
- stale
|
|
192
|
+
comment_mr: |
|
|
193
|
+
Hi @{{author_username}} 👋
|
|
194
|
+
|
|
195
|
+
This MR has had no activity for **{{days_since_update}} days**.
|
|
196
|
+
|
|
197
|
+
Please update it before **{{close_date}}** or it will be closed automatically.
|
|
198
|
+
|
|
199
|
+
If this is intentional, add the `on-hold` label to prevent auto-closing.
|
|
200
|
+
|
|
201
|
+
_Automated by gitlab-branch-triage._
|
|
202
|
+
|
|
203
|
+
# ── 2. Close MRs abandoned for 60+ days ─────────────────────────────
|
|
204
|
+
- name: Close abandoned MRs (60+ days without update)
|
|
205
|
+
conditions:
|
|
206
|
+
date:
|
|
207
|
+
attribute: updated_at
|
|
208
|
+
condition: older_than
|
|
209
|
+
interval_type: days
|
|
210
|
+
interval: 60
|
|
211
|
+
forbidden_labels:
|
|
212
|
+
- do-not-close
|
|
213
|
+
- on-hold
|
|
214
|
+
actions:
|
|
215
|
+
print: "Closing MR !{{iid}} {{title}} ({{days_since_update}}d without update)"
|
|
216
|
+
comment_mr: |
|
|
217
|
+
Closing this MR automatically after **{{days_since_update}} days** of inactivity.
|
|
218
|
+
|
|
219
|
+
If you want to continue this work, feel free to reopen it.
|
|
220
|
+
|
|
221
|
+
_Automated by gitlab-branch-triage._
|
|
222
|
+
close_mr: true
|
|
223
|
+
|
|
224
|
+
# ── 3. Notify about long-running drafts (30+ days) ──────────────────
|
|
225
|
+
- name: Notify long-running Draft/WIP MRs (30+ days)
|
|
226
|
+
conditions:
|
|
227
|
+
draft: true
|
|
228
|
+
date:
|
|
229
|
+
attribute: created_at
|
|
230
|
+
condition: older_than
|
|
231
|
+
interval_type: days
|
|
232
|
+
interval: 30
|
|
233
|
+
forbidden_labels:
|
|
234
|
+
- on-hold
|
|
235
|
+
actions:
|
|
236
|
+
label_mr:
|
|
237
|
+
- needs-attention
|
|
238
|
+
notify_mr:
|
|
239
|
+
title: "Long-running Draft MR: !{{iid}} in {{project_path}}"
|
|
240
|
+
body: |
|
|
241
|
+
Hi @{{author_username}} 👋
|
|
242
|
+
|
|
243
|
+
Draft MR [!{{iid}} {{title}}]({{web_url}}) in `{{project_path}}`
|
|
244
|
+
has been open for **{{days_since_creation}} days** and is still a Draft.
|
|
245
|
+
|
|
246
|
+
Consider:
|
|
247
|
+
- Marking it as ready for review if the work is complete
|
|
248
|
+
- Closing it if it is no longer needed
|
|
249
|
+
|
|
250
|
+
_Automated by gitlab-branch-triage._
|
|
251
|
+
labels:
|
|
252
|
+
- draft-cleanup
|
|
253
|
+
- automated
|
|
254
|
+
|
|
255
|
+
# ── 4. Flag MRs with failed pipeline and no activity ────────────────
|
|
256
|
+
- name: Flag MRs with failed pipeline (7+ days)
|
|
257
|
+
conditions:
|
|
258
|
+
pipeline_status: "failed"
|
|
259
|
+
date:
|
|
260
|
+
attribute: updated_at
|
|
261
|
+
condition: older_than
|
|
262
|
+
interval_type: days
|
|
263
|
+
interval: 7
|
|
264
|
+
actions:
|
|
265
|
+
label_mr:
|
|
266
|
+
- pipeline-failing
|
|
267
|
+
comment_mr: |
|
|
268
|
+
@{{author_username}}, the pipeline for this MR has been failing
|
|
269
|
+
for **{{days_since_update}} days** with no update.
|
|
270
|
+
|
|
271
|
+
Please check the pipeline and fix any issues.
|
|
272
|
+
|
|
273
|
+
_Automated by gitlab-branch-triage._
|
|
274
|
+
|
|
275
|
+
# ── 5. Notify MRs with no reviewer assigned ──────────────────────────
|
|
276
|
+
- name: Notify MRs with no reviewer (14+ days open)
|
|
277
|
+
conditions:
|
|
278
|
+
has_reviewer: false
|
|
279
|
+
draft: false
|
|
280
|
+
date:
|
|
281
|
+
attribute: created_at
|
|
282
|
+
condition: older_than
|
|
283
|
+
interval_type: days
|
|
284
|
+
interval: 14
|
|
285
|
+
forbidden_labels:
|
|
286
|
+
- on-hold
|
|
287
|
+
actions:
|
|
288
|
+
label_mr:
|
|
289
|
+
- needs-reviewer
|
|
290
|
+
print: "MR !{{iid}} open {{days_since_creation}}d with no reviewer: {{title}}"
|
|
291
|
+
YAML
|
|
292
|
+
|
|
293
|
+
EXAMPLE_CI = <<~YAML
|
|
294
|
+
# .gitlab-ci.yml
|
|
295
|
+
#
|
|
296
|
+
# Setup:
|
|
297
|
+
# 1. Add GITLAB_TOKEN (api scope) in Settings > CI/CD > Variables
|
|
298
|
+
# 2. Create a Schedule in Settings > CI/CD > Schedules:
|
|
299
|
+
# Cron: 0 9 * * 1 (every Monday at 09:00)
|
|
300
|
+
|
|
301
|
+
branch-triage:
|
|
302
|
+
stage: triage
|
|
303
|
+
image: ruby:3.2-slim
|
|
304
|
+
before_script:
|
|
305
|
+
- gem install gitlab-branch-triage --no-document
|
|
306
|
+
script:
|
|
307
|
+
- |
|
|
308
|
+
# Single project:
|
|
309
|
+
gitlab-branch-triage \\
|
|
310
|
+
--token $GITLAB_TOKEN \\
|
|
311
|
+
--source-id $CI_PROJECT_PATH \\
|
|
312
|
+
--no-dry-run
|
|
313
|
+
|
|
314
|
+
# Or entire group (all subgroups included):
|
|
315
|
+
# gitlab-branch-triage \\
|
|
316
|
+
# --token $GITLAB_TOKEN \\
|
|
317
|
+
# --source groups \\
|
|
318
|
+
# --source-id my-top-level-group \\
|
|
319
|
+
# --no-dry-run
|
|
320
|
+
rules:
|
|
321
|
+
- if: $CI_PIPELINE_SOURCE == "schedule"
|
|
322
|
+
- if: $CI_PIPELINE_SOURCE == "web"
|
|
323
|
+
when: manual
|
|
324
|
+
allow_failure: true
|
|
325
|
+
YAML
|
|
326
|
+
|
|
327
|
+
# ── Option parsing ──────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
options = {
|
|
330
|
+
token: ENV["GITLAB_TOKEN"],
|
|
331
|
+
source: "projects",
|
|
332
|
+
source_id: ENV["CI_PROJECT_PATH"],
|
|
333
|
+
host_url: ENV.fetch("GITLAB_HOST", "https://gitlab.com"),
|
|
334
|
+
policies_file: ".branch-triage-policies.yml",
|
|
335
|
+
dry_run: true,
|
|
336
|
+
debug: false,
|
|
337
|
+
exclude_archived: true,
|
|
338
|
+
exclude_forks: true,
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
parser = OptionParser.new do |opts|
|
|
342
|
+
opts.banner = "Usage: gitlab-branch-triage [options]"
|
|
343
|
+
opts.separator ""
|
|
344
|
+
opts.separator "Connection:"
|
|
345
|
+
|
|
346
|
+
opts.on("-t", "--token TOKEN",
|
|
347
|
+
"GitLab API token (or GITLAB_TOKEN env var)") { |v| options[:token] = v }
|
|
348
|
+
|
|
349
|
+
opts.on("-H", "--host-url URL",
|
|
350
|
+
"GitLab host URL (default: https://gitlab.com)") { |v| options[:host_url] = v }
|
|
351
|
+
|
|
352
|
+
opts.separator ""
|
|
353
|
+
opts.separator "Source (what to triage):"
|
|
354
|
+
|
|
355
|
+
opts.on("-s", "--source TYPE",
|
|
356
|
+
"Source type: 'projects' (default) or 'groups'") do |v|
|
|
357
|
+
unless %w[projects groups].include?(v)
|
|
358
|
+
abort("❌ --source must be 'projects' or 'groups'")
|
|
359
|
+
end
|
|
360
|
+
options[:source] = v
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
opts.on("-i", "--source-id ID",
|
|
364
|
+
"Project or group path/ID",
|
|
365
|
+
" projects: my-group/my-project",
|
|
366
|
+
" groups: my-group or my-group/sub-group") { |v| options[:source_id] = v }
|
|
367
|
+
|
|
368
|
+
opts.separator ""
|
|
369
|
+
opts.separator "Group filters (only with --source groups):"
|
|
370
|
+
|
|
371
|
+
opts.on("--[no-]exclude-archived",
|
|
372
|
+
"Exclude archived projects (default: true)") { |v| options[:exclude_archived] = v }
|
|
373
|
+
|
|
374
|
+
opts.on("--[no-]exclude-forks",
|
|
375
|
+
"Exclude forked projects (default: true)") { |v| options[:exclude_forks] = v }
|
|
376
|
+
|
|
377
|
+
opts.separator ""
|
|
378
|
+
opts.separator "Policies:"
|
|
379
|
+
|
|
380
|
+
opts.on("-f", "--policies-file FILE",
|
|
381
|
+
"YAML policies file (default: .branch-triage-policies.yml)") { |v| options[:policies_file] = v }
|
|
382
|
+
|
|
383
|
+
opts.separator ""
|
|
384
|
+
opts.separator "Behaviour:"
|
|
385
|
+
|
|
386
|
+
opts.on("-n", "--dry-run",
|
|
387
|
+
"Don't perform any real actions (default: on)") { options[:dry_run] = true }
|
|
388
|
+
|
|
389
|
+
opts.on("--no-dry-run",
|
|
390
|
+
"Execute real actions") { options[:dry_run] = false }
|
|
391
|
+
|
|
392
|
+
opts.on("-d", "--debug",
|
|
393
|
+
"Print extra debug information") { options[:debug] = true }
|
|
394
|
+
|
|
395
|
+
opts.separator ""
|
|
396
|
+
opts.separator "Helpers:"
|
|
397
|
+
|
|
398
|
+
opts.on("--init", "Create an example .branch-triage-policies.yml") do
|
|
399
|
+
path = ".branch-triage-policies.yml"
|
|
400
|
+
if File.exist?(path)
|
|
401
|
+
warn "⚠️ #{path} already exists. Remove it first."
|
|
402
|
+
exit 1
|
|
403
|
+
end
|
|
404
|
+
File.write(path, EXAMPLE_POLICY)
|
|
405
|
+
puts "✅ Created #{path}"
|
|
406
|
+
puts " Edit it, then run:"
|
|
407
|
+
puts " gitlab-branch-triage --token $TOKEN --source-id <project>"
|
|
408
|
+
puts " gitlab-branch-triage --token $TOKEN --source groups --source-id <group>"
|
|
409
|
+
exit
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
opts.on("--init-ci", "Print an example .gitlab-ci.yml snippet") do
|
|
413
|
+
puts EXAMPLE_CI
|
|
414
|
+
exit
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
opts.on("-v", "--version", "Print version") do
|
|
418
|
+
puts "gitlab-branch-triage #{Gitlab::BranchTriage::VERSION}"
|
|
419
|
+
exit
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
opts.on("-h", "--help", "Print this help") do
|
|
423
|
+
puts opts
|
|
424
|
+
exit
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
opts.separator ""
|
|
428
|
+
opts.separator "Examples:"
|
|
429
|
+
opts.separator " # Triage a single project (dry-run by default)"
|
|
430
|
+
opts.separator " gitlab-branch-triage --token $TOKEN --source-id my-group/my-project"
|
|
431
|
+
opts.separator ""
|
|
432
|
+
opts.separator " # Triage all projects in a group (subgroups included recursively)"
|
|
433
|
+
opts.separator " gitlab-branch-triage --token $TOKEN --source groups --source-id my-group"
|
|
434
|
+
opts.separator ""
|
|
435
|
+
opts.separator " # Triage a nested subgroup"
|
|
436
|
+
opts.separator " gitlab-branch-triage --token $TOKEN --source groups --source-id my-group/backend"
|
|
437
|
+
opts.separator ""
|
|
438
|
+
opts.separator " # Live run"
|
|
439
|
+
opts.separator " gitlab-branch-triage --token $TOKEN --source groups --source-id my-group --no-dry-run"
|
|
440
|
+
opts.separator ""
|
|
441
|
+
opts.separator " # Keep forks (exclude archived only)"
|
|
442
|
+
opts.separator " gitlab-branch-triage --token $TOKEN --source groups --source-id my-group --no-exclude-forks"
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
parser.parse!(ARGV)
|
|
446
|
+
|
|
447
|
+
# ── Validation ───────────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
abort("❌ --token is required (or set GITLAB_TOKEN)") unless options[:token]
|
|
450
|
+
abort("❌ --source-id is required") unless options[:source_id]
|
|
451
|
+
abort("❌ Policies file not found: #{options[:policies_file]}\n Run --init to create one.") \
|
|
452
|
+
unless File.exist?(options[:policies_file])
|
|
453
|
+
|
|
454
|
+
# ── Logger ────────────────────────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
logger = Logger.new($stdout)
|
|
457
|
+
logger.level = options[:debug] ? Logger::DEBUG : Logger::INFO
|
|
458
|
+
logger.formatter = proc { |_, _, _, msg| "#{msg}\n" }
|
|
459
|
+
|
|
460
|
+
# ── Run ───────────────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
begin
|
|
463
|
+
policy_loader = Gitlab::BranchTriage::PolicyLoader.load(options[:policies_file])
|
|
464
|
+
client = Gitlab::BranchTriage::Client.new(
|
|
465
|
+
host_url: options[:host_url],
|
|
466
|
+
token: options[:token],
|
|
467
|
+
logger: logger
|
|
468
|
+
)
|
|
469
|
+
runner = Gitlab::BranchTriage::Runner.new(
|
|
470
|
+
client: client,
|
|
471
|
+
policy_loader: policy_loader,
|
|
472
|
+
source: options[:source],
|
|
473
|
+
source_id: options[:source_id],
|
|
474
|
+
dry_run: options[:dry_run],
|
|
475
|
+
logger: logger,
|
|
476
|
+
options: {
|
|
477
|
+
exclude_archived: options[:exclude_archived],
|
|
478
|
+
exclude_forks: options[:exclude_forks],
|
|
479
|
+
}
|
|
480
|
+
)
|
|
481
|
+
runner.run
|
|
482
|
+
rescue Gitlab::BranchTriage::PolicyLoader::InvalidPolicyError => e
|
|
483
|
+
abort("❌ Invalid policy file: #{e.message}")
|
|
484
|
+
rescue Interrupt
|
|
485
|
+
abort("\n⛔ Interrupted.")
|
|
486
|
+
rescue => e
|
|
487
|
+
abort("❌ Fatal error: #{e.message}#{options[:debug] ? "\n#{e.backtrace.first(5).join("\n")}" : ""}")
|
|
488
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/gitlab/branch_triage/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "gitlab-branch-triage"
|
|
7
|
+
spec.version = Gitlab::BranchTriage::VERSION
|
|
8
|
+
spec.authors = ["SolucTeam"]
|
|
9
|
+
spec.email = ["contact@solucteam.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Automated branch and MR triage for GitLab, driven by YAML policies"
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
gitlab-branch-triage enables project maintainers to automatically triage
|
|
14
|
+
GitLab branches and merge requests based on policies defined in a YAML file.
|
|
15
|
+
Notify stale branch authors, auto-delete merged branches, close abandoned MRs,
|
|
16
|
+
and detect inactive authors — all configurable via simple rules.
|
|
17
|
+
DESC
|
|
18
|
+
spec.homepage = "https://github.com/solucteam/gitlab-branch-triage"
|
|
19
|
+
spec.license = "MIT"
|
|
20
|
+
|
|
21
|
+
spec.required_ruby_version = ">= 3.0"
|
|
22
|
+
|
|
23
|
+
spec.metadata = {
|
|
24
|
+
"homepage_uri" => spec.homepage,
|
|
25
|
+
"source_code_uri" => spec.homepage,
|
|
26
|
+
"changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md",
|
|
27
|
+
"bug_tracker_uri" => "#{spec.homepage}/issues",
|
|
28
|
+
"rubygems_mfa_required" => "true",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
spec.files = Dir["lib/**/*.rb", "bin/*", "*.md", "*.gemspec", "LICENSE", "logo.svg"]
|
|
32
|
+
spec.bindir = "bin"
|
|
33
|
+
spec.executables = ["gitlab-branch-triage"]
|
|
34
|
+
spec.require_paths = ["lib"]
|
|
35
|
+
|
|
36
|
+
spec.add_dependency "httparty", "~> 0.21"
|
|
37
|
+
spec.add_dependency "activesupport", "~> 7.0"
|
|
38
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitlab
|
|
4
|
+
module BranchTriage
|
|
5
|
+
module Actions
|
|
6
|
+
class Base
|
|
7
|
+
attr_reader :client, :project_id, :branch, :config, :dry_run, :logger
|
|
8
|
+
|
|
9
|
+
def initialize(client:, project_id:, branch:, config:, dry_run: true, logger: Logger.new($stdout))
|
|
10
|
+
@client = client
|
|
11
|
+
@project_id = project_id
|
|
12
|
+
@branch = branch
|
|
13
|
+
@config = config
|
|
14
|
+
@dry_run = dry_run
|
|
15
|
+
@logger = logger
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def execute
|
|
19
|
+
raise NotImplementedError, "#{self.class}#execute not implemented"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
protected
|
|
23
|
+
|
|
24
|
+
def render(template, extra_ctx = {})
|
|
25
|
+
ctx = branch.template_context(extra_ctx)
|
|
26
|
+
# Simple {{variable}} substitution (Mustache-style, no logic needed)
|
|
27
|
+
template.gsub(/\{\{(\w+)\}\}/) { ctx[$1] || "" }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def log_dry(msg)
|
|
31
|
+
logger.info(" [DRY-RUN] #{msg}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def log_ok(msg)
|
|
35
|
+
logger.info(" ✅ #{msg}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def log_err(msg)
|
|
39
|
+
logger.error(" ❌ #{msg}")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitlab
|
|
4
|
+
module BranchTriage
|
|
5
|
+
module Actions
|
|
6
|
+
# delete: true
|
|
7
|
+
#
|
|
8
|
+
# Deletes the branch unconditionally.
|
|
9
|
+
# Author state is checked for logging purposes only here —
|
|
10
|
+
# the notify action handles the "inactive author" special case.
|
|
11
|
+
class Delete < Base
|
|
12
|
+
def execute
|
|
13
|
+
return unless config
|
|
14
|
+
|
|
15
|
+
if dry_run
|
|
16
|
+
log_dry("Would delete branch: #{branch.name.inspect}")
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
ok = client.delete_branch(project_id, branch.name)
|
|
21
|
+
if ok
|
|
22
|
+
log_ok("Branch deleted: #{branch.name.inspect}")
|
|
23
|
+
else
|
|
24
|
+
log_err("Failed to delete branch: #{branch.name.inspect}")
|
|
25
|
+
end
|
|
26
|
+
rescue => e
|
|
27
|
+
log_err("Error deleting branch #{branch.name}: #{e.message}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitlab
|
|
4
|
+
module BranchTriage
|
|
5
|
+
module Actions
|
|
6
|
+
REGISTRY = {
|
|
7
|
+
"notify" => Notify,
|
|
8
|
+
"delete" => Delete,
|
|
9
|
+
"print" => Print,
|
|
10
|
+
"comment" => Comment,
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
class Executor
|
|
14
|
+
def initialize(client:, project_id:, branch:, actions:, dry_run: true, logger: Logger.new($stdout))
|
|
15
|
+
@client = client
|
|
16
|
+
@project_id = project_id
|
|
17
|
+
@branch = branch
|
|
18
|
+
@actions = actions || {}
|
|
19
|
+
@dry_run = dry_run
|
|
20
|
+
@logger = logger
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def execute!
|
|
24
|
+
@actions.each do |key, config|
|
|
25
|
+
klass = REGISTRY[key]
|
|
26
|
+
if klass.nil?
|
|
27
|
+
@logger.warn(" ⚠️ Unknown action '#{key}' — ignored")
|
|
28
|
+
next
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
klass.new(
|
|
32
|
+
client: @client,
|
|
33
|
+
project_id: @project_id,
|
|
34
|
+
branch: @branch,
|
|
35
|
+
config: config,
|
|
36
|
+
dry_run: @dry_run,
|
|
37
|
+
logger: @logger
|
|
38
|
+
).execute
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|