gitlab-triage 1.51.1 → 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/api_observability.rb +73 -0
- data/lib/gitlab/triage/engine.rb +72 -20
- data/lib/gitlab/triage/filters/work_item_date_conditions_filter.rb +12 -0
- data/lib/gitlab/triage/graphql_network.rb +3 -2
- data/lib/gitlab/triage/network.rb +4 -0
- data/lib/gitlab/triage/network_adapters/base_adapter.rb +3 -2
- data/lib/gitlab/triage/network_adapters/graphql_adapter.rb +6 -2
- data/lib/gitlab/triage/network_adapters/httparty_adapter.rb +34 -1
- data/lib/gitlab/triage/normalizers/work_item_normalizer.rb +74 -0
- data/lib/gitlab/triage/option_parser.rb +8 -0
- data/lib/gitlab/triage/options.rb +3 -1
- 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 +24 -1
- 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 +9 -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
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Gitlab
|
|
6
|
+
module Triage
|
|
7
|
+
module APIObservability
|
|
8
|
+
class Tracker
|
|
9
|
+
PAGINATION_PARAMS = %w[page per_page].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :calls, :resources_fetched
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@calls = Hash.new(0)
|
|
15
|
+
@resources_fetched = 0
|
|
16
|
+
@rate_limit_waits = 0
|
|
17
|
+
@start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record(method, url, resource_count: 0)
|
|
21
|
+
@calls["#{method} #{normalize_endpoint(url)}"] += 1
|
|
22
|
+
@resources_fetched += resource_count
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record_rate_limit_wait
|
|
26
|
+
@rate_limit_waits += 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def total
|
|
30
|
+
@calls.values.sum
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def print_summary
|
|
34
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
|
|
35
|
+
|
|
36
|
+
puts "\n"
|
|
37
|
+
puts '=' * 70
|
|
38
|
+
puts ' API Call Summary'
|
|
39
|
+
puts '=' * 70
|
|
40
|
+
puts " Total HTTP requests: #{total}"
|
|
41
|
+
puts " Resources fetched: #{@resources_fetched}"
|
|
42
|
+
puts " Rate limit waits: #{@rate_limit_waits}"
|
|
43
|
+
puts " Elapsed time: #{elapsed.round(1)}s"
|
|
44
|
+
puts '-' * 70
|
|
45
|
+
|
|
46
|
+
@calls.sort_by { |_, count| -count }.each do |endpoint, count|
|
|
47
|
+
puts " #{count.to_s.rjust(5)}x #{endpoint}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
puts '=' * 70
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def normalize_endpoint(url)
|
|
56
|
+
uri = URI.parse(url)
|
|
57
|
+
path = uri.path
|
|
58
|
+
.sub(%r{/api/v\d+}, '')
|
|
59
|
+
.sub(%r{/projects/[^/]+}, '/projects/:id')
|
|
60
|
+
.sub(%r{/groups/[^/]+}, '/groups/:id')
|
|
61
|
+
.gsub(%r{/\d+(/|\z)}, '/:id\1')
|
|
62
|
+
|
|
63
|
+
query_keys = (URI.decode_www_form(uri.query || '').map(&:first) - PAGINATION_PARAMS).sort
|
|
64
|
+
|
|
65
|
+
path += "?#{query_keys.join('&')}" if query_keys.any?
|
|
66
|
+
path
|
|
67
|
+
rescue URI::InvalidURIError
|
|
68
|
+
url
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
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,7 +32,11 @@ 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'
|
|
39
|
+
require_relative 'api_observability'
|
|
35
40
|
|
|
36
41
|
module Gitlab
|
|
37
42
|
module Triage
|
|
@@ -45,7 +50,8 @@ module Gitlab
|
|
|
45
50
|
date: {
|
|
46
51
|
'branches' => Filters::BranchDateFilter,
|
|
47
52
|
'issues' => Filters::IssueDateConditionsFilter,
|
|
48
|
-
'merge_requests' => Filters::MergeRequestDateConditionsFilter
|
|
53
|
+
'merge_requests' => Filters::MergeRequestDateConditionsFilter,
|
|
54
|
+
'work_items' => Filters::WorkItemDateConditionsFilter
|
|
49
55
|
},
|
|
50
56
|
protected: Filters::BranchProtectedFilter,
|
|
51
57
|
assignee_member: Filters::AssigneeMemberConditionsFilter,
|
|
@@ -62,7 +68,8 @@ module Gitlab
|
|
|
62
68
|
DEFAULT_GRAPHQL_ADAPTER = Gitlab::Triage::NetworkAdapters::GraphqlAdapter
|
|
63
69
|
ALLOWED_STATE_VALUES = {
|
|
64
70
|
issues: %w[opened closed],
|
|
65
|
-
merge_requests: %w[opened closed merged]
|
|
71
|
+
merge_requests: %w[opened closed merged],
|
|
72
|
+
work_items: %w[opened closed]
|
|
66
73
|
}.with_indifferent_access.freeze
|
|
67
74
|
MILESTONE_TIMEBOX_VALUES = %w[none any upcoming started].freeze
|
|
68
75
|
ITERATION_SELECTION_VALUES = %w[none any].freeze
|
|
@@ -88,22 +95,30 @@ module Gitlab
|
|
|
88
95
|
end
|
|
89
96
|
|
|
90
97
|
def perform
|
|
98
|
+
Validators::PolicyValidator.new(resource_rules).validate!
|
|
99
|
+
|
|
91
100
|
puts "Performing a dry run.\n\n" if options.dry_run
|
|
92
101
|
|
|
93
|
-
|
|
94
|
-
|
|
102
|
+
unless options.quiet
|
|
103
|
+
puts Gitlab::Triage::UI.header("Triaging the `#{options.source_id}` #{options.source.singularize}", char: '=')
|
|
104
|
+
puts
|
|
105
|
+
end
|
|
95
106
|
|
|
96
107
|
resource_rules.each do |resource_type, policy_definition|
|
|
97
108
|
next unless right_resource_type_for_resource_option?(resource_type)
|
|
98
109
|
|
|
99
110
|
assert_epic_rule!(resource_type)
|
|
100
111
|
|
|
101
|
-
|
|
102
|
-
|
|
112
|
+
unless options.quiet
|
|
113
|
+
puts Gitlab::Triage::UI.header("Processing summaries & rules for #{resource_type}", char: '-')
|
|
114
|
+
puts
|
|
115
|
+
end
|
|
103
116
|
|
|
104
117
|
process_summaries(resource_type, policy_definition[:summaries])
|
|
105
118
|
process_rules(resource_type, policy_definition[:rules])
|
|
106
119
|
end
|
|
120
|
+
|
|
121
|
+
tracker.print_summary if options.summary
|
|
107
122
|
end
|
|
108
123
|
|
|
109
124
|
def network
|
|
@@ -118,6 +133,11 @@ module Gitlab
|
|
|
118
133
|
@graphql_network ||= GraphqlNetwork.new(graphql_network_adapter)
|
|
119
134
|
end
|
|
120
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
|
+
|
|
121
141
|
private
|
|
122
142
|
|
|
123
143
|
def assert_options!
|
|
@@ -185,7 +205,7 @@ module Gitlab
|
|
|
185
205
|
resource_reference = options.resource_reference
|
|
186
206
|
|
|
187
207
|
case resource_type
|
|
188
|
-
when 'issues'
|
|
208
|
+
when 'issues', 'work_items'
|
|
189
209
|
resource_reference.start_with?('#')
|
|
190
210
|
when 'merge_requests'
|
|
191
211
|
resource_reference.start_with?('!')
|
|
@@ -204,12 +224,16 @@ module Gitlab
|
|
|
204
224
|
@resource_rules ||= policies.delete(:resource_rules) { {} }
|
|
205
225
|
end
|
|
206
226
|
|
|
227
|
+
def tracker
|
|
228
|
+
@tracker ||= options.summary ? APIObservability::Tracker.new : nil
|
|
229
|
+
end
|
|
230
|
+
|
|
207
231
|
def network_adapter
|
|
208
|
-
@network_adapter ||= @network_adapter_class.new(options)
|
|
232
|
+
@network_adapter ||= @network_adapter_class.new(options, tracker: tracker)
|
|
209
233
|
end
|
|
210
234
|
|
|
211
235
|
def graphql_network_adapter
|
|
212
|
-
@graphql_network_adapter ||= @graphql_network_adapter_class.new(options)
|
|
236
|
+
@graphql_network_adapter ||= @graphql_network_adapter_class.new(options, tracker: tracker)
|
|
213
237
|
end
|
|
214
238
|
|
|
215
239
|
def rule_conditions(rule)
|
|
@@ -310,8 +334,10 @@ module Gitlab
|
|
|
310
334
|
#
|
|
311
335
|
# @return [nil]
|
|
312
336
|
def process_summary(resource_type, summary_definition)
|
|
313
|
-
|
|
314
|
-
|
|
337
|
+
unless options.quiet
|
|
338
|
+
puts Gitlab::Triage::UI.header("Processing summary: **#{summary_definition[:name]}**", char: '~')
|
|
339
|
+
puts
|
|
340
|
+
end
|
|
315
341
|
|
|
316
342
|
summary_parts_for_rules(resource_type, summary_definition[:rules]) do |summary_resources|
|
|
317
343
|
policy = Policies::SummaryPolicy.new(
|
|
@@ -365,7 +391,7 @@ module Gitlab
|
|
|
365
391
|
#
|
|
366
392
|
# @return [nil]
|
|
367
393
|
def resources_for_rule(resource_type, rule_definition, in_summary: false)
|
|
368
|
-
puts Gitlab::Triage::UI.header("Gathering resources for rule: **#{rule_definition[:name]}**", char: '-')
|
|
394
|
+
puts Gitlab::Triage::UI.header("Gathering resources for rule: **#{rule_definition[:name]}**", char: '-') unless options.quiet
|
|
369
395
|
|
|
370
396
|
# Skip resource fetching for custom type rules
|
|
371
397
|
if rule_definition[:type] == 'custom'
|
|
@@ -373,7 +399,7 @@ module Gitlab
|
|
|
373
399
|
raise ArgumentError, "type: custom can only be used in summary rules, not in top-level rules (rule: #{rule_definition[:name]})"
|
|
374
400
|
end
|
|
375
401
|
|
|
376
|
-
puts "\n* Skipping resource fetch for custom type rule"
|
|
402
|
+
puts "\n* Skipping resource fetch for custom type rule" unless options.quiet
|
|
377
403
|
yield(PoliciesResources::RuleResources.new([]), {})
|
|
378
404
|
return
|
|
379
405
|
end
|
|
@@ -391,14 +417,23 @@ module Gitlab
|
|
|
391
417
|
# In some filters/actions we want to know which resource type it is
|
|
392
418
|
attach_resource_type(resources, resource_type)
|
|
393
419
|
|
|
394
|
-
|
|
395
|
-
|
|
420
|
+
unless options.quiet
|
|
421
|
+
puts "\n\n* Found #{resources.count} resources..."
|
|
422
|
+
print "* Filtering resources..."
|
|
423
|
+
end
|
|
424
|
+
|
|
396
425
|
resources = filter_resources(resources, expanded_conditions)
|
|
397
|
-
|
|
398
|
-
|
|
426
|
+
|
|
427
|
+
unless options.quiet
|
|
428
|
+
puts "\n* Total after filtering: #{resources.count} resources"
|
|
429
|
+
print "* Limiting resources..."
|
|
430
|
+
end
|
|
431
|
+
|
|
399
432
|
resources = limit_resources(resources, rule_limits(rule_definition))
|
|
400
|
-
|
|
401
|
-
|
|
433
|
+
unless options.quiet
|
|
434
|
+
puts "\n* Total after limiting: #{resources.count} resources"
|
|
435
|
+
puts
|
|
436
|
+
end
|
|
402
437
|
|
|
403
438
|
resources = sanitize_resources(resources)
|
|
404
439
|
|
|
@@ -407,6 +442,8 @@ module Gitlab
|
|
|
407
442
|
end
|
|
408
443
|
|
|
409
444
|
def fetch_resources(resource_type, expanded_conditions, rule_definition)
|
|
445
|
+
return fetch_work_items(expanded_conditions) if resource_type == 'work_items'
|
|
446
|
+
|
|
410
447
|
resources = []
|
|
411
448
|
|
|
412
449
|
if rule_definition[:api] == 'graphql'
|
|
@@ -454,6 +491,21 @@ module Gitlab
|
|
|
454
491
|
resources
|
|
455
492
|
end
|
|
456
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
|
+
|
|
457
509
|
def attach_resource_type(resources, resource_type)
|
|
458
510
|
resources.each { |resource| resource[:type] = resource_type }
|
|
459
511
|
end
|
|
@@ -470,7 +522,7 @@ module Gitlab
|
|
|
470
522
|
policy: policy,
|
|
471
523
|
network: network,
|
|
472
524
|
dry: options.dry_run)
|
|
473
|
-
puts
|
|
525
|
+
puts unless options.quiet
|
|
474
526
|
end
|
|
475
527
|
|
|
476
528
|
def filter_resources(resources, conditions)
|