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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f0c6207b42ee32ebdee0043b2bc42fa281174887aa4e0e7983d8ec730212635
4
- data.tar.gz: 740c0de7ba602b533247202bdb9f8e2c4ef34fc29eac5ccbb130c87b6ccc19ed
3
+ metadata.gz: 728dd7248ca6212ced7a37dbef04c41fa8e658459132662ac6200b941a239464
4
+ data.tar.gz: 3462129e927e1fdb8f3f81611986f472e434dae2fc6d4ba4f234dc9767cce7b1
5
5
  SHA512:
6
- metadata.gz: 9b9e817f6c1b37d237ee74b08b23a0ac684de7cdbc20cffc0704e7699b465cf2f636ed19014f6583a3f76e1c98378dd4d93d3585ecc6a251d2c4d7ec9735dfbe
7
- data.tar.gz: fd595edf5634bab0e9ea7b0030869d2d79630c6cd7262cfe1472240158b7f9511b07f20ae8da7045e3a3c9697150fb8873e7f1f9747ea725819429b9d73fd5d3
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.0] - 2026-03-11
9
-
10
- ### Added
8
+ ## [1.0.1] - 2026-03-16
11
9
 
12
- - initial commit and push beta version v1.0.0 (bfba928)
10
+ ### Fixed
13
11
 
14
- ### Other
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.0]: https://github.com/solucteam/gitlab-branch-triage/releases/tag/v1.0.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 and merge request cleanup on GitLab using YAML-driven policies. Notify stale branch authors, auto-delete merged branches, close abandoned MRs, detect inactive authors, and keep your repositories clean — all from a single configuration file.
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
- - **MR triage** — warn about abandoned MRs, close stale ones, flag failing pipelines
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`. The file has two main sections: `branches` rules and `merge_requests` rules.
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
- ### Merge Request Rules
138
+ ### Template Variables
139
139
 
140
- ```yaml
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
- - name: Close abandoned MRs (60+ days)
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
- ### MR Conditions
144
+ gitlab-branch-triage manages the full lifecycle of notification issues automatically.
171
145
 
172
- | Condition | Example | Description |
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
- | Action | Config | Description |
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
- ### Template Variables
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
- **Branches:** `{{name}}`, `{{author_name}}`, `{{author_email}}`, `{{author_username}}`, `{{committed_date}}`, `{{days_inactive}}`, `{{delete_date}}`, `{{commit_title}}`, `{{short_sha}}`, `{{project_path}}`, `{{today}}`
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
- **Merge Requests:** `{{iid}}`, `{{title}}`, `{{web_url}}`, `{{source_branch}}`, `{{target_branch}}`, `{{author_username}}`, `{{author_name}}`, `{{labels}}`, `{{state}}`, `{{draft}}`, `{{days_since_update}}`, `{{days_since_creation}}`, `{{updated_at}}`, `{{pipeline_status}}`, `{{close_date}}`, `{{project_path}}`, `{{today}}`
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 'Add my feature'`)
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 and MR triage for GitLab, driven by YAML policies"
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 and merge requests based on policies defined in a YAML file.
15
- Notify stale branch authors, auto-delete merged branches, close abandoned MRs,
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 unconditionally.
9
- # Author state is checked for logging purposes only here
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
- # ── Merge Request endpoints ────────────────────────────────────────────
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
- def merge_requests(project_id, state: "opened")
42
- paginate("/projects/#{encode(project_id)}/merge_requests", state: state)
43
- end
40
+ # ── Issue endpoints ────────────────────────────────────────────────────
44
41
 
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
- )
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 close_mr(project_id, mr_iid)
51
+ def close_issue(project_id, issue_iid)
55
52
  resp = self.class.put(
56
- "/projects/#{encode(project_id)}/merge_requests/#{mr_iid}",
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
- 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
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 # reset on success
128
+ retries = 0
152
129
 
153
130
  handle_response!(resp)
154
131
  data = resp.parsed_response
@@ -26,10 +26,6 @@ module Gitlab
26
26
  def branch_rules
27
27
  raw.dig("resource_rules", "branches", "rules") || []
28
28
  end
29
-
30
- def mr_rules
31
- raw.dig("resource_rules", "merge_requests", "rules") || []
32
- end
33
29
  end
34
30
  end
35
31
  end
@@ -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, mrs_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? && mr_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, mr_rules) }
39
+ projects.each { |project| run_project(project, branch_rules) }
42
40
 
43
- print_summary(branch_rules.size, mr_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, mr_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
- run_branch_rules(branch_rules, project_id, project_path) if branch_rules.any?
77
- run_mr_rules(mr_rules, project_id, project_path) if mr_rules.any?
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, mr_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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module BranchTriage
5
- VERSION = "1.0.0"
5
+ VERSION = "1.0.1"
6
6
  end
7
7
  end
@@ -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.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 00:00:00.000000000 Z
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 and merge requests based on policies defined in a YAML file.
44
- Notify stale branch authors, auto-delete merged branches, close abandoned MRs,
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 and MR triage for GitLab, driven by YAML policies
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