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
|
@@ -29,7 +29,7 @@ module Gitlab
|
|
|
29
29
|
parsed_graphql_query = adapter.parse(graphql_query.query)
|
|
30
30
|
|
|
31
31
|
begin
|
|
32
|
-
print '.'
|
|
32
|
+
print '.' unless options.quiet
|
|
33
33
|
|
|
34
34
|
response = adapter.query(
|
|
35
35
|
parsed_graphql_query,
|
|
@@ -52,7 +52,7 @@ module Gitlab
|
|
|
52
52
|
def mutate(graphql_mutation, variables = {})
|
|
53
53
|
return if graphql_mutation.blank?
|
|
54
54
|
|
|
55
|
-
print '.'
|
|
55
|
+
print '.' unless options.quiet
|
|
56
56
|
|
|
57
57
|
parsed_mutation = adapter.parse(graphql_mutation)
|
|
58
58
|
|
|
@@ -99,6 +99,7 @@ module Gitlab
|
|
|
99
99
|
return unless reset_at
|
|
100
100
|
|
|
101
101
|
puts Gitlab::Triage::UI.debug "Rate limit almost exceeded, sleeping for #{reset_at - Time.now} seconds" if options.debug
|
|
102
|
+
adapter.tracker&.record_rate_limit_wait
|
|
102
103
|
sleep(1) until Time.now >= reset_at
|
|
103
104
|
end
|
|
104
105
|
end
|
|
@@ -8,10 +8,11 @@ module Gitlab
|
|
|
8
8
|
class BaseAdapter
|
|
9
9
|
USER_AGENT = "GitLab Triage #{Gitlab::Triage::VERSION}".freeze
|
|
10
10
|
|
|
11
|
-
attr_reader :options
|
|
11
|
+
attr_reader :options, :tracker
|
|
12
12
|
|
|
13
|
-
def initialize(options)
|
|
13
|
+
def initialize(options, tracker: nil)
|
|
14
14
|
@options = options
|
|
15
|
+
@tracker = tracker
|
|
15
16
|
end
|
|
16
17
|
end
|
|
17
18
|
end
|
|
@@ -138,8 +138,11 @@ module Gitlab
|
|
|
138
138
|
end
|
|
139
139
|
|
|
140
140
|
def http_client
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
graphql_tracker = tracker
|
|
142
|
+
graphql_url = "#{options.host_url}/api/graphql"
|
|
143
|
+
|
|
144
|
+
Client::HTTP.new(graphql_url) do
|
|
145
|
+
define_method(:execute) do |document:, operation_name: nil, variables: {}, context: {}|
|
|
143
146
|
body = {}
|
|
144
147
|
body['query'] = document.to_query_string
|
|
145
148
|
body['variables'] = variables if variables.any?
|
|
@@ -157,6 +160,7 @@ module Gitlab
|
|
|
157
160
|
|
|
158
161
|
case response.code
|
|
159
162
|
when 200, 400
|
|
163
|
+
graphql_tracker&.record('POST', graphql_url)
|
|
160
164
|
JSON.parse(response.body).merge('extensions' => { 'headers' => response.headers })
|
|
161
165
|
else
|
|
162
166
|
{ 'errors' => [{ 'message' => "#{response.code} #{response.message}" }] }
|
|
@@ -24,13 +24,18 @@ module Gitlab
|
|
|
24
24
|
raise_on_internal_server_error!(response)
|
|
25
25
|
raise_on_too_many_requests!(response)
|
|
26
26
|
|
|
27
|
-
{
|
|
27
|
+
result = {
|
|
28
28
|
more_pages: (response.headers["x-next-page"].to_s != ""),
|
|
29
29
|
next_page_url: next_page_url(url, response),
|
|
30
30
|
results: response.parsed_response,
|
|
31
31
|
ratelimit_remaining: response.headers["ratelimit-remaining"].to_i,
|
|
32
32
|
ratelimit_reset_at: Time.at(response.headers["ratelimit-reset"].to_i)
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
resource_count = result[:results].is_a?(Array) ? result[:results].size : 0
|
|
36
|
+
tracker&.record('GET', url, resource_count: resource_count)
|
|
37
|
+
|
|
38
|
+
result
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
def post(token, url, body)
|
|
@@ -48,6 +53,8 @@ module Gitlab
|
|
|
48
53
|
raise_on_internal_server_error!(response)
|
|
49
54
|
raise_on_too_many_requests!(response)
|
|
50
55
|
|
|
56
|
+
tracker&.record('POST', url)
|
|
57
|
+
|
|
51
58
|
{
|
|
52
59
|
results: response.parsed_response,
|
|
53
60
|
ratelimit_remaining: response.headers["ratelimit-remaining"].to_i,
|
|
@@ -55,6 +62,30 @@ module Gitlab
|
|
|
55
62
|
}
|
|
56
63
|
end
|
|
57
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
|
+
|
|
58
89
|
def delete(token, url)
|
|
59
90
|
response = HTTParty.delete(
|
|
60
91
|
url,
|
|
@@ -68,6 +99,8 @@ module Gitlab
|
|
|
68
99
|
raise_on_internal_server_error!(response)
|
|
69
100
|
raise_on_too_many_requests!(response)
|
|
70
101
|
|
|
102
|
+
tracker&.record('DELETE', url)
|
|
103
|
+
|
|
71
104
|
{
|
|
72
105
|
results: response.parsed_response,
|
|
73
106
|
ratelimit_remaining: response.headers["ratelimit-remaining"].to_i,
|
|
@@ -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
|
|
@@ -65,6 +65,14 @@ module Gitlab
|
|
|
65
65
|
options.debug = value
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
opts.on('--summary', 'Print API call summary at end of execution') do |value|
|
|
69
|
+
options.summary = value
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
opts.on('--quiet', 'Suppress pagination dots and section headers') do |value|
|
|
73
|
+
options.quiet = value
|
|
74
|
+
end
|
|
75
|
+
|
|
68
76
|
opts.on('--cache-dir [string]', String,
|
|
69
77
|
'Directory containing pre-fetched API responses. ' \
|
|
70
78
|
'Files must follow the naming convention: <source>_<id>_<resource>.json ' \
|
|
@@ -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
|
|
@@ -35,7 +35,7 @@ module Gitlab
|
|
|
35
35
|
resources = []
|
|
36
36
|
|
|
37
37
|
begin
|
|
38
|
-
print '.'
|
|
38
|
+
print '.' unless options.quiet
|
|
39
39
|
url = response.fetch(:next_page_url) { url }
|
|
40
40
|
|
|
41
41
|
response = execute_with_retry(
|
|
@@ -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],
|
|
@@ -165,6 +187,7 @@ module Gitlab
|
|
|
165
187
|
return unless response.delete(:ratelimit_remaining) < MINIMUM_RATE_LIMIT
|
|
166
188
|
|
|
167
189
|
puts Gitlab::Triage::UI.debug "Rate limit almost exceeded, sleeping for #{response[:ratelimit_reset_at] - Time.now} seconds" if options.debug
|
|
190
|
+
@adapter.tracker&.record_rate_limit_wait
|
|
168
191
|
sleep(1) until Time.now >= response[:ratelimit_reset_at]
|
|
169
192
|
end
|
|
170
193
|
|
|
@@ -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
|