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,187 @@
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
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module BranchTriage
5
+ module Actions
6
+ # notify:
7
+ # title: "🔔 Stale branch: {{name}}"
8
+ # body: |
9
+ # @{{author_username}}, branch `{{name}}` inactive for {{days_inactive}} days.
10
+ # labels:
11
+ # - branch-cleanup
12
+ #
13
+ # Behaviour when author is inactive/blocked/deleted:
14
+ # → Branch is deleted automatically (no point notifying a ghost).
15
+ # → An issue is still created and assigned to a project owner so the
16
+ # team knows a cleanup happened.
17
+ class Notify < Base
18
+ DEFAULT_TITLE = "🔔 Stale branch: `{{name}}`"
19
+ DEFAULT_BODY = <<~BODY
20
+ Hi @{{author_username}} 👋
21
+
22
+ Branch `{{name}}` has been inactive for **{{days_inactive}} days**
23
+ (last commit on `{{committed_date}}`).
24
+
25
+ It will be **automatically deleted on {{delete_date}}** unless you take action.
26
+
27
+ **What you can do:**
28
+ - 🔀 Open a Merge Request if this work needs to be merged
29
+ - 🗑️ Delete the branch manually if it's no longer needed
30
+ - 💬 Comment on this issue to request more time
31
+
32
+ _This issue was created automatically by gitlab-branch-triage._
33
+ BODY
34
+
35
+ INACTIVE_AUTHOR_TITLE = "🗑️ Branch auto-deleted: `{{name}}` (author inactive)"
36
+ INACTIVE_AUTHOR_BODY = <<~BODY
37
+ Branch `{{name}}` was **automatically deleted** because its author
38
+ is no longer active on this GitLab instance.
39
+
40
+ | Field | Value |
41
+ |-------|-------|
42
+ | Branch | `{{name}}` |
43
+ | Last commit | `{{committed_date}}` |
44
+ | Days inactive | {{days_inactive}} |
45
+ | Git author | {{author_name}} &lt;{{author_email}}&gt; |
46
+ | Author status | {{author_status}} |
47
+
48
+ _This issue was created automatically by gitlab-branch-triage._
49
+ BODY
50
+
51
+ def execute
52
+ resolver = UserResolver.new(client: client, logger: logger)
53
+ result = resolver.resolve(
54
+ email: branch.author_email,
55
+ name: branch.author_name
56
+ )
57
+
58
+ logger.info(" Author resolution: #{result.display}")
59
+
60
+ if result.inactive?
61
+ handle_inactive_author(result, resolver)
62
+ else
63
+ # Store resolved user on branch for template rendering
64
+ branch.author_user = result.user
65
+ notify_active_author
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ # ── Inactive author → delete branch + notify owners ──────────────────
72
+
73
+ def handle_inactive_author(result, resolver)
74
+ logger.info(" Author is inactive (#{result.status}) — branch will be deleted")
75
+
76
+ # 1. Delete the branch
77
+ if dry_run
78
+ log_dry("Would delete branch #{branch.name.inspect} (inactive author: #{result.status})")
79
+ else
80
+ ok = client.delete_branch(project_id, branch.name)
81
+ if ok
82
+ log_ok("Branch deleted: #{branch.name.inspect} (author #{result.status})")
83
+ else
84
+ log_err("Failed to delete branch #{branch.name.inspect}")
85
+ return
86
+ end
87
+ end
88
+
89
+ # 2. Notify owners so they're aware of the cleanup
90
+ owners = resolver.project_owners(project_id)
91
+ owner = owners.first
92
+ logger.warn(" No project owners/maintainers found for #{project_id}") if owner.nil?
93
+
94
+ title = render(INACTIVE_AUTHOR_TITLE)
95
+ body = render(INACTIVE_AUTHOR_BODY, "author_status" => result.status.to_s)
96
+
97
+ if dry_run
98
+ log_dry("Would create cleanup issue for owners: #{title.inspect}")
99
+ return
100
+ end
101
+
102
+ issue = client.create_issue(
103
+ project_id,
104
+ title: title,
105
+ description: body,
106
+ labels: ["branch-cleanup", "author-inactive", "automated"],
107
+ assignee_id: owner&.dig("id")
108
+ )
109
+ log_ok("Cleanup issue created for owners: #{issue["web_url"]}")
110
+ rescue => e
111
+ log_err("Error handling inactive author for #{branch.name}: #{e.message}")
112
+ end
113
+
114
+ # ── Active author → normal notification ───────────────────────────────
115
+
116
+ def notify_active_author
117
+ title_tpl = config.is_a?(Hash) ? config["title"] || DEFAULT_TITLE : DEFAULT_TITLE
118
+ body_tpl = config.is_a?(Hash) ? config["body"] || DEFAULT_BODY : DEFAULT_BODY
119
+ labels = config.is_a?(Hash) ? Array(config["labels"]) : []
120
+ delete_in = branch.delete_in_days || 30
121
+
122
+ delete_date = (Time.now + delete_in * 86_400).strftime("%Y-%m-%d")
123
+ title = render(title_tpl, "delete_date" => delete_date)
124
+ body = render(body_tpl, "delete_date" => delete_date)
125
+
126
+ if dry_run
127
+ log_dry("Would create issue: #{title.inspect}")
128
+ return
129
+ end
130
+
131
+ issue = client.create_issue(
132
+ project_id,
133
+ title: title,
134
+ description: body,
135
+ labels: labels,
136
+ assignee_id: branch.author_user&.dig("id")
137
+ )
138
+ log_ok("Issue created: #{issue["web_url"]}")
139
+ rescue => e
140
+ log_err("Failed to create issue: #{e.message}")
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module BranchTriage
5
+ module Actions
6
+ # print: "Branch {{name}} is {{days_inactive}} days old."
7
+ class Print < Base
8
+ def execute
9
+ msg = render(config.to_s)
10
+ logger.info(" 📢 #{msg}")
11
+ end
12
+ end
13
+
14
+ # comment:
15
+ # issue_iid: 42
16
+ # body: "Reminder: {{name}} is still inactive after {{days_inactive}} days."
17
+ class Comment < Base
18
+ def execute
19
+ unless config.is_a?(Hash) && config["issue_iid"] && config["body"]
20
+ log_err("'comment' action requires 'issue_iid' and 'body' keys")
21
+ return
22
+ end
23
+
24
+ body = render(config["body"])
25
+ iid = config["issue_iid"].to_i
26
+
27
+ if dry_run
28
+ log_dry("Would comment on issue ##{iid}: #{body[0, 80].inspect}...")
29
+ return
30
+ end
31
+
32
+ client.add_note(project_id, iid, body)
33
+ log_ok("Commented on issue ##{iid}")
34
+ rescue => e
35
+ log_err("Failed to comment: #{e.message}")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module BranchTriage
5
+ class Client
6
+ include HTTParty
7
+
8
+ BASE_PATH = "/api/v4"
9
+
10
+ attr_reader :host_url, :logger
11
+
12
+ def initialize(host_url:, token:, logger: Logger.new($stdout))
13
+ @host_url = host_url.chomp("/")
14
+ @token = token
15
+ @logger = logger
16
+
17
+ self.class.base_uri "#{@host_url}#{BASE_PATH}"
18
+ self.class.headers "PRIVATE-TOKEN" => token, "Content-Type" => "application/json"
19
+ end
20
+
21
+ # ── Branch endpoints ───────────────────────────────────────────────────
22
+
23
+ def branches(project_id)
24
+ paginate("/projects/#{encode(project_id)}/repository/branches")
25
+ end
26
+
27
+ def delete_branch(project_id, branch_name)
28
+ path = "/projects/#{encode(project_id)}/repository/branches/#{encode(branch_name)}"
29
+ resp = self.class.delete(path)
30
+ handle_response!(resp, expect: 204)
31
+ true
32
+ end
33
+
34
+ # ── Merge Request endpoints ────────────────────────────────────────────
35
+
36
+ def open_mr_source_branches(project_id)
37
+ mrs = paginate("/projects/#{encode(project_id)}/merge_requests", state: "opened")
38
+ mrs.map { |mr| mr["source_branch"] }.to_set
39
+ end
40
+
41
+ def merge_requests(project_id, state: "opened")
42
+ paginate("/projects/#{encode(project_id)}/merge_requests", state: state)
43
+ end
44
+
45
+ def add_mr_note(project_id, mr_iid, body)
46
+ resp = self.class.post(
47
+ "/projects/#{encode(project_id)}/merge_requests/#{mr_iid}/notes",
48
+ body: { body: body }.to_json
49
+ )
50
+ handle_response!(resp, expect: 201)
51
+ resp.parsed_response
52
+ end
53
+
54
+ def close_mr(project_id, mr_iid)
55
+ resp = self.class.put(
56
+ "/projects/#{encode(project_id)}/merge_requests/#{mr_iid}",
57
+ body: { state_event: "close" }.to_json
58
+ )
59
+ handle_response!(resp)
60
+ resp.parsed_response
61
+ end
62
+
63
+ def add_mr_labels(project_id, mr_iid, labels)
64
+ resp = self.class.put(
65
+ "/projects/#{encode(project_id)}/merge_requests/#{mr_iid}",
66
+ body: { add_labels: labels.join(",") }.to_json
67
+ )
68
+ handle_response!(resp)
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
81
+ end
82
+
83
+ def add_note(project_id, issue_iid, body)
84
+ resp = self.class.post(
85
+ "/projects/#{encode(project_id)}/issues/#{issue_iid}/notes",
86
+ body: { body: body }.to_json
87
+ )
88
+ handle_response!(resp, expect: 201)
89
+ resp.parsed_response
90
+ end
91
+
92
+ # ── Group endpoints ───────────────────────────────────────────────────
93
+
94
+ # Returns all projects in a group, including subgroups recursively.
95
+ # The GitLab API supports include_subgroups=true natively.
96
+ def group_projects(group_id, include_subgroups: true)
97
+ paginate(
98
+ "/groups/#{encode(group_id)}/projects",
99
+ include_subgroups: include_subgroups,
100
+ with_shared: false
101
+ )
102
+ end
103
+
104
+ # ── User endpoints ─────────────────────────────────────────────────────
105
+
106
+ # Search users — includes blocked users if token has admin scope,
107
+ # otherwise only returns active accounts.
108
+ def find_users(search, per_page: 5)
109
+ resp = self.class.get("/users", query: { search: search, per_page: per_page })
110
+ data = resp.parsed_response
111
+ data.is_a?(Array) ? data : []
112
+ end
113
+
114
+ # Kept for backward compat
115
+ def find_user(search)
116
+ find_users(search, per_page: 1).first
117
+ end
118
+
119
+ # Returns project members with at least min_access_level.
120
+ # access_level: 40 = Maintainer, 50 = Owner
121
+ def project_members(project_id, min_access_level: 40)
122
+ paginate(
123
+ "/projects/#{encode(project_id)}/members/all",
124
+ min_access_level: min_access_level
125
+ )
126
+ end
127
+
128
+ private
129
+
130
+ MAX_RETRIES = 5
131
+
132
+ def paginate(path, extra_params = {})
133
+ results = []
134
+ page = 1
135
+ retries = 0
136
+
137
+ loop do
138
+ resp = self.class.get(path, query: { per_page: 100, page: page }.merge(extra_params))
139
+
140
+ # Handle rate limiting with max retries
141
+ if resp.code == 429
142
+ retries += 1
143
+ raise "Rate limited by GitLab API after #{MAX_RETRIES} retries" if retries > MAX_RETRIES
144
+
145
+ wait = (resp.headers["retry-after"] || 10).to_i
146
+ @logger.warn("Rate limited — waiting #{wait}s (retry #{retries}/#{MAX_RETRIES})")
147
+ sleep(wait)
148
+ next
149
+ end
150
+
151
+ retries = 0 # reset on success
152
+
153
+ handle_response!(resp)
154
+ data = resp.parsed_response
155
+ break if data.empty?
156
+
157
+ results.concat(data)
158
+ break if data.size < 100
159
+
160
+ page += 1
161
+ end
162
+
163
+ results
164
+ end
165
+
166
+ def handle_response!(resp, expect: 200)
167
+ return if resp.code == expect || (expect == 200 && resp.success?)
168
+
169
+ raise "GitLab API error #{resp.code}: #{resp.body&.slice(0, 200)}"
170
+ end
171
+
172
+ def encode(str)
173
+ URI.encode_www_form_component(str.to_s)
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module BranchTriage
5
+ module Conditions
6
+ # author:
7
+ # email_domain: "company.com"
8
+ # name_matches: "^John.*"
9
+ # username: "john.doe"
10
+ class AuthorCondition < Base
11
+ def satisfied?
12
+ match_author?
13
+ end
14
+
15
+ protected
16
+
17
+ def match_author?
18
+ if config.key?("email_domain")
19
+ return branch.author_email.end_with?("@#{config["email_domain"]}")
20
+ end
21
+ if config.key?("name_matches")
22
+ return Regexp.new(config["name_matches"]).match?(branch.author_name)
23
+ end
24
+ if config.key?("username")
25
+ term = config["username"]
26
+ return branch.author_email.include?(term) || branch.author_name.include?(term)
27
+ end
28
+ true
29
+ end
30
+ end
31
+
32
+ # forbidden_author: — inverse of AuthorCondition
33
+ class ForbiddenAuthor < AuthorCondition
34
+ def satisfied?
35
+ !match_author?
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module BranchTriage
5
+ module Conditions
6
+ class Base
7
+ attr_reader :branch, :config
8
+
9
+ def initialize(branch, config)
10
+ @branch = branch
11
+ @config = config
12
+ end
13
+
14
+ # Subclasses must implement #satisfied?
15
+ def satisfied?
16
+ raise NotImplementedError, "#{self.class}#satisfied? not implemented"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module BranchTriage
5
+ module Conditions
6
+ # date:
7
+ # attribute: committed_date
8
+ # condition: older_than | more_recent_than
9
+ # interval_type: days | weeks | months | years
10
+ # interval: 60
11
+ class DateCondition < Base
12
+ MULTIPLIERS = {
13
+ "days" => 1,
14
+ "weeks" => 7,
15
+ "months" => 30,
16
+ "years" => 365,
17
+ }.freeze
18
+
19
+ def satisfied?
20
+ threshold_days = config["interval"].to_i * MULTIPLIERS.fetch(config["interval_type"] || "days", 1)
21
+ age_days = branch.days_inactive
22
+
23
+ case config["condition"]
24
+ when "older_than" then age_days >= threshold_days
25
+ when "more_recent_than" then age_days < threshold_days
26
+ else
27
+ warn "Unknown date condition: #{config["condition"]}"
28
+ false
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module BranchTriage
5
+ module Conditions
6
+ # Maps condition keys from the YAML to their handler classes.
7
+ REGISTRY = {
8
+ "date" => DateCondition,
9
+ "inactive_days" => InactiveDays,
10
+ "name" => NameCondition,
11
+ "forbidden_name" => ForbiddenName,
12
+ "merged" => nil, # handled inline (StateCondition)
13
+ "protected" => nil,
14
+ "has_open_mr" => nil,
15
+ "author" => AuthorCondition,
16
+ "forbidden_author" => ForbiddenAuthor,
17
+ }.freeze
18
+
19
+ STATE_KEYS = %w[merged protected has_open_mr].freeze
20
+
21
+ class Evaluator
22
+ def initialize(branch, conditions)
23
+ @branch = branch
24
+ @conditions = conditions || {}
25
+ end
26
+
27
+ # Returns true if ALL conditions are satisfied.
28
+ def satisfied?
29
+ @conditions.all? do |key, value|
30
+ evaluate_condition(key, value)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def evaluate_condition(key, value)
37
+ if STATE_KEYS.include?(key)
38
+ return StateCondition.for(key, @branch, value).satisfied?
39
+ end
40
+
41
+ klass = REGISTRY[key]
42
+ if klass.nil? && !STATE_KEYS.include?(key)
43
+ warn " ⚠️ Unknown condition '#{key}' — ignored"
44
+ return true
45
+ end
46
+
47
+ klass.new(@branch, value).satisfied?
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end