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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c0188aec696ec4f1821f6e9d4244a2323527d464e9c51da0b7e2b54053003a2c
4
- data.tar.gz: d432a7b181c290420afdd883c01a55f686a1e30d1797f736fbbf3171459f36ee
3
+ metadata.gz: e4b12c328cc4c5a865ceca6ccadd49a8dedc1066564fa62a28718c3b164f3a15
4
+ data.tar.gz: 1298d537c79ef8d74f3816e18d482a8bbee02fde1b3a1717869709795f40f547
5
5
  SHA512:
6
- metadata.gz: 797365b9ad72539cf3027e7e00130cbf88172e11ec46162c7c38e25d7eebbaca39cb6d5518a033ac7b820e127c15b0ef8a3d64c8011a0dcd45f4d9ef985a13c6
7
- data.tar.gz: 46c14ed3a8916402aefe35aaa6b5f80c8b6b647ee4f6e1ab7bbf138ac4c0c9fd0326a672be08abc1043bfdcc5280d74456554a71f2f390439f1b9858828401f7
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: 78
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab-triage (1.52.0)
4
+ gitlab-triage (1.53.0)
5
5
  activesupport (>= 5.1)
6
6
  globalid (~> 1.0, >= 1.0.1)
7
7
  graphql (< 2.1.0)
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
@@ -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
@@ -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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'issue_date_conditions_filter'
4
+
5
+ module Gitlab
6
+ module Triage
7
+ module Filters
8
+ class WorkItemDateConditionsFilter < IssueDateConditionsFilter
9
+ end
10
+ end
11
+ end
12
+ 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
@@ -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
@@ -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
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module Triage
5
- VERSION = '1.52.0'
5
+ VERSION = '1.53.0'
6
6
  end
7
7
  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.52.0
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-07-14 00:00:00.000000000 Z
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