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,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}} <{{author_email}}> |
|
|
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
|