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.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +38 -0
  3. data/LICENSE +21 -0
  4. data/README.md +269 -0
  5. data/bin/gitlab-branch-triage +488 -0
  6. data/gitlab-branch-triage.gemspec +38 -0
  7. data/lib/gitlab/branch_triage/actions/base.rb +44 -0
  8. data/lib/gitlab/branch_triage/actions/comment.rb +5 -0
  9. data/lib/gitlab/branch_triage/actions/delete.rb +32 -0
  10. data/lib/gitlab/branch_triage/actions/executor.rb +44 -0
  11. data/lib/gitlab/branch_triage/actions/mr_actions.rb +187 -0
  12. data/lib/gitlab/branch_triage/actions/notify.rb +145 -0
  13. data/lib/gitlab/branch_triage/actions/print.rb +40 -0
  14. data/lib/gitlab/branch_triage/client.rb +177 -0
  15. data/lib/gitlab/branch_triage/conditions/author_condition.rb +40 -0
  16. data/lib/gitlab/branch_triage/conditions/base.rb +21 -0
  17. data/lib/gitlab/branch_triage/conditions/date_condition.rb +34 -0
  18. data/lib/gitlab/branch_triage/conditions/evaluator.rb +52 -0
  19. data/lib/gitlab/branch_triage/conditions/inactive_days.rb +14 -0
  20. data/lib/gitlab/branch_triage/conditions/mr_conditions.rb +141 -0
  21. data/lib/gitlab/branch_triage/conditions/name_condition.rb +35 -0
  22. data/lib/gitlab/branch_triage/conditions/state_condition.rb +29 -0
  23. data/lib/gitlab/branch_triage/group_resolver.rb +47 -0
  24. data/lib/gitlab/branch_triage/policy_loader.rb +35 -0
  25. data/lib/gitlab/branch_triage/resource/branch.rb +89 -0
  26. data/lib/gitlab/branch_triage/resource/merge_request.rb +150 -0
  27. data/lib/gitlab/branch_triage/runner.rb +247 -0
  28. data/lib/gitlab/branch_triage/user_resolver.rb +103 -0
  29. data/lib/gitlab/branch_triage/version.rb +7 -0
  30. data/lib/gitlab-branch-triage.rb +60 -0
  31. data/logo.svg +87 -0
  32. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Comment and Print actions are defined together in print.rb
4
+ # This file exists so autoload can find the Comment constant.
5
+ require_relative "print"
@@ -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