gitlab-triage 1.52.0 → 1.53.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 +4 -4
- data/.rubocop_todo.yml +3 -1
- data/Gemfile.lock +1 -1
- data/README.md +102 -0
- data/lib/gitlab/triage/action/work_item.rb +183 -0
- data/lib/gitlab/triage/action.rb +25 -8
- data/lib/gitlab/triage/engine.rb +33 -3
- data/lib/gitlab/triage/filters/work_item_date_conditions_filter.rb +12 -0
- data/lib/gitlab/triage/network.rb +4 -0
- data/lib/gitlab/triage/network_adapters/httparty_adapter.rb +24 -0
- data/lib/gitlab/triage/normalizers/work_item_normalizer.rb +74 -0
- data/lib/gitlab/triage/policies/base_policy.rb +4 -0
- data/lib/gitlab/triage/resource/context.rb +1 -0
- data/lib/gitlab/triage/resource/work_item.rb +35 -0
- data/lib/gitlab/triage/rest_api_network.rb +22 -0
- data/lib/gitlab/triage/sources/work_items_rest_source.rb +224 -0
- data/lib/gitlab/triage/validators/policy_validator.rb +66 -0
- data/lib/gitlab/triage/version.rb +1 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e4b12c328cc4c5a865ceca6ccadd49a8dedc1066564fa62a28718c3b164f3a15
|
|
4
|
+
data.tar.gz: 1298d537c79ef8d74f3816e18d482a8bbee02fde1b3a1717869709795f40f547
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c8d68d1f8162e2324685ee874a862429569f76d4a060d138f922f1b4866b57eabfc9420f72a6da68f28f0285669542169f6b7294f785e6152c3075c36280bf40
|
|
7
|
+
data.tar.gz: aaf7e8821f7cbfc9112c2f25776566c7775276c5eeebaccd43dedcee686824cce2db97ee813648504274befd5db4696f4e816afb6bab83d72039e2cfa7e13437
|
data/.rubocop_todo.yml
CHANGED
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
# Note that changes in the inspected code, or installation of new
|
|
7
7
|
# versions of RuboCop, may require this file to be generated again.
|
|
8
8
|
|
|
9
|
-
# Offense count:
|
|
9
|
+
# Offense count: 81
|
|
10
10
|
CodeReuse/ActiveRecord:
|
|
11
11
|
Exclude:
|
|
12
|
+
- 'lib/gitlab/triage/action/work_item.rb'
|
|
12
13
|
- 'lib/gitlab/triage/engine.rb'
|
|
13
14
|
- 'lib/gitlab/triage/filters/base_conditions_filter.rb'
|
|
14
15
|
- 'lib/gitlab/triage/filters/member_conditions_filter.rb'
|
|
15
16
|
- 'lib/gitlab/triage/graphql_network.rb'
|
|
17
|
+
- 'lib/gitlab/triage/normalizers/work_item_normalizer.rb'
|
|
16
18
|
- 'lib/gitlab/triage/validators/limiter_validator.rb'
|
|
17
19
|
- 'spec/gitlab/triage/action_spec.rb'
|
|
18
20
|
- 'spec/gitlab/triage/engine_spec.rb'
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -50,6 +50,7 @@ Select which resource to add the policy to:
|
|
|
50
50
|
- `issues`
|
|
51
51
|
- `merge_requests`
|
|
52
52
|
- `branches`
|
|
53
|
+
- `work_items` (see [Work items policy type](#work-items-policy-type))
|
|
53
54
|
|
|
54
55
|
And create an array of `rules` to define your policies:
|
|
55
56
|
|
|
@@ -148,6 +149,107 @@ We're enforcing multiple polices with pipeline schedules at [triage-ops](
|
|
|
148
149
|
https://gitlab.com/gitlab-org/quality/triage-ops), where we're also
|
|
149
150
|
extensively utilizing the [plugins system](#can-i-customize).
|
|
150
151
|
|
|
152
|
+
### Work items policy type
|
|
153
|
+
|
|
154
|
+
The `work_items` policy type fetches and updates resources through the
|
|
155
|
+
[Work Items REST API](https://docs.gitlab.com/api/work_items/) instead of the
|
|
156
|
+
legacy issues REST API. The legacy issues API cannot see custom work item
|
|
157
|
+
types; the `work_items` type can, which lets you triage work items of any type,
|
|
158
|
+
including namespace-defined custom types.
|
|
159
|
+
|
|
160
|
+
> **Warning:** The Work Items REST API is currently behind the
|
|
161
|
+
`:work_item_rest_api` feature flag and is pre-GA. The `work_items` policy type
|
|
162
|
+
only works on instances where that API is available and enabled, and its
|
|
163
|
+
surface may change before GA. It is not yet a stable contract for external
|
|
164
|
+
users. Treat this policy type as experimental until the API reaches GA.
|
|
165
|
+
|
|
166
|
+
> **Note:** A `404` or `403` from the list endpoint (feature flag off, or
|
|
167
|
+
insufficient access) raises a clear error rather than silently matching zero
|
|
168
|
+
resources.
|
|
169
|
+
|
|
170
|
+
Example:
|
|
171
|
+
|
|
172
|
+
```yml
|
|
173
|
+
resource_rules:
|
|
174
|
+
work_items:
|
|
175
|
+
rules:
|
|
176
|
+
- name: Triage new bugs
|
|
177
|
+
conditions:
|
|
178
|
+
type: Bug
|
|
179
|
+
state: opened
|
|
180
|
+
labels:
|
|
181
|
+
- needs-triage
|
|
182
|
+
actions:
|
|
183
|
+
assignees:
|
|
184
|
+
- my-username
|
|
185
|
+
labels:
|
|
186
|
+
- triaged
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### Supported conditions
|
|
190
|
+
|
|
191
|
+
- `type` - match work item types by name, including custom types. Maps to the
|
|
192
|
+
`work_item_type_names` filter (case-insensitive, server-side).
|
|
193
|
+
- `state` - `opened` or `closed`.
|
|
194
|
+
- `status` - custom work item status name (for example `In progress`).
|
|
195
|
+
Enterprise Edition only.
|
|
196
|
+
- `labels` / `forbidden_labels` - label names to require or exclude.
|
|
197
|
+
- `assignees` - assignee usernames.
|
|
198
|
+
- `date` - `created_at` / `updated_at` staleness (same shape as the
|
|
199
|
+
[date condition](#date-condition)).
|
|
200
|
+
|
|
201
|
+
#### Supported actions
|
|
202
|
+
|
|
203
|
+
- `labels` - add labels by name. If any requested label does not exist, the
|
|
204
|
+
automation stops immediately (matching the `labels` action on the `issues`
|
|
205
|
+
type) rather than applying nothing.
|
|
206
|
+
- `assignees` - set assignees by username. If any requested username does not
|
|
207
|
+
exist, the automation stops immediately rather than sending an empty
|
|
208
|
+
assignee set (which would unassign everyone).
|
|
209
|
+
- `comment` - **not yet supported.** The Work Items REST API does not yet
|
|
210
|
+
provide a note-create endpoint (tracked in
|
|
211
|
+
[gitlab-org/gitlab#604028](https://gitlab.com/gitlab-org/gitlab/-/work_items/604028)).
|
|
212
|
+
A `comment` action on a `work_items` policy is rejected at load time, before
|
|
213
|
+
any resource is fetched or acted on, with a clear error - so an unsupported
|
|
214
|
+
policy never runs partway through. Use the `issues` policy type if you need
|
|
215
|
+
to comment.
|
|
216
|
+
|
|
217
|
+
Summaries are also supported (`summarize` and `comment_on_summary`). Any other
|
|
218
|
+
action key (for example `remove_labels`, `mention`, `move`, or `status`) is
|
|
219
|
+
rejected at load time with an error listing the supported actions. On the
|
|
220
|
+
older policy types these keys are applied through comment quick actions, which
|
|
221
|
+
work items do not support yet - rejecting them up front beats silently doing
|
|
222
|
+
nothing.
|
|
223
|
+
|
|
224
|
+
Actions require a token with sufficient permissions (Planner or Reporter role
|
|
225
|
+
and above). The update endpoint can return success without persisting a change
|
|
226
|
+
when the token lacks permission, so after each update the applied labels and
|
|
227
|
+
assignees are read back from the response and the run raises if the change was
|
|
228
|
+
not reflected.
|
|
229
|
+
|
|
230
|
+
The `work_items` type works against both project and group sources
|
|
231
|
+
(`--source projects` or `--source groups`). Fetches use the source you run
|
|
232
|
+
against; updates and label lookups target each work item's own namespace,
|
|
233
|
+
because a group run can return work items living in different sub-projects and
|
|
234
|
+
an iid is only unique within its namespace. Project-scoped resource helpers
|
|
235
|
+
(for example `project_path` in templates) likewise resolve the underlying
|
|
236
|
+
project from the work item's namespace.
|
|
237
|
+
|
|
238
|
+
The `api:` field is not applicable to the `work_items` type, since the type
|
|
239
|
+
already fixes the transport to the Work Items REST API.
|
|
240
|
+
|
|
241
|
+
#### Field vocabulary
|
|
242
|
+
|
|
243
|
+
The `work_items` condition names (`type`, `status`, `state`) are deliberately
|
|
244
|
+
cleaner than the `issues` equivalents (`issue_type`, `work_item_status`,
|
|
245
|
+
`state`). This is intentional: they are meant to become the shared vocabulary
|
|
246
|
+
that `issues` converges onto over time (long term, `issues` would alias to
|
|
247
|
+
`work_items` and eventually deprecate in a major version), not a throwaway
|
|
248
|
+
parallel dialect. `state` (`opened` / `closed`) already means the same on both
|
|
249
|
+
types; `status` is new on `work_items` and has no clean one-to-one equivalent
|
|
250
|
+
on `issues` (where it is the prefixed `work_item_status`). Whether the old
|
|
251
|
+
names are carried as aliases or dropped at a major bump is a future decision.
|
|
252
|
+
|
|
151
253
|
### Fields
|
|
152
254
|
|
|
153
255
|
A policy consists of the following fields:
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../sources/work_items_rest_source'
|
|
5
|
+
require_relative '../url_builders/url_builder'
|
|
6
|
+
require_relative '../resource/label'
|
|
7
|
+
|
|
8
|
+
module Gitlab
|
|
9
|
+
module Triage
|
|
10
|
+
module Action
|
|
11
|
+
# Applies work_items actions through the Work Items REST API update
|
|
12
|
+
# endpoint (PATCH) rather than the issues note/quick-action path.
|
|
13
|
+
#
|
|
14
|
+
# - labels -> features.labels.add_label_ids (resolved from names)
|
|
15
|
+
# - assignees -> features.assignees.assignee_ids (resolved from usernames)
|
|
16
|
+
#
|
|
17
|
+
# Missing labels or usernames stop the automation immediately (matching
|
|
18
|
+
# the labels action behaviour on the issues type) rather than silently
|
|
19
|
+
# applying nothing - which is especially important because clearing the
|
|
20
|
+
# assignees array would unassign everyone.
|
|
21
|
+
class WorkItem < Base
|
|
22
|
+
AssigneeDoesntExistError = Class.new(StandardError)
|
|
23
|
+
UpdateNotAppliedError = Class.new(StandardError)
|
|
24
|
+
|
|
25
|
+
# The dry run must not touch the network. It prints the requested
|
|
26
|
+
# label names and assignee usernames rather than resolving them to
|
|
27
|
+
# ids, mirroring how Comment::Dry avoids network calls.
|
|
28
|
+
class Dry < WorkItem
|
|
29
|
+
def act
|
|
30
|
+
puts "The following work items would be updated for the rule **#{policy.name}**:\n\n"
|
|
31
|
+
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def perform(resource)
|
|
38
|
+
puts "# #{resource[:web_url]}"
|
|
39
|
+
puts "Would add labels: #{requested_labels.join(', ')}" if requested_labels.any?
|
|
40
|
+
puts "Would assign: #{requested_assignees.join(', ')}" if requested_assignees.any?
|
|
41
|
+
puts "\n"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def act
|
|
46
|
+
return unless requested_labels.any? || requested_assignees.any?
|
|
47
|
+
|
|
48
|
+
policy.resources.each do |resource|
|
|
49
|
+
perform(resource)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def perform(resource)
|
|
56
|
+
features = build_features(resource)
|
|
57
|
+
return if features.empty?
|
|
58
|
+
|
|
59
|
+
response = source.update(resource, features)
|
|
60
|
+
|
|
61
|
+
verify_applied!(resource, response)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_features(resource)
|
|
65
|
+
features = {}
|
|
66
|
+
|
|
67
|
+
features[:labels] = { add_label_ids: label_ids_to_add(resource) } if requested_labels.any?
|
|
68
|
+
features[:assignees] = { assignee_ids: assignee_ids } if requested_assignees.any?
|
|
69
|
+
|
|
70
|
+
features
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def requested_labels
|
|
74
|
+
@requested_labels ||= Array(policy.actions[:labels])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def requested_assignees
|
|
78
|
+
@requested_assignees ||= Array(policy.actions[:assignees])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def source
|
|
82
|
+
@source ||= Sources::WorkItemsRestSource.new(
|
|
83
|
+
network: network, options: network.options)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Label ids are namespace-specific, so resolve them against the
|
|
87
|
+
# resource's own namespace and cache per scope - a group-spanning run
|
|
88
|
+
# then makes one label lookup per distinct project/group.
|
|
89
|
+
def label_ids_to_add(resource)
|
|
90
|
+
scope = source.scope_for(resource)
|
|
91
|
+
|
|
92
|
+
@label_ids_by_scope ||= {}
|
|
93
|
+
@label_ids_by_scope[scope] ||= resolve_label_ids(requested_labels, scope)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def assignee_ids
|
|
97
|
+
@assignee_ids ||= resolve_assignee_ids(requested_assignees)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Resolve every requested label name to an id. Any name that does not
|
|
101
|
+
# resolve raises, so the run stops instead of PATCHing a partial (or
|
|
102
|
+
# empty) label set.
|
|
103
|
+
def resolve_label_ids(names, scope)
|
|
104
|
+
return [] if names.empty?
|
|
105
|
+
|
|
106
|
+
by_name = network.query_api(labels_url(scope)).index_by { |label| label[:name] }
|
|
107
|
+
|
|
108
|
+
names.map do |name|
|
|
109
|
+
label = by_name[name]
|
|
110
|
+
raise Resource::Label::LabelDoesntExistError, "Label `#{name}` doesn't exist!" unless label
|
|
111
|
+
|
|
112
|
+
label[:id]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Resolve every requested username to an id. A missing username raises
|
|
117
|
+
# rather than being dropped, because an empty assignee_ids array would
|
|
118
|
+
# clear all assignees on the work item.
|
|
119
|
+
def resolve_assignee_ids(usernames)
|
|
120
|
+
return [] if usernames.empty?
|
|
121
|
+
|
|
122
|
+
usernames.map do |username|
|
|
123
|
+
id = resolve_user_id(username)
|
|
124
|
+
raise AssigneeDoesntExistError, "User `#{username}` doesn't exist!" unless id
|
|
125
|
+
|
|
126
|
+
id
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def resolve_user_id(username)
|
|
131
|
+
users = network.query_api(users_url(username))
|
|
132
|
+
|
|
133
|
+
users.first&.dig(:id)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The Work Items REST update endpoint can return success without
|
|
137
|
+
# persisting a change when the token lacks permission. The response
|
|
138
|
+
# echoes the resulting widgets, so confirm the requested ids landed and
|
|
139
|
+
# raise otherwise rather than reporting a silent no-op as success.
|
|
140
|
+
def verify_applied!(resource, response)
|
|
141
|
+
verify_labels_applied!(resource, response) if requested_labels.any?
|
|
142
|
+
verify_assignees_applied!(resource, response) if requested_assignees.any?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def verify_labels_applied!(resource, response)
|
|
146
|
+
applied_ids = [*response.dig(:features, :labels, :labels)].pluck(:id)
|
|
147
|
+
missing = label_ids_to_add(resource) - applied_ids
|
|
148
|
+
return if missing.empty?
|
|
149
|
+
|
|
150
|
+
raise UpdateNotAppliedError,
|
|
151
|
+
"Labels were not applied to #{resource[:web_url]}; the token may lack permission."
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def verify_assignees_applied!(resource, response)
|
|
155
|
+
applied_ids = [*response.dig(:features, :assignees)].pluck(:id)
|
|
156
|
+
missing = assignee_ids - applied_ids
|
|
157
|
+
return if missing.empty?
|
|
158
|
+
|
|
159
|
+
raise UpdateNotAppliedError,
|
|
160
|
+
"Assignees were not applied to #{resource[:web_url]}; the token may lack permission."
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def labels_url(scope)
|
|
164
|
+
scope_source, scope_id = scope
|
|
165
|
+
|
|
166
|
+
UrlBuilders::UrlBuilder.new(
|
|
167
|
+
network_options: network.options,
|
|
168
|
+
source: scope_source,
|
|
169
|
+
source_id: scope_id,
|
|
170
|
+
resource_type: 'labels',
|
|
171
|
+
params: { per_page: 100 }
|
|
172
|
+
).build
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def users_url(username)
|
|
176
|
+
options = network.options
|
|
177
|
+
|
|
178
|
+
"#{options.host_url}/api/#{options.api_version}/users?username=#{CGI.escape(username)}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
data/lib/gitlab/triage/action.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative 'action/comment_on_summary'
|
|
|
6
6
|
require_relative 'action/issue'
|
|
7
7
|
require_relative 'action/delete'
|
|
8
8
|
require_relative 'action/work_item_status'
|
|
9
|
+
require_relative 'action/work_item'
|
|
9
10
|
|
|
10
11
|
module Gitlab
|
|
11
12
|
module Triage
|
|
@@ -13,18 +14,34 @@ module Gitlab
|
|
|
13
14
|
def self.process(policy:, **args)
|
|
14
15
|
policy.validate!
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
[Summarize, policy.summarize?],
|
|
18
|
-
[Comment, policy.comment?],
|
|
19
|
-
[CommentOnSummary, policy.comment_on_summary?],
|
|
20
|
-
[Issue, policy.issue?],
|
|
21
|
-
[Delete, policy.delete?],
|
|
22
|
-
[WorkItemStatus, policy.work_item_status?]
|
|
23
|
-
].each do |action, active|
|
|
17
|
+
actions_for(policy).each do |action, active|
|
|
24
18
|
act(action: action, policy: policy, **args) if active
|
|
25
19
|
end
|
|
26
20
|
end
|
|
27
21
|
|
|
22
|
+
# Work items are fetched and updated through the Work Items REST API,
|
|
23
|
+
# which does not share the issues note/quick-action write path. Their
|
|
24
|
+
# actions (assign, label, comment) are handled by a dedicated action
|
|
25
|
+
# instead of the note-based Comment action.
|
|
26
|
+
def self.actions_for(policy)
|
|
27
|
+
if policy.type == 'work_items'
|
|
28
|
+
[
|
|
29
|
+
[Summarize, policy.summarize?],
|
|
30
|
+
[WorkItem, policy.work_item?],
|
|
31
|
+
[CommentOnSummary, policy.comment_on_summary?]
|
|
32
|
+
]
|
|
33
|
+
else
|
|
34
|
+
[
|
|
35
|
+
[Summarize, policy.summarize?],
|
|
36
|
+
[Comment, policy.comment?],
|
|
37
|
+
[CommentOnSummary, policy.comment_on_summary?],
|
|
38
|
+
[Issue, policy.issue?],
|
|
39
|
+
[Delete, policy.delete?],
|
|
40
|
+
[WorkItemStatus, policy.work_item_status?]
|
|
41
|
+
]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
28
45
|
def self.act(action:, dry:, **args)
|
|
29
46
|
klass =
|
|
30
47
|
if dry
|
data/lib/gitlab/triage/engine.rb
CHANGED
|
@@ -15,6 +15,7 @@ require_relative 'filters/assignee_member_conditions_filter'
|
|
|
15
15
|
require_relative 'filters/discussions_conditions_filter'
|
|
16
16
|
require_relative 'filters/ruby_conditions_filter'
|
|
17
17
|
require_relative 'filters/work_item_status_conditions_filter'
|
|
18
|
+
require_relative 'filters/work_item_date_conditions_filter'
|
|
18
19
|
require_relative 'limiters/date_field_limiter'
|
|
19
20
|
require_relative 'action'
|
|
20
21
|
require_relative 'policies/rule_policy'
|
|
@@ -31,6 +32,9 @@ require_relative 'rest_api_network'
|
|
|
31
32
|
require_relative 'network_adapters/httparty_adapter'
|
|
32
33
|
require_relative 'network_adapters/graphql_adapter'
|
|
33
34
|
require_relative 'graphql_queries/query_builder'
|
|
35
|
+
require_relative 'sources/work_items_rest_source'
|
|
36
|
+
require_relative 'validators/params_validator'
|
|
37
|
+
require_relative 'validators/policy_validator'
|
|
34
38
|
require_relative 'ui'
|
|
35
39
|
require_relative 'api_observability'
|
|
36
40
|
|
|
@@ -46,7 +50,8 @@ module Gitlab
|
|
|
46
50
|
date: {
|
|
47
51
|
'branches' => Filters::BranchDateFilter,
|
|
48
52
|
'issues' => Filters::IssueDateConditionsFilter,
|
|
49
|
-
'merge_requests' => Filters::MergeRequestDateConditionsFilter
|
|
53
|
+
'merge_requests' => Filters::MergeRequestDateConditionsFilter,
|
|
54
|
+
'work_items' => Filters::WorkItemDateConditionsFilter
|
|
50
55
|
},
|
|
51
56
|
protected: Filters::BranchProtectedFilter,
|
|
52
57
|
assignee_member: Filters::AssigneeMemberConditionsFilter,
|
|
@@ -63,7 +68,8 @@ module Gitlab
|
|
|
63
68
|
DEFAULT_GRAPHQL_ADAPTER = Gitlab::Triage::NetworkAdapters::GraphqlAdapter
|
|
64
69
|
ALLOWED_STATE_VALUES = {
|
|
65
70
|
issues: %w[opened closed],
|
|
66
|
-
merge_requests: %w[opened closed merged]
|
|
71
|
+
merge_requests: %w[opened closed merged],
|
|
72
|
+
work_items: %w[opened closed]
|
|
67
73
|
}.with_indifferent_access.freeze
|
|
68
74
|
MILESTONE_TIMEBOX_VALUES = %w[none any upcoming started].freeze
|
|
69
75
|
ITERATION_SELECTION_VALUES = %w[none any].freeze
|
|
@@ -89,6 +95,8 @@ module Gitlab
|
|
|
89
95
|
end
|
|
90
96
|
|
|
91
97
|
def perform
|
|
98
|
+
Validators::PolicyValidator.new(resource_rules).validate!
|
|
99
|
+
|
|
92
100
|
puts "Performing a dry run.\n\n" if options.dry_run
|
|
93
101
|
|
|
94
102
|
unless options.quiet
|
|
@@ -125,6 +133,11 @@ module Gitlab
|
|
|
125
133
|
@graphql_network ||= GraphqlNetwork.new(graphql_network_adapter)
|
|
126
134
|
end
|
|
127
135
|
|
|
136
|
+
def work_items_source
|
|
137
|
+
@work_items_source ||= Sources::WorkItemsRestSource.new(
|
|
138
|
+
network: network, options: options, per_page: per_page)
|
|
139
|
+
end
|
|
140
|
+
|
|
128
141
|
private
|
|
129
142
|
|
|
130
143
|
def assert_options!
|
|
@@ -192,7 +205,7 @@ module Gitlab
|
|
|
192
205
|
resource_reference = options.resource_reference
|
|
193
206
|
|
|
194
207
|
case resource_type
|
|
195
|
-
when 'issues'
|
|
208
|
+
when 'issues', 'work_items'
|
|
196
209
|
resource_reference.start_with?('#')
|
|
197
210
|
when 'merge_requests'
|
|
198
211
|
resource_reference.start_with?('!')
|
|
@@ -429,6 +442,8 @@ module Gitlab
|
|
|
429
442
|
end
|
|
430
443
|
|
|
431
444
|
def fetch_resources(resource_type, expanded_conditions, rule_definition)
|
|
445
|
+
return fetch_work_items(expanded_conditions) if resource_type == 'work_items'
|
|
446
|
+
|
|
432
447
|
resources = []
|
|
433
448
|
|
|
434
449
|
if rule_definition[:api] == 'graphql'
|
|
@@ -476,6 +491,21 @@ module Gitlab
|
|
|
476
491
|
resources
|
|
477
492
|
end
|
|
478
493
|
|
|
494
|
+
def fetch_work_items(expanded_conditions)
|
|
495
|
+
conditions = expanded_conditions.dup
|
|
496
|
+
conditions[:iids] = options.resource_reference[1..] if options.resource_reference
|
|
497
|
+
|
|
498
|
+
# The work_items path bypasses build_get_url, so validate `state` here
|
|
499
|
+
# the same way the URL builders do for the other resource types.
|
|
500
|
+
if conditions[:state]
|
|
501
|
+
ParamsValidator.new(
|
|
502
|
+
[{ name: 'state', type: String, values: ALLOWED_STATE_VALUES[:work_items] }],
|
|
503
|
+
{ 'state' => conditions[:state] }).validate!
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
work_items_source.list(conditions)
|
|
507
|
+
end
|
|
508
|
+
|
|
479
509
|
def attach_resource_type(resources, resource_type)
|
|
480
510
|
resources.each { |resource| resource[:type] = resource_type }
|
|
481
511
|
end
|
|
@@ -62,6 +62,30 @@ module Gitlab
|
|
|
62
62
|
}
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
def patch(token, url, body)
|
|
66
|
+
response = HTTParty.patch(
|
|
67
|
+
url,
|
|
68
|
+
body: body.to_json,
|
|
69
|
+
headers: {
|
|
70
|
+
'User-Agent' => USER_AGENT,
|
|
71
|
+
'Content-type' => 'application/json',
|
|
72
|
+
'PRIVATE-TOKEN' => token
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
raise_on_unauthorized_error!(response)
|
|
77
|
+
raise_on_internal_server_error!(response)
|
|
78
|
+
raise_on_too_many_requests!(response)
|
|
79
|
+
|
|
80
|
+
tracker&.record('PATCH', url)
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
results: response.parsed_response,
|
|
84
|
+
ratelimit_remaining: response.headers['ratelimit-remaining'].to_i,
|
|
85
|
+
ratelimit_reset_at: Time.at(response.headers['ratelimit-reset'].to_i)
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
65
89
|
def delete(token, url)
|
|
66
90
|
response = HTTParty.delete(
|
|
67
91
|
url,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/all'
|
|
4
|
+
|
|
5
|
+
module Gitlab
|
|
6
|
+
module Triage
|
|
7
|
+
module Normalizers
|
|
8
|
+
# Translates a raw Work Items REST API payload into the flat,
|
|
9
|
+
# issue-shaped resource hash the rest of the gem expects (integer id,
|
|
10
|
+
# iid, state as opened/closed, labels as title strings, assignees and
|
|
11
|
+
# author as hashes). This is the single place that knows the Work Items
|
|
12
|
+
# REST wire shape: the sparse `fields` payload and the nested `features`
|
|
13
|
+
# object with its labels/assignees/status widgets.
|
|
14
|
+
class WorkItemNormalizer
|
|
15
|
+
def call(raw)
|
|
16
|
+
resource = raw.deep_transform_keys(&:underscore).with_indifferent_access
|
|
17
|
+
features = resource[:features] || {}
|
|
18
|
+
|
|
19
|
+
namespace = resource[:namespace] || {}
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
id: resource[:id],
|
|
23
|
+
iid: resource[:iid],
|
|
24
|
+
web_url: resource[:web_url],
|
|
25
|
+
title: resource[:title],
|
|
26
|
+
state: resource[:state],
|
|
27
|
+
confidential: resource[:confidential],
|
|
28
|
+
created_at: resource[:created_at],
|
|
29
|
+
updated_at: resource[:updated_at],
|
|
30
|
+
author: resource[:author],
|
|
31
|
+
labels: label_titles(features),
|
|
32
|
+
assignees: assignees(features),
|
|
33
|
+
milestone: features[:milestone],
|
|
34
|
+
work_item_type: resource.dig(:work_item_type, :name),
|
|
35
|
+
work_item_status: status_name(features),
|
|
36
|
+
start_date: features.dig(:start_and_due_date, :start_date),
|
|
37
|
+
due_date: features.dig(:start_and_due_date, :due_date),
|
|
38
|
+
namespace: namespace.presence,
|
|
39
|
+
group_id: group_id_for(namespace),
|
|
40
|
+
type: 'work_items'
|
|
41
|
+
}.compact.with_indifferent_access
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# The list endpoint exposes the containing namespace, not a legacy
|
|
47
|
+
# project id. For a group namespace, `namespace.id` IS the legacy group
|
|
48
|
+
# id, so we can surface `group_id` directly - that lets the resource's
|
|
49
|
+
# group-scoped helpers build `/groups/:id/...` URLs unchanged.
|
|
50
|
+
#
|
|
51
|
+
# For a project namespace, `namespace.id` is the project-namespace id,
|
|
52
|
+
# which is NOT the legacy project id and is useless for `/projects/:id`
|
|
53
|
+
# endpoints. We deliberately do NOT map it to `project_id`; the source
|
|
54
|
+
# resolves the real project id from `namespace.full_path` instead (it
|
|
55
|
+
# needs the network, so it cannot happen here).
|
|
56
|
+
def group_id_for(namespace)
|
|
57
|
+
namespace[:id] if namespace[:kind] == 'group'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def label_titles(features)
|
|
61
|
+
[*features.dig(:labels, :labels)].pluck(:title)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def assignees(features)
|
|
65
|
+
[*features[:assignees]]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def status_name(features)
|
|
69
|
+
features.dig(:status, :status, :name)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'shared/issuable'
|
|
5
|
+
|
|
6
|
+
module Gitlab
|
|
7
|
+
module Triage
|
|
8
|
+
module Resource
|
|
9
|
+
class WorkItem < Base
|
|
10
|
+
include Shared::Issuable
|
|
11
|
+
|
|
12
|
+
DATE_FIELDS = %i[
|
|
13
|
+
start_date
|
|
14
|
+
due_date
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
DATE_FIELDS.each do |field|
|
|
18
|
+
define_field(field) do
|
|
19
|
+
value = resource[field]
|
|
20
|
+
|
|
21
|
+
Date.parse(value) if value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def work_item_type
|
|
26
|
+
resource[:work_item_type]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def work_item_status
|
|
30
|
+
resource[:work_item_status]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -90,6 +90,28 @@ module Gitlab
|
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
def patch_api(url, body)
|
|
94
|
+
response = execute_with_retry(
|
|
95
|
+
exception_types: [Net::ReadTimeout, Errors::Network::InternalServerError],
|
|
96
|
+
backoff_exceptions: Errors::Network::TooManyRequests, debug: options.debug) do
|
|
97
|
+
puts Gitlab::Triage::UI.debug "patch_api: #{url}" if options.debug
|
|
98
|
+
|
|
99
|
+
@adapter.patch(token, url, body)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
rate_limit_debug(response) if options.debug
|
|
103
|
+
rate_limit_wait(response)
|
|
104
|
+
|
|
105
|
+
results = response.delete(:results)
|
|
106
|
+
|
|
107
|
+
case results
|
|
108
|
+
when Hash
|
|
109
|
+
results.with_indifferent_access
|
|
110
|
+
else
|
|
111
|
+
raise_unexpected_response(results)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
93
115
|
def delete_api(url)
|
|
94
116
|
response = execute_with_retry(
|
|
95
117
|
exception_types: [Net::ReadTimeout, Errors::Network::InternalServerError],
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/all'
|
|
4
|
+
|
|
5
|
+
require_relative '../url_builders/url_builder'
|
|
6
|
+
require_relative '../normalizers/work_item_normalizer'
|
|
7
|
+
require_relative '../errors'
|
|
8
|
+
require_relative '../ui'
|
|
9
|
+
|
|
10
|
+
module Gitlab
|
|
11
|
+
module Triage
|
|
12
|
+
module Sources
|
|
13
|
+
# Fetches and updates work items through the GitLab Work Items REST API.
|
|
14
|
+
#
|
|
15
|
+
# Unlike the legacy issues REST API, the Work Items REST list endpoint
|
|
16
|
+
# applies no default base-type filter, so work items of any type -
|
|
17
|
+
# including custom, namespace-defined types - are returned and can be
|
|
18
|
+
# matched by name via the `work_item_type_names` filter.
|
|
19
|
+
#
|
|
20
|
+
# The source is a self-contained peer to the engine's inline issues /
|
|
21
|
+
# merge_requests / branches fetch: it owns list URL building, filter
|
|
22
|
+
# push-down, normalization, and the PATCH update path. Comment creation
|
|
23
|
+
# is not yet available on the Work Items REST API, so the `comment`
|
|
24
|
+
# action is rejected at load time by Validators::PolicyValidator.
|
|
25
|
+
class WorkItemsRestSource
|
|
26
|
+
UnavailableError = Class.new(StandardError)
|
|
27
|
+
ScopeError = Class.new(StandardError)
|
|
28
|
+
|
|
29
|
+
# Base fields opted into on the list endpoint. The endpoint is sparse
|
|
30
|
+
# by default (id, iid, global_id, title only); every field a filter or
|
|
31
|
+
# template reads must be requested here or it comes back nil.
|
|
32
|
+
LIST_FIELDS = %w[
|
|
33
|
+
state
|
|
34
|
+
confidential
|
|
35
|
+
created_at
|
|
36
|
+
updated_at
|
|
37
|
+
web_url
|
|
38
|
+
author
|
|
39
|
+
namespace
|
|
40
|
+
work_item_type
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
# Widget payloads opted into via `features`.
|
|
44
|
+
LIST_FEATURES = %w[
|
|
45
|
+
labels
|
|
46
|
+
assignees
|
|
47
|
+
milestone
|
|
48
|
+
status
|
|
49
|
+
start_and_due_date
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
52
|
+
def initialize(network:, options:, per_page: 100, normalizer: Normalizers::WorkItemNormalizer.new)
|
|
53
|
+
@network = network
|
|
54
|
+
@options = options
|
|
55
|
+
@per_page = per_page
|
|
56
|
+
@normalizer = normalizer
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def list(conditions)
|
|
60
|
+
raw = @network.query_api(build_list_url(conditions))
|
|
61
|
+
|
|
62
|
+
raise_if_unavailable!(raw)
|
|
63
|
+
|
|
64
|
+
raw.map { |item| resolve_project_id!(@normalizer.call(item)) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def update(resource, features)
|
|
68
|
+
@network.patch_api(work_item_url(resource), features: features)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns [source, source_id] for the namespace the resource lives in.
|
|
72
|
+
# A group run can return work items from descendant projects, and an
|
|
73
|
+
# iid is only unique within its own namespace, so writes and label
|
|
74
|
+
# lookups must target the resource's namespace, not the run-level
|
|
75
|
+
# source.
|
|
76
|
+
def scope_for(resource)
|
|
77
|
+
return ['projects', resource[:project_id]] if resource[:project_id]
|
|
78
|
+
return ['groups', resource[:group_id]] if resource[:group_id]
|
|
79
|
+
|
|
80
|
+
# A project item whose legacy project id could not be resolved must
|
|
81
|
+
# not fall back to the run-level scope: on a group run the same iid
|
|
82
|
+
# can name a different, visible work item there.
|
|
83
|
+
if resource.dig(:namespace, :kind) == 'project'
|
|
84
|
+
raise ScopeError,
|
|
85
|
+
"Cannot resolve the project for work item #{resource[:web_url] || resource[:iid]}; " \
|
|
86
|
+
'the token may lack access to its project.'
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
[@options.source, @options.source_id]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# The normalizer surfaces `group_id` directly for group-namespace items
|
|
95
|
+
# (namespace.id == legacy group id) but cannot supply a `project_id` for
|
|
96
|
+
# project-namespace items, because the list payload only carries the
|
|
97
|
+
# project-namespace id and full path, not the legacy project id that the
|
|
98
|
+
# gem's project-scoped resource helpers need. Resolve it here from the
|
|
99
|
+
# full path, cached per run, so that a group-spanning run makes at most
|
|
100
|
+
# one lookup per distinct project - and only for project items.
|
|
101
|
+
def resolve_project_id!(resource)
|
|
102
|
+
namespace = resource[:namespace]
|
|
103
|
+
return resource unless namespace && namespace[:kind] == 'project'
|
|
104
|
+
|
|
105
|
+
full_path = namespace[:full_path]
|
|
106
|
+
return resource if full_path.blank?
|
|
107
|
+
|
|
108
|
+
project_id = project_id_by_full_path(full_path)
|
|
109
|
+
resource[:project_id] = project_id if project_id
|
|
110
|
+
|
|
111
|
+
resource
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def project_id_by_full_path(full_path)
|
|
115
|
+
@project_ids_by_full_path ||= {}
|
|
116
|
+
return @project_ids_by_full_path[full_path] if @project_ids_by_full_path.key?(full_path)
|
|
117
|
+
|
|
118
|
+
project = @network.query_api_cached(project_url(full_path)).first
|
|
119
|
+
@project_ids_by_full_path[full_path] = project && project[:id]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def project_url(full_path)
|
|
123
|
+
UrlBuilders::UrlBuilder.new(
|
|
124
|
+
network_options: @options,
|
|
125
|
+
source: 'projects',
|
|
126
|
+
source_id: full_path
|
|
127
|
+
).build
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The Work Items REST API is behind the :work_item_rest_api feature
|
|
131
|
+
# flag. When it is off, or the token lacks access, or the endpoint is
|
|
132
|
+
# absent, the response is an error hash (e.g. a 403 "feature flag is
|
|
133
|
+
# disabled" or a 404). The adapter does not raise on those, so without
|
|
134
|
+
# this guard the error hash would be normalized into a bogus resource
|
|
135
|
+
# and the run would silently match nothing - which in an automation
|
|
136
|
+
# tool looks like "all clear". Raise a clear, actionable error instead.
|
|
137
|
+
def raise_if_unavailable!(raw)
|
|
138
|
+
error = raw.find { |item| item.is_a?(Hash) && item[:iid].blank? && (item[:message] || item[:error]) }
|
|
139
|
+
return unless error
|
|
140
|
+
|
|
141
|
+
message = error[:message] || error[:error]
|
|
142
|
+
|
|
143
|
+
raise UnavailableError,
|
|
144
|
+
"The Work Items REST API is not available (#{message}). " \
|
|
145
|
+
'Ensure the :work_item_rest_api feature flag is enabled and the ' \
|
|
146
|
+
'token has sufficient access.'
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build_list_url(conditions)
|
|
150
|
+
UrlBuilders::UrlBuilder.new(
|
|
151
|
+
network_options: @options,
|
|
152
|
+
source: @options.source,
|
|
153
|
+
source_id: @options.source_id,
|
|
154
|
+
resource_type: '-/work_items',
|
|
155
|
+
params: list_params(conditions)
|
|
156
|
+
).build
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def work_item_url(resource)
|
|
160
|
+
scope, scope_id = scope_for(resource)
|
|
161
|
+
|
|
162
|
+
UrlBuilders::UrlBuilder.new(
|
|
163
|
+
network_options: @options,
|
|
164
|
+
source: scope,
|
|
165
|
+
source_id: scope_id,
|
|
166
|
+
resource_type: '-/work_items',
|
|
167
|
+
resource_id: resource[:iid]
|
|
168
|
+
).build
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def list_params(conditions)
|
|
172
|
+
params = {
|
|
173
|
+
per_page: @per_page,
|
|
174
|
+
fields: LIST_FIELDS.join(','),
|
|
175
|
+
features: LIST_FEATURES.join(',')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
apply_filters(params, conditions)
|
|
179
|
+
|
|
180
|
+
params
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Push-down: map work_items conditions to Work Items REST query params.
|
|
184
|
+
# `type` uses `work_item_type_names`, which resolves names (including
|
|
185
|
+
# custom types) case-insensitively server-side - no id resolution
|
|
186
|
+
# needed.
|
|
187
|
+
def apply_filters(params, conditions)
|
|
188
|
+
params[:iids] = Array(conditions[:iids]).join(',') if conditions[:iids].present?
|
|
189
|
+
params[:work_item_type_names] = Array(conditions[:type]).join(',') if conditions[:type]
|
|
190
|
+
params[:state] = conditions[:state] if conditions[:state]
|
|
191
|
+
params[:label_name] = Array(conditions[:labels]).join(',') if conditions[:labels]
|
|
192
|
+
params['not[label_name]'] = Array(conditions[:forbidden_labels]).join(',') if conditions[:forbidden_labels]
|
|
193
|
+
params[:assignee_usernames] = Array(conditions[:assignees]).join(',') if conditions[:assignees]
|
|
194
|
+
params['status[name]'] = conditions[:status] if conditions[:status]
|
|
195
|
+
|
|
196
|
+
apply_date_filter(params, conditions[:date]) if conditions[:date]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def apply_date_filter(params, date_condition)
|
|
200
|
+
attribute = date_condition[:attribute]
|
|
201
|
+
condition = date_condition[:condition]
|
|
202
|
+
return unless %w[created_at updated_at].include?(attribute)
|
|
203
|
+
|
|
204
|
+
boundary = date_boundary(date_condition)
|
|
205
|
+
return unless boundary
|
|
206
|
+
|
|
207
|
+
direction = condition == 'older_than' ? 'before' : 'after'
|
|
208
|
+
params["#{attribute.delete_suffix('_at')}_#{direction}"] = boundary.iso8601
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def date_boundary(date_condition)
|
|
212
|
+
interval = date_condition[:interval]
|
|
213
|
+
interval_type = date_condition[:interval_type]
|
|
214
|
+
return unless interval && interval_type
|
|
215
|
+
|
|
216
|
+
# A malformed interval_type raises here rather than silently dropping
|
|
217
|
+
# the date bound, which would widen the matched set - dangerous in an
|
|
218
|
+
# automation tool. interval_type is also validated by the date filter.
|
|
219
|
+
interval.public_send(interval_type).ago # rubocop:disable GitlabSecurity/PublicSend -- interval_type constrained to a known set
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitlab
|
|
4
|
+
module Triage
|
|
5
|
+
module Validators
|
|
6
|
+
# Validates policy definitions at load time, before any resource is
|
|
7
|
+
# fetched or acted on. Triage typically runs unattended and scheduled,
|
|
8
|
+
# so an unsupported policy must be rejected up front rather than raising
|
|
9
|
+
# partway through a run (which could leave a rule half-applied and fail
|
|
10
|
+
# where nobody sees it).
|
|
11
|
+
class PolicyValidator
|
|
12
|
+
UnsupportedActionError = Class.new(StandardError)
|
|
13
|
+
|
|
14
|
+
COMMENT_NOT_SUPPORTED_MESSAGE =
|
|
15
|
+
'Commenting on work_items is not yet supported by the Work Items REST API ' \
|
|
16
|
+
'(blocked on https://gitlab.com/gitlab-org/gitlab/-/work_items/604028). ' \
|
|
17
|
+
'Use the issues policy type for comment actions.'
|
|
18
|
+
|
|
19
|
+
# Action keys Action.actions_for dispatches for work_items policies.
|
|
20
|
+
# Unlike the legacy resource types, work_items have no quick-actions
|
|
21
|
+
# comment fallback for unrecognized keys, so anything outside this
|
|
22
|
+
# list would silently do nothing - reject it up front instead.
|
|
23
|
+
SUPPORTED_WORK_ITEM_ACTIONS = %i[labels assignees summarize comment_on_summary].freeze
|
|
24
|
+
|
|
25
|
+
def initialize(resource_rules)
|
|
26
|
+
@resource_rules = resource_rules || {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate!
|
|
30
|
+
@resource_rules.each do |resource_type, policy_definition|
|
|
31
|
+
next unless resource_type.to_s == 'work_items'
|
|
32
|
+
|
|
33
|
+
validate_work_items_actions!(policy_definition)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def validate_work_items_actions!(policy_definition)
|
|
40
|
+
action_sets_from(policy_definition).each do |actions|
|
|
41
|
+
raise UnsupportedActionError, COMMENT_NOT_SUPPORTED_MESSAGE if actions.key?(:comment)
|
|
42
|
+
|
|
43
|
+
unsupported = actions.keys.map(&:to_sym) - SUPPORTED_WORK_ITEM_ACTIONS
|
|
44
|
+
next if unsupported.none?
|
|
45
|
+
|
|
46
|
+
raise UnsupportedActionError,
|
|
47
|
+
"Unsupported action(s) for work_items policies: #{unsupported.join(', ')}. " \
|
|
48
|
+
"Supported actions: #{SUPPORTED_WORK_ITEM_ACTIONS.join(', ')}."
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Every place a policy definition can carry actions: top-level rules,
|
|
53
|
+
# rules nested in summaries, and the summaries' own actions.
|
|
54
|
+
def action_sets_from(policy_definition)
|
|
55
|
+
policy_definition ||= {}
|
|
56
|
+
|
|
57
|
+
summaries = Array(policy_definition[:summaries])
|
|
58
|
+
summary_rules = summaries.flat_map { |summary| Array(summary[:rules]) }
|
|
59
|
+
|
|
60
|
+
(Array(policy_definition[:rules]) + summary_rules + summaries)
|
|
61
|
+
.map { |definition| definition[:actions] || {} }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gitlab-triage
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.53.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- GitLab
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -239,6 +239,7 @@ files:
|
|
|
239
239
|
- lib/gitlab/triage/action/delete.rb
|
|
240
240
|
- lib/gitlab/triage/action/issue.rb
|
|
241
241
|
- lib/gitlab/triage/action/summarize.rb
|
|
242
|
+
- lib/gitlab/triage/action/work_item.rb
|
|
242
243
|
- lib/gitlab/triage/action/work_item_status.rb
|
|
243
244
|
- lib/gitlab/triage/api_observability.rb
|
|
244
245
|
- lib/gitlab/triage/api_query_builders/base_query_param_builder.rb
|
|
@@ -275,6 +276,7 @@ files:
|
|
|
275
276
|
- lib/gitlab/triage/filters/no_additional_labels_conditions_filter.rb
|
|
276
277
|
- lib/gitlab/triage/filters/ruby_conditions_filter.rb
|
|
277
278
|
- lib/gitlab/triage/filters/votes_conditions_filter.rb
|
|
279
|
+
- lib/gitlab/triage/filters/work_item_date_conditions_filter.rb
|
|
278
280
|
- lib/gitlab/triage/filters/work_item_status_conditions_filter.rb
|
|
279
281
|
- lib/gitlab/triage/graphql_network.rb
|
|
280
282
|
- lib/gitlab/triage/graphql_queries/query_builder.rb
|
|
@@ -288,6 +290,7 @@ files:
|
|
|
288
290
|
- lib/gitlab/triage/network_adapters/graphql_adapter.rb
|
|
289
291
|
- lib/gitlab/triage/network_adapters/httparty_adapter.rb
|
|
290
292
|
- lib/gitlab/triage/network_adapters/test_adapter.rb
|
|
293
|
+
- lib/gitlab/triage/normalizers/work_item_normalizer.rb
|
|
291
294
|
- lib/gitlab/triage/option_parser.rb
|
|
292
295
|
- lib/gitlab/triage/options.rb
|
|
293
296
|
- lib/gitlab/triage/param_builders/date_param_builder.rb
|
|
@@ -308,13 +311,16 @@ files:
|
|
|
308
311
|
- lib/gitlab/triage/resource/merge_request.rb
|
|
309
312
|
- lib/gitlab/triage/resource/milestone.rb
|
|
310
313
|
- lib/gitlab/triage/resource/shared/issuable.rb
|
|
314
|
+
- lib/gitlab/triage/resource/work_item.rb
|
|
311
315
|
- lib/gitlab/triage/rest_api_network.rb
|
|
312
316
|
- lib/gitlab/triage/retryable.rb
|
|
317
|
+
- lib/gitlab/triage/sources/work_items_rest_source.rb
|
|
313
318
|
- lib/gitlab/triage/ui.rb
|
|
314
319
|
- lib/gitlab/triage/url_builders/url_builder.rb
|
|
315
320
|
- lib/gitlab/triage/utils.rb
|
|
316
321
|
- lib/gitlab/triage/validators/limiter_validator.rb
|
|
317
322
|
- lib/gitlab/triage/validators/params_validator.rb
|
|
323
|
+
- lib/gitlab/triage/validators/policy_validator.rb
|
|
318
324
|
- lib/gitlab/triage/version.rb
|
|
319
325
|
- support/.gitlab-ci.example.yml
|
|
320
326
|
- support/.triage-policies.example.yml
|