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.
@@ -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
@@ -37,6 +37,10 @@ module Gitlab
37
37
  restapi.post_api(...)
38
38
  end
39
39
 
40
+ def patch_api(...)
41
+ restapi.patch_api(...)
42
+ end
43
+
40
44
  def delete_api(...)
41
45
  restapi.delete_api(...)
42
46
  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
- Client::HTTP.new("#{options.host_url}/api/graphql") do
142
- def execute(document:, operation_name: nil, variables: {}, context: {}) # rubocop:disable Lint/NestedMethodDefinition
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 ' \
@@ -15,7 +15,9 @@ module Gitlab
15
15
  :host_url,
16
16
  :require_files,
17
17
  :api_version,
18
- :cache_dir
18
+ :cache_dir,
19
+ :summary,
20
+ :quiet
19
21
  ) do
20
22
  def initialize(*args)
21
23
  super
@@ -71,6 +71,10 @@ module Gitlab
71
71
  actions.key?(:work_item_status)
72
72
  end
73
73
 
74
+ def work_item?
75
+ (actions.keys.map(&:to_sym) & %i[labels assignees]).any?
76
+ end
77
+
74
78
  def build_issue
75
79
  raise NotImplementedError
76
80
  end
@@ -7,6 +7,7 @@ require_relative 'linked_issue'
7
7
  require_relative 'merge_request'
8
8
  require_relative 'instance_version'
9
9
  require_relative 'branch'
10
+ require_relative 'work_item'
10
11
 
11
12
  module Gitlab
12
13
  module Triage
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module Triage
5
- VERSION = '1.51.1'
5
+ VERSION = '1.53.0'
6
6
  end
7
7
  end