gitlab-triage 1.57.0 → 1.58.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: 624c4d1977031933b3f310f7ae5c82778ac62bcda0f86a0c2c4a375504b6e715
4
- data.tar.gz: 9545979a57eea0ff733995ac553271ad310c2c2710ac4fe78645daff962449b5
3
+ metadata.gz: 8a6d5407a6cccbf8711ab5d9e6cfd060165831e32880681257a6d12ed2290df3
4
+ data.tar.gz: 30aca49b70d3c65f56fcb5cd34275d6aad83aea62f54b683d1196af54c8c312a
5
5
  SHA512:
6
- metadata.gz: 1721670e0285a0d0fffd8cb076becb239e859267d1da55f4819526f39128dec62e884251f6ae28e9d1550eeca2eb29f8c5b08c1c8d6e16b480f22254bf20016c
7
- data.tar.gz: 7b233404c63d41de48dfadf6e7fdacadb6412c47fe00562d417626534ef21b5dd8c4c14340b1d46f27bad1c5c2e58378d55e80b6aac5c8e0f25f12d2075c3515
6
+ metadata.gz: ba6a0e711a7c06f6c99f1af0471d68cd7c335dbfd53d5456f571414e79ce6c9454f6e9df0379ff89fdb57f155f8947cde33ce164ee19766f75c75f89cc8df345
7
+ data.tar.gz: da23490510ce31cf2abb2ece62189c9d464421ef15be0b07a44245fce4619afb0df52216674759a9dd07c2d07a7284b00fde9ef66e252c5e1a9e43fb81635c81
@@ -8,4 +8,8 @@ that reviewers can understand your intent. Keeping the description updated is
8
8
  especially important if they didn't participate in the discussion.
9
9
  -->
10
10
 
11
+ ## Checklist
12
+
13
+ - [ ] User-visible commits have a `Changelog:` trailer (see [CONTRIBUTING.md](https://gitlab.com/gitlab-org/ruby/gems/gitlab-triage/-/blob/master/CONTRIBUTING.md#changelog)).
14
+
11
15
  <!-- template sourced from https://gitlab.com/gitlab-org/ruby/gems/gitlab-triage/-/blob/master/.gitlab/merge_request_templates/Default.md -->
data/CONTRIBUTING.md CHANGED
@@ -29,3 +29,25 @@ git checkout my_branch
29
29
  bundle install --path vendor/bundle
30
30
  bundle exec rake spec
31
31
  ```
32
+
33
+ ## Changelog
34
+
35
+ Release notes are generated from `Changelog:` commit trailers using the
36
+ [GitLab changelog API](https://docs.gitlab.com/ee/api/repositories.html#generate-changelog-data).
37
+ The available categories are listed in `.gitlab/changelog_config.yml`:
38
+ `added`, `fixed`, `changed`, `deprecated`, `removed`, `security`,
39
+ `performance`, and `other`.
40
+
41
+ - Add a trailer to every commit with a user-visible change
42
+ - Dependency updates, documentation-only changes, and internal refactors
43
+ don't need one
44
+ - Use `changed` when a change makes previously-loading policies fail
45
+
46
+ ```
47
+ Add remove_labels action to the work_items policy type
48
+
49
+ The work_items type only supported labels and assignees, so
50
+ remove_labels was rejected at load time.
51
+
52
+ Changelog: added
53
+ ```
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab-triage (1.57.0)
4
+ gitlab-triage (1.58.0)
5
5
  activesupport (>= 5.1)
6
6
  globalid (~> 1.0, >= 1.0.1)
7
7
  graphql (>= 2.4.13, < 2.7.0)
data/README.md CHANGED
@@ -188,15 +188,61 @@ resource_rules:
188
188
 
189
189
  #### Supported conditions
190
190
 
191
+ Sent to the Work Items REST API:
192
+
191
193
  - `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
+ `work_item_type_names` filter (case-insensitive, server-side). Any work item
195
+ type name is accepted.
196
+ - `state` - `opened` or `closed`, checked at load time.
194
197
  - `status` - custom work item status name (for example `In progress`).
195
198
  Enterprise Edition only.
196
199
  - `labels` / `forbidden_labels` - label names to require or exclude.
197
200
  - `assignees` - assignee usernames.
201
+ - `milestone` - one milestone title, or one of `none`, `any`, `upcoming`,
202
+ `started` (case-insensitive). Maps to the `milestone_title` or
203
+ `milestone_wildcard_id` filter. Same shape as the
204
+ [milestone condition](#milestone-condition). A list of more than one
205
+ milestone, or a blank one, is rejected at load time. Quote titles that look
206
+ like numbers (`milestone: '19.10'`): YAML reads an unquoted `19.10` as
207
+ `19.1`, so it is rejected too.
208
+
209
+ `upcoming` and `started` follow GitLab's current milestone logic, so they
210
+ can match different work items than the same values on `issues`. The
211
+ Issues REST API keeps the logic deprecated in GitLab 17.7 to avoid a
212
+ breaking change
213
+ ([gitlab-org/gitlab#501294](https://gitlab.com/gitlab-org/gitlab/-/issues/501294)),
214
+ and the Work Items REST API does not:
215
+
216
+ - `upcoming`: on `issues`, the one milestone per project or group with the
217
+ nearest future due date, often the milestone in progress. On
218
+ `work_items`, every milestone that has not started yet.
219
+ - `started`: on `issues`, active milestones that have started, including
220
+ overdue ones. On `work_items`, only those whose due date has not passed.
221
+
222
+ Dry-run a rule that uses `upcoming` or `started` before moving it from
223
+ `issues` to `work_items`.
198
224
  - `date` - `created_at` / `updated_at` staleness (same shape as the
199
- [date condition](#date-condition)).
225
+ [date condition](#date-condition)). `attribute`, `condition`,
226
+ `interval_type`, and `interval` are all required and checked at load time,
227
+ with or without `filter_in_ruby`, and `interval` must be a positive number.
228
+ A missing or unsupported value would otherwise send no date filter, so the
229
+ rule would match every work item, and a misspelled `condition` would select
230
+ the opposite work items.
231
+
232
+ Run in Ruby on the fetched work items:
233
+
234
+ - `author_member` - see [author member condition](#author-member-condition).
235
+ - `assignee_member` - see [assignee member condition](#assignee-member-condition).
236
+ - `ruby` - see [ruby condition](#ruby-condition).
237
+ - `no_additional_labels` - see [no additional labels condition](#no-additional-labels-condition).
238
+ - `work_item_status` - see [work item status condition](#work-item-status-condition).
239
+
240
+ Any other condition key (for example `issue_type`, `weight`, or a misspelled
241
+ key) is rejected at load time, before any resource is fetched, with an error
242
+ listing the supported conditions. Ignoring it instead would make the rule match
243
+ more work items than intended. `votes`, `upvotes`, and `discussions` are not
244
+ supported yet (tracked in
245
+ [gitlab-org/gitlab#630293](https://gitlab.com/gitlab-org/gitlab/-/work_items/630293)).
200
246
 
201
247
  #### Supported actions
202
248
 
@@ -208,20 +254,18 @@ resource_rules:
208
254
  - `assignees` - set assignees by username. If any requested username does not
209
255
  exist, the automation stops immediately rather than sending an empty
210
256
  assignee set (which would unassign everyone).
211
- - `comment` - **not yet supported.** The Work Items REST API does not yet
212
- provide a note-create endpoint (tracked in
213
- [gitlab-org/gitlab#604028](https://gitlab.com/gitlab-org/gitlab/-/work_items/604028)).
214
- A `comment` action on a `work_items` policy is rejected at load time, before
215
- any resource is fetched or acted on, with a clear error - so an unsupported
216
- policy never runs partway through. Use the `issues` policy type if you need
217
- to comment.
257
+ - `comment` - post a comment (note) on the work item through the Work Items REST
258
+ API. Supports the same body templating as the `issues` type, along with
259
+ `mention`, `comment_type: thread`, `comment_internal`, and
260
+ `redact_confidential_resources`. The `{{closed_by}}` placeholder needs
261
+ GitLab 19.6 or later. Quick actions in the body are run by the API, but the
262
+ available command set follows the work item type's enabled widgets, so some
263
+ issue-only commands are no-ops on work items.
218
264
 
219
265
  Summaries are also supported (`summarize` and `comment_on_summary`). Any other
220
- action key (for example `mention`, `move`, or `status`) is
221
- rejected at load time with an error listing the supported actions. On the
222
- older policy types these keys are applied through comment quick actions, which
223
- work items do not support yet - rejecting them up front beats silently doing
224
- nothing.
266
+ action key (for example `move` or `status`) is rejected at load time with an
267
+ error listing the supported actions, since those actions are not wired for the
268
+ `work_items` type yet - rejecting them up front beats silently doing nothing.
225
269
 
226
270
  Actions require a token with sufficient permissions (Planner or Reporter role
227
271
  and above). The update endpoint can return success without persisting a change
@@ -235,7 +279,15 @@ against; updates and label lookups target each work item's own namespace,
235
279
  because a group run can return work items living in different sub-projects and
236
280
  an iid is only unique within its namespace. Project-scoped resource helpers
237
281
  (for example `project_path` in templates) likewise resolve the underlying
238
- project from the work item's namespace.
282
+ project from the work item's namespace. `full_resource_reference` and the
283
+ `{{full_reference}}` placeholder render the work item's full reference (for
284
+ example `gitlab-org/gitlab#42`), the same as on `issues`. `resource[:health_status]`
285
+ is a string such as `on_track`, `needs_attention`, or `at_risk`, the same as on
286
+ `issues`. It is `nil` when no health status is set, and always `nil` on GitLab
287
+ Community Edition or a namespace without the license for health status. After a
288
+ license downgrade, work items can keep a stored health status that the API no
289
+ longer returns, so a rule such as `resource[:health_status] != 'at_risk'` can
290
+ match work items that are at risk.
239
291
 
240
292
  The `api:` field is not applicable to the `work_items` type, since the type
241
293
  already fixes the transport to the Work Items REST API.
@@ -1526,6 +1578,10 @@ resource_rules:
1526
1578
 
1527
1579
  Delete the resource.
1528
1580
 
1581
+ When GitLab refuses a deletion, for example with 403 because the token may not
1582
+ delete the resource, a warning with the URL, the status and GitLab's message is
1583
+ printed, and the rule goes on with the next resource.
1584
+
1529
1585
  Accept a boolean. Set to `true` to enable.
1530
1586
 
1531
1587
  Example :
@@ -61,21 +61,25 @@ module Gitlab
61
61
  end
62
62
 
63
63
  def perform(resource, comment)
64
- if network.options.debug
65
- if policy.actions.fetch(:redact_confidential_resources, true) && resource[:confidential]
66
- puts Gitlab::Triage::UI.debug "Comment action resource: (confidential)"
67
- else
68
- puts Gitlab::Triage::UI.debug "Comment action resource: #{resource.inspect}"
69
- end
70
-
71
- puts Gitlab::Triage::UI.debug "Comment action content: #{comment}"
72
- end
64
+ print_debug(resource, comment)
73
65
 
74
66
  network.post_api(
75
67
  build_post_url(resource),
76
68
  post_body(comment))
77
69
  end
78
70
 
71
+ def print_debug(resource, comment)
72
+ return unless network.options.debug
73
+
74
+ if policy.actions.fetch(:redact_confidential_resources, true) && resource[:confidential]
75
+ puts Gitlab::Triage::UI.debug "Comment action resource: (confidential)"
76
+ else
77
+ puts Gitlab::Triage::UI.debug "Comment action resource: #{resource.inspect}"
78
+ end
79
+
80
+ puts Gitlab::Triage::UI.debug "Comment action content: #{comment}"
81
+ end
82
+
79
83
  def build_post_url(resource)
80
84
  url_builder_opts = {
81
85
  network_options: network.options,
@@ -15,7 +15,7 @@ module Gitlab
15
15
  private
16
16
 
17
17
  def perform(resource)
18
- puts "DELETE resource with type: #{resource[:type]} and id: #{resource_id(resource)}"
18
+ puts "DELETE #{build_delete_url(resource)}"
19
19
  end
20
20
  end
21
21
 
@@ -36,7 +36,8 @@ module Gitlab
36
36
  def build_delete_url(resource)
37
37
  delete_url = UrlBuilders::UrlBuilder.new(
38
38
  source: policy.source,
39
- source_id: network.options.source_id,
39
+ # Branches carry no project_id, so they fall back to --source-id.
40
+ source_id: resource[policy.source_id_sym] || network.options.source_id,
40
41
  resource_type: policy.type,
41
42
  resource_id: resource_id(resource),
42
43
  network_options: network.options
@@ -22,9 +22,8 @@ module Gitlab
22
22
  class WorkItem < Base
23
23
  UpdateNotAppliedError = Class.new(StandardError)
24
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.
25
+ # Prints the requested changes without writing them. verify! still
26
+ # looks the names up first, so a typo fails the dry run too.
28
27
  class Dry < WorkItem
29
28
  def act
30
29
  puts "The following work items would be updated for the rule **#{policy.name}**:\n\n"
@@ -43,6 +42,17 @@ module Gitlab
43
42
  end
44
43
  end
45
44
 
45
+ # Resolves every item's labels and assignees up front, so a name missing
46
+ # in a later item's namespace fails the run before any item is labeled.
47
+ def verify!
48
+ policy.resources.each do |resource|
49
+ label_ids_to_add(resource) if requested_labels.any?
50
+ label_ids_to_remove(resource) if requested_removed_labels.any?
51
+ end
52
+
53
+ assignee_ids if requested_assignees.any?
54
+ end
55
+
46
56
  def act
47
57
  return unless requested_labels.any? || requested_removed_labels.any? || requested_assignees.any?
48
58
 
@@ -126,7 +136,7 @@ module Gitlab
126
136
 
127
137
  def labels_by_name(scope)
128
138
  @labels_by_name_by_scope ||= {}
129
- @labels_by_name_by_scope[scope] ||= network.query_api(labels_url(scope)).index_by { |label| label[:name] }
139
+ @labels_by_name_by_scope[scope] ||= network.query_api_cached(labels_url(scope)).index_by { |label| label[:name] }
130
140
  end
131
141
 
132
142
  # The Work Items REST update endpoint can return success without
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'comment'
4
+ require_relative '../sources/work_items_rest_source'
5
+
6
+ module Gitlab
7
+ module Triage
8
+ module Action
9
+ # Posts comments on work items through the Work Items REST API notes
10
+ # endpoint rather than the issues note/quick-action path. The comment body
11
+ # (templating, redaction, and mentions) is built exactly as on the issues
12
+ # type - only the write target differs, so this reuses Comment and
13
+ # overrides just where the note is sent.
14
+ class WorkItemComment < Comment
15
+ # WorkItem writes labels before this posts, so reject an unknown
16
+ # comment_type up front rather than after the labels are written.
17
+ def verify!
18
+ sub_resource_type
19
+ end
20
+
21
+ private
22
+
23
+ # Raising would also lose every later item's note, and any labels are
24
+ # already written. Warn and move on, as issues drop a rejected note.
25
+ def perform(resource, comment)
26
+ print_debug(resource, comment)
27
+ source.create_note(resource, post_body(comment), sub_resource_type: sub_resource_type)
28
+ rescue Sources::WorkItemsRestSource::NoteNotCreatedError => e
29
+ puts Gitlab::Triage::UI.warn(e.message)
30
+ end
31
+
32
+ def source
33
+ @source ||= Sources::WorkItemsRestSource.new(
34
+ network: network, options: network.options)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -7,6 +7,7 @@ require_relative 'action/issue'
7
7
  require_relative 'action/delete'
8
8
  require_relative 'action/work_item_status'
9
9
  require_relative 'action/work_item'
10
+ require_relative 'action/work_item_comment'
10
11
  require_relative 'action/update'
11
12
 
12
13
  module Gitlab
@@ -24,10 +25,9 @@ module Gitlab
24
25
  end
25
26
  end
26
27
 
27
- # Comment runs before the actions that write labels, so without this a
28
- # rule naming a label, user, milestone, or move destination that no
29
- # longer exists posts its note and only then raises, and the next
30
- # scheduled run posts it again.
28
+ # Without this, a rule naming a label, user, milestone, or move
29
+ # destination that no longer exists raises only after earlier actions
30
+ # wrote, such as an issues note the next scheduled run posts again.
31
31
  def self.verify!(actions, policy:, network:)
32
32
  actions.each do |action|
33
33
  action.new(policy: policy, network: network).verify!
@@ -36,13 +36,17 @@ module Gitlab
36
36
 
37
37
  # Work items are fetched and updated through the Work Items REST API,
38
38
  # which does not share the issues note/quick-action write path. Their
39
- # actions (assign, label, comment) are handled by a dedicated action
40
- # instead of the note-based Comment action.
39
+ # actions are handled by dedicated actions: WorkItem (assign, label) and
40
+ # WorkItemComment, which posts through the REST notes endpoint rather than
41
+ # the issues Comment path. Unlike issues, labels are written before the
42
+ # note: a failed label write then loses a note once, instead of leaving
43
+ # unlabeled items that get the note again on every run.
41
44
  def self.actions_for(policy)
42
45
  if policy.type == 'work_items'
43
46
  [
44
47
  [Summarize, policy.summarize?],
45
48
  [WorkItem, policy.work_item?],
49
+ [WorkItemComment, policy.comment?],
46
50
  [CommentOnSummary, policy.comment_on_summary?]
47
51
  ]
48
52
  else
@@ -33,6 +33,7 @@ require_relative 'rest_api_network'
33
33
  require_relative 'network_adapters/httparty_adapter'
34
34
  require_relative 'network_adapters/graphql_adapter'
35
35
  require_relative 'graphql_queries/query_builder'
36
+ require_relative 'milestone_wildcards'
36
37
  require_relative 'sources/work_items_rest_source'
37
38
  require_relative 'validators/params_validator'
38
39
  require_relative 'validators/policy_validator'
@@ -47,6 +48,8 @@ module Gitlab
47
48
  # This filter map is used to help make the filter_resource method
48
49
  # smaller. We loop through each of the keys (conditions) and map
49
50
  # that to the filters that will be used for it.
51
+ # A filter enabled for work_items must also be added to
52
+ # Validators::PolicyValidator::RUBY_WORK_ITEM_CONDITIONS.
50
53
  FILTER_MAP = {
51
54
  date: {
52
55
  'branches' => Filters::BranchDateFilter,
@@ -70,9 +73,9 @@ module Gitlab
70
73
  ALLOWED_STATE_VALUES = {
71
74
  issues: %w[opened closed],
72
75
  merge_requests: %w[opened closed merged],
73
- work_items: %w[opened closed]
76
+ work_items: Validators::PolicyValidator::WORK_ITEM_STATES
74
77
  }.with_indifferent_access.freeze
75
- MILESTONE_TIMEBOX_VALUES = %w[none any upcoming started].freeze
78
+ MILESTONE_TIMEBOX_VALUES = MilestoneWildcards::VALUES
76
79
  ITERATION_SELECTION_VALUES = %w[none any].freeze
77
80
  EpicsTriagingForProjectImpossibleError = Class.new(StandardError)
78
81
  MultiPolicyInInjectionModeError = Class.new(StandardError)
@@ -515,14 +518,8 @@ module Gitlab
515
518
  conditions = expanded_conditions.dup
516
519
  conditions[:iids] = options.resource_reference[1..] if options.resource_reference
517
520
 
518
- # The work_items path bypasses build_get_url, so validate `state` here
519
- # the same way the URL builders do for the other resource types.
520
- if conditions[:state]
521
- ParamsValidator.new(
522
- [{ name: 'state', type: String, values: ALLOWED_STATE_VALUES[:work_items] }],
523
- { 'state' => conditions[:state] }).validate!
524
- end
525
-
521
+ # The work_items path bypasses build_get_url, so `state` is not checked by
522
+ # the URL builders. PolicyValidator checks it in #perform before any fetch.
526
523
  work_items_source.list(conditions)
527
524
  end
528
525
 
@@ -679,12 +676,8 @@ module Gitlab
679
676
  return if milestone_value.empty?
680
677
 
681
678
  # Issues API should use the `milestone_id` param for timebox values, and `milestone` for milestone title
682
- args =
683
- if resource_type.to_sym == :issues && MILESTONE_TIMEBOX_VALUES.include?(milestone_value.downcase)
684
- ['milestone_id', milestone_value.titleize] # The API only accepts titleized values.
685
- else
686
- ['milestone', milestone_value]
687
- end
679
+ wildcard = MilestoneWildcards.api_value(milestone_value) if resource_type.to_sym == :issues
680
+ args = wildcard ? ['milestone_id', wildcard] : ['milestone', milestone_value]
688
681
 
689
682
  APIQueryBuilders::SingleQueryParamBuilder.new(*args)
690
683
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string/inflections'
4
+
5
+ module Gitlab
6
+ module Triage
7
+ # Milestone condition values that select a group of milestones rather than one
8
+ # title. Kept apart from Engine so the work items source and the policy validator
9
+ # can share it: Engine requires both of them.
10
+ module MilestoneWildcards
11
+ VALUES = %w[none any upcoming started].freeze
12
+
13
+ # The Issues API (milestone_id) and the Work Items API (milestone_wildcard_id)
14
+ # only accept the capitalised form, for example Started. Returns nil for a title.
15
+ def self.api_value(milestone)
16
+ value = milestone.to_s.downcase
17
+
18
+ value.titleize if VALUES.include?(value)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -126,6 +126,7 @@ module Gitlab
126
126
  tracker&.record('DELETE', url)
127
127
 
128
128
  {
129
+ status: response.code,
129
130
  results: response.parsed_response,
130
131
  ratelimit_remaining: rate_limit_remaining(response.headers),
131
132
  ratelimit_reset_at: rate_limit_reset_at(response.headers)
@@ -27,10 +27,22 @@ module Gitlab
27
27
  confidential: resource[:confidential],
28
28
  created_at: resource[:created_at],
29
29
  updated_at: resource[:updated_at],
30
+ closed_at: resource[:closed_at],
30
31
  author: resource[:author],
32
+ closed_by: resource[:closed_by],
33
+ # Same shape as the Issues API, which full_resource_reference and
34
+ # the {{full_reference}} placeholder read.
35
+ references: ({ full: resource[:reference] } if resource[:reference]),
31
36
  labels: label_titles(features),
32
37
  assignees: assignees(features),
38
+ # The issues API's deprecated single assignee, read by {{assignee}}.
39
+ assignee: assignees(features).first,
33
40
  milestone: features[:milestone],
41
+ upvotes: features.dig(:award_emoji, :upvotes),
42
+ downvotes: features.dig(:award_emoji, :downvotes),
43
+ # Flat string, like the Issues API: ruby conditions compare
44
+ # resource[:health_status] with values such as 'at_risk'.
45
+ health_status: features.dig(:health_status, :health_status),
34
46
  work_item_type: resource.dig(:work_item_type, :name),
35
47
  work_item_status: status_name(features),
36
48
  start_date: features.dig(:start_and_due_date, :start_date),
@@ -147,6 +147,7 @@ module Gitlab
147
147
 
148
148
  rate_limit_debug(response) if options.debug
149
149
  rate_limit_wait(response)
150
+ warn_on_failed_delete(url, response)
150
151
  end
151
152
 
152
153
  private
@@ -218,6 +219,21 @@ module Gitlab
218
219
  sleep(1) until Time.now >= reset_at
219
220
  end
220
221
 
222
+ # Warns rather than raises, so one refused DELETE does not stop the
223
+ # other deletions of the rule.
224
+ def warn_on_failed_delete(url, response)
225
+ status = response[:status]
226
+ return if status.nil? || (200..299).cover?(status)
227
+
228
+ results = response[:results]
229
+ message = results['message'] || results['error'] if results.is_a?(Hash)
230
+
231
+ warning = "DELETE #{url} failed with status #{status}"
232
+ warning += ": #{message}" if message
233
+
234
+ puts Gitlab::Triage::UI.warn(warning)
235
+ end
236
+
221
237
  def raise_unexpected_response(results)
222
238
  raise Errors::Network::UnexpectedResponse, "Unexpected response: #{results.inspect}"
223
239
  end
@@ -2,10 +2,12 @@
2
2
 
3
3
  require 'active_support/all'
4
4
 
5
+ require_relative '../milestone_wildcards'
5
6
  require_relative '../url_builders/url_builder'
6
7
  require_relative '../normalizers/work_item_normalizer'
7
8
  require_relative '../errors'
8
9
  require_relative '../ui'
10
+ require_relative '../validators/policy_validator'
9
11
 
10
12
  module Gitlab
11
13
  module Triage
@@ -19,12 +21,12 @@ module Gitlab
19
21
  #
20
22
  # The source is a self-contained peer to the engine's inline issues /
21
23
  # 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.
24
+ # push-down, normalization, the PATCH update path, and the notes /
25
+ # discussions write path used by the `comment` action.
25
26
  class WorkItemsRestSource
26
27
  UnavailableError = Class.new(StandardError)
27
28
  ScopeError = Class.new(StandardError)
29
+ NoteNotCreatedError = Class.new(StandardError)
28
30
 
29
31
  # Base fields opted into on the list endpoint. The endpoint is sparse
30
32
  # by default (id, iid, global_id, title only); every field a filter or
@@ -38,15 +40,22 @@ module Gitlab
38
40
  author
39
41
  namespace
40
42
  work_item_type
43
+ reference
44
+ closed_at
45
+ closed_by
41
46
  ].freeze
42
47
 
43
- # Widget payloads opted into via `features`.
48
+ # Widget payloads opted into via `features`. status and health_status are EE
49
+ # only: CE accepts them on read endpoints and leaves them out of the response,
50
+ # as an unlicensed EE namespace does, so their values are then nil.
44
51
  LIST_FEATURES = %w[
45
52
  labels
46
53
  assignees
47
54
  milestone
48
55
  status
49
56
  start_and_due_date
57
+ award_emoji
58
+ health_status
50
59
  ].freeze
51
60
 
52
61
  def initialize(network:, options:, per_page: 100, normalizer: Normalizers::WorkItemNormalizer.new)
@@ -68,6 +77,16 @@ module Gitlab
68
77
  @network.patch_api(work_item_url(resource), features: features)
69
78
  end
70
79
 
80
+ # The adapter does not raise on a 4xx. Check for an error message, not a
81
+ # missing id, since a quick-action-only note returns 202 with no id.
82
+ def create_note(resource, body, sub_resource_type: 'notes')
83
+ response = @network.post_api(note_url(resource, sub_resource_type), body)
84
+ error = response[:id].blank? && (response[:message] || response[:error])
85
+ return response unless error
86
+
87
+ raise NoteNotCreatedError, "The note was not posted on #{resource[:web_url] || resource[:iid]} (#{error})."
88
+ end
89
+
71
90
  # Returns [source, source_id] for the namespace the resource lives in.
72
91
  # A group run can return work items from descendant projects, and an
73
92
  # iid is only unique within its own namespace, so writes and label
@@ -168,12 +187,26 @@ module Gitlab
168
187
  ).build
169
188
  end
170
189
 
190
+ def note_url(resource, sub_resource_type)
191
+ scope, scope_id = scope_for(resource)
192
+
193
+ UrlBuilders::UrlBuilder.new(
194
+ network_options: @options,
195
+ source: scope,
196
+ source_id: scope_id,
197
+ resource_type: '-/work_items',
198
+ resource_id: resource[:iid],
199
+ sub_resource_type: sub_resource_type
200
+ ).build
201
+ end
202
+
171
203
  def list_params(conditions)
172
204
  params = {
173
205
  per_page: @per_page,
174
206
  fields: LIST_FIELDS.join(','),
175
207
  features: LIST_FEATURES.join(',')
176
208
  }
209
+ params[:include_descendants] = true if @options.source == 'groups'
177
210
 
178
211
  apply_filters(params, conditions)
179
212
 
@@ -184,6 +217,8 @@ module Gitlab
184
217
  # `type` uses `work_item_type_names`, which resolves names (including
185
218
  # custom types) case-insensitively server-side - no id resolution
186
219
  # needed.
220
+ # A new work_items condition must also be added to
221
+ # Validators::PolicyValidator::API_WORK_ITEM_CONDITIONS.
187
222
  def apply_filters(params, conditions)
188
223
  params[:iids] = Array(conditions[:iids]).join(',') if conditions[:iids].present?
189
224
  params[:work_item_type_names] = Array(conditions[:type]).join(',') if conditions[:type]
@@ -193,30 +228,39 @@ module Gitlab
193
228
  params[:assignee_usernames] = Array(conditions[:assignees]).join(',') if conditions[:assignees]
194
229
  params['status[name]'] = conditions[:status] if conditions[:status]
195
230
 
231
+ apply_milestone_filter(params, conditions[:milestone]) if conditions[:milestone]
196
232
  apply_date_filter(params, conditions[:date]) if conditions[:date]
197
233
  end
198
234
 
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)
235
+ # PolicyValidator rejects more than one value, so a list here has at most one.
236
+ # The Issues REST API keeps the legacy Started/Upcoming logic and this API does
237
+ # not, so the same wildcard can match different milestones (see README).
238
+ def apply_milestone_filter(params, milestone_condition)
239
+ milestone = Array(milestone_condition).first.to_s
240
+ return if milestone.empty?
241
+
242
+ wildcard = MilestoneWildcards.api_value(milestone)
203
243
 
204
- boundary = date_boundary(date_condition)
205
- return unless boundary
244
+ if wildcard
245
+ params[:milestone_wildcard_id] = wildcard
246
+ else
247
+ params[:milestone_title] = milestone
248
+ end
249
+ end
206
250
 
207
- direction = condition == 'older_than' ? 'before' : 'after'
208
- params["#{attribute.delete_suffix('_at')}_#{direction}"] = boundary.iso8601
251
+ # A bad date condition would otherwise send no date filter, or the opposite one,
252
+ # and widen the matched set, so raise even if load-time validation was skipped.
253
+ def apply_date_filter(params, date_condition)
254
+ Validators::PolicyValidator.validate_work_items_date!(date_condition)
255
+
256
+ attribute = date_condition[:attribute]
257
+ direction = date_condition[:condition] == 'older_than' ? 'before' : 'after'
258
+ params["#{attribute.delete_suffix('_at')}_#{direction}"] = date_boundary(date_condition).iso8601
209
259
  end
210
260
 
211
261
  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
262
+ # validate_work_items_date! limits interval_type to the date filter's INTERVAL_TYPES.
263
+ date_condition[:interval].public_send(date_condition[:interval_type]).ago # rubocop:disable GitlabSecurity/PublicSend -- interval_type constrained to a known set
220
264
  end
221
265
  end
222
266
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../filters/work_item_date_conditions_filter'
4
+ require_relative '../milestone_wildcards'
5
+
3
6
  module Gitlab
4
7
  module Triage
5
8
  module Validators
@@ -10,16 +13,77 @@ module Gitlab
10
13
  # where nobody sees it).
11
14
  class PolicyValidator
12
15
  UnsupportedActionError = Class.new(StandardError)
16
+ UnsupportedConditionError = Class.new(StandardError)
17
+ InvalidConditionError = Class.new(StandardError)
13
18
  ConflictingActionError = Class.new(StandardError)
14
19
 
15
- COMMENT_NOT_SUPPORTED_MESSAGE =
16
- 'Commenting on work_items is not yet supported by the Work Items REST API ' \
17
- '(blocked on https://gitlab.com/gitlab-org/gitlab/-/work_items/604028). ' \
18
- 'Use the issues policy type for comment actions.'
20
+ # Action keys Action.actions_for dispatches for work_items policies
21
+ # (comment, mention, comment_type, comment_internal, and
22
+ # redact_confidential_resources are the comment action and its options).
23
+ # Anything outside this list would silently do nothing, so reject it up
24
+ # front.
25
+ SUPPORTED_WORK_ITEM_ACTIONS = %i[
26
+ labels remove_labels assignees
27
+ comment mention comment_type comment_internal redact_confidential_resources
28
+ summarize comment_on_summary
29
+ ].freeze
30
+
31
+ # A summary's own actions run over the whole summary, not per item, so
32
+ # the per-item actions would crash there.
33
+ SUPPORTED_SUMMARY_ACTIONS = %i[summarize comment_on_summary].freeze
34
+
35
+ # A rule nested in a summary only feeds it: the engine reads its
36
+ # summarize action and runs nothing else.
37
+ SUPPORTED_SUMMARY_RULE_ACTIONS = %i[summarize].freeze
38
+
39
+ # Sent to the Work Items REST API as query params by WorkItemsRestSource#apply_filters.
40
+ API_WORK_ITEM_CONDITIONS = %i[type state status labels forbidden_labels assignees milestone date].freeze
41
+
42
+ # Applied in Ruby to the fetched work items through Engine::FILTER_MAP.
43
+ RUBY_WORK_ITEM_CONDITIONS = %i[author_member assignee_member ruby no_additional_labels work_item_status].freeze
44
+
45
+ # Any other key would be silently ignored and widen the matched set, so reject it up front.
46
+ SUPPORTED_WORK_ITEM_CONDITIONS = (API_WORK_ITEM_CONDITIONS + RUBY_WORK_ITEM_CONDITIONS).freeze
47
+
48
+ # States the Work Items REST API filters on. Engine::ALLOWED_STATE_VALUES reads
49
+ # this too, since the engine requires this file and not the other way round.
50
+ WORK_ITEM_STATES = %w[opened closed].freeze
51
+
52
+ # WorkItemsRestSource#apply_date_filter would send no date filter for a bad attribute
53
+ # or a missing interval, so the rule would match every item, and reads any condition
54
+ # other than older_than as newer_than. Check the values against the same parameters
55
+ # the Ruby date filter uses, so filter_in_ruby: true accepts exactly the same ones.
56
+ # The source calls this too, so a condition that skipped load-time validation still
57
+ # cannot reach the API unchecked.
58
+ def self.validate_work_items_date!(date_condition)
59
+ # Despite the name, filter_parameters returns the definitions of the date
60
+ # condition's keys (name, type, allowed values), not API query params.
61
+ date_parameters = Filters::WorkItemDateConditionsFilter.filter_parameters
62
+
63
+ unless date_condition.is_a?(Hash)
64
+ raise InvalidConditionError,
65
+ "The date condition on work_items policies takes " \
66
+ "#{date_parameters.pluck(:name).to_sentence}, but got #{date_condition.inspect}."
67
+ end
68
+
69
+ date_condition = date_condition.with_indifferent_access
70
+ ParamsValidator.new(date_parameters, date_condition).validate!
71
+ validate_date_interval!(date_condition)
72
+ rescue ParamsValidator::InvalidParameter => e
73
+ raise InvalidConditionError,
74
+ "Invalid date condition on work_items policies: #{e.message}, but got #{date_condition.inspect}."
75
+ end
19
76
 
20
- # Action keys Action.actions_for dispatches for work_items policies.
21
- # Anything outside this list would silently do nothing, so reject it up front.
22
- SUPPORTED_WORK_ITEM_ACTIONS = %i[labels remove_labels assignees summarize comment_on_summary].freeze
77
+ # A zero or negative interval puts the boundary at or after now, so older_than
78
+ # matches every item; YAML's .inf and .nan load as Floats and raise only when
79
+ # the boundary is computed, partway through a run.
80
+ def self.validate_date_interval!(date_condition)
81
+ interval = date_condition[:interval]
82
+ return if interval.finite? && interval.positive?
83
+
84
+ raise ParamsValidator::InvalidParameter, 'interval must be a positive, finite number'
85
+ end
86
+ private_class_method :validate_date_interval!
23
87
 
24
88
  def initialize(resource_rules)
25
89
  @resource_rules = resource_rules || {}
@@ -29,16 +93,68 @@ module Gitlab
29
93
  @resource_rules.each do |resource_type, policy_definition|
30
94
  next unless resource_type.to_s == 'work_items'
31
95
 
96
+ validate_work_items_conditions!(policy_definition)
32
97
  validate_work_items_actions!(policy_definition)
33
98
  end
34
99
  end
35
100
 
36
101
  private
37
102
 
38
- def validate_work_items_actions!(policy_definition)
39
- action_sets_from(policy_definition).each do |actions|
40
- raise UnsupportedActionError, COMMENT_NOT_SUPPORTED_MESSAGE if actions.key?(:comment)
103
+ def validate_work_items_conditions!(policy_definition)
104
+ condition_sets_from(policy_definition).each do |conditions|
105
+ unsupported = conditions.keys.map(&:to_sym) - SUPPORTED_WORK_ITEM_CONDITIONS
106
+ unless unsupported.none?
107
+ raise UnsupportedConditionError,
108
+ "Unsupported condition(s) for work_items policies: #{unsupported.join(', ')}. " \
109
+ "Supported conditions: #{SUPPORTED_WORK_ITEM_CONDITIONS.join(', ')}."
110
+ end
111
+
112
+ validate_state!(conditions[:state]) if conditions.key?(:state)
113
+ validate_milestone!(conditions[:milestone]) if conditions.key?(:milestone)
114
+ self.class.validate_work_items_date!(conditions[:date]) if conditions.key?(:date)
115
+ end
116
+ end
117
+
118
+ # Checked here rather than when the rule fetches, so a typo such as `open`
119
+ # fails before any earlier rule has acted. `merged` is valid for merge
120
+ # requests but not for work items.
121
+ def validate_state!(state)
122
+ return if WORK_ITEM_STATES.include?(state)
123
+
124
+ raise InvalidConditionError,
125
+ "The state condition on work_items policies takes one of #{WORK_ITEM_STATES.join(', ')}, " \
126
+ "but got #{state.inspect}."
127
+ end
128
+
129
+ # The issues policy type silently uses the first of several milestones, and
130
+ # skips the filter for a blank one, so the rule matches every item. work_items
131
+ # has no old policies to stay compatible with, so it rejects both.
132
+ def validate_milestone!(milestone_condition)
133
+ milestones = Array(milestone_condition)
134
+ if milestones.size > 1
135
+ raise InvalidConditionError,
136
+ "The milestone condition takes one value on work_items policies, " \
137
+ "but got #{milestones.size}: #{milestones.join(', ')}."
138
+ end
139
+
140
+ milestone = milestones.first
141
+ return if milestone.is_a?(String) && milestone.strip.present?
142
+
143
+ raise InvalidConditionError, invalid_milestone_message(milestone)
144
+ end
41
145
 
146
+ # YAML reads an unquoted 19.10 as the number 19.1, so a version-like title
147
+ # must be quoted to reach the API intact.
148
+ def invalid_milestone_message(milestone)
149
+ message = "The milestone condition on work_items policies takes a milestone title " \
150
+ "or one of #{MilestoneWildcards::VALUES.join(', ')}, but got #{milestone.inspect}."
151
+ return message unless milestone.is_a?(Numeric)
152
+
153
+ "#{message} Quote titles that look like numbers, for example milestone: '19.10'."
154
+ end
155
+
156
+ def validate_work_items_actions!(policy_definition)
157
+ action_sets_from(policy_definition).each do |actions, supported, scope|
42
158
  # GitLab applies remove_label_ids after add_label_ids, so a label in
43
159
  # both lists is always removed. Catch the typo here rather than let the
44
160
  # run fail later with a misleading "token may lack permission" error.
@@ -49,25 +165,37 @@ module Gitlab
49
165
  "A label cannot be added and removed in the same rule."
50
166
  end
51
167
 
52
- unsupported = actions.keys.map(&:to_sym) - SUPPORTED_WORK_ITEM_ACTIONS
168
+ unsupported = actions.keys.map(&:to_sym) - supported
53
169
  next if unsupported.none?
54
170
 
55
171
  raise UnsupportedActionError,
56
- "Unsupported action(s) for work_items policies: #{unsupported.join(', ')}. " \
57
- "Supported actions: #{SUPPORTED_WORK_ITEM_ACTIONS.join(', ')}."
172
+ "Unsupported action(s) for work_items #{scope}: #{unsupported.join(', ')}. " \
173
+ "Supported actions: #{supported.join(', ')}."
58
174
  end
59
175
  end
60
176
 
61
- # Every place a policy definition can carry actions: top-level rules,
62
- # rules nested in summaries, and the summaries' own actions.
177
+ # Every place a policy definition can carry actions, with the actions
178
+ # allowed there: top-level rules, rules nested in summaries, and the
179
+ # summaries' own actions.
63
180
  def action_sets_from(policy_definition)
64
181
  policy_definition ||= {}
65
182
 
66
183
  summaries = Array(policy_definition[:summaries])
67
184
  summary_rules = summaries.flat_map { |summary| Array(summary[:rules]) }
68
185
 
69
- (Array(policy_definition[:rules]) + summary_rules + summaries)
70
- .map { |definition| definition[:actions] || {} }
186
+ Array(policy_definition[:rules]).map { |rule| [rule[:actions] || {}, SUPPORTED_WORK_ITEM_ACTIONS, 'policies'] } +
187
+ summary_rules.map { |rule| [rule[:actions] || {}, SUPPORTED_SUMMARY_RULE_ACTIONS, 'summary rules'] } +
188
+ summaries.map { |summary| [summary[:actions] || {}, SUPPORTED_SUMMARY_ACTIONS, 'summaries'] }
189
+ end
190
+
191
+ # Conditions live on top-level rules and rules nested in summaries.
192
+ def condition_sets_from(policy_definition)
193
+ policy_definition ||= {}
194
+
195
+ summary_rules = Array(policy_definition[:summaries]).flat_map { |summary| Array(summary[:rules]) }
196
+
197
+ (Array(policy_definition[:rules]) + summary_rules)
198
+ .map { |definition| definition[:conditions] || {} }
71
199
  end
72
200
  end
73
201
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module Triage
5
- VERSION = '1.57.0'
5
+ VERSION = '1.58.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.57.0
4
+ version: 1.58.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-10-19 00:00:00.000000000 Z
11
+ date: 2026-10-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -252,6 +252,7 @@ files:
252
252
  - lib/gitlab/triage/action/summarize.rb
253
253
  - lib/gitlab/triage/action/update.rb
254
254
  - lib/gitlab/triage/action/work_item.rb
255
+ - lib/gitlab/triage/action/work_item_comment.rb
255
256
  - lib/gitlab/triage/action/work_item_status.rb
256
257
  - lib/gitlab/triage/api_observability.rb
257
258
  - lib/gitlab/triage/api_query_builders/base_query_param_builder.rb
@@ -294,6 +295,7 @@ files:
294
295
  - lib/gitlab/triage/graphql_queries/query_param_builders/date_param_builder.rb
295
296
  - lib/gitlab/triage/limiters/base_limiter.rb
296
297
  - lib/gitlab/triage/limiters/date_field_limiter.rb
298
+ - lib/gitlab/triage/milestone_wildcards.rb
297
299
  - lib/gitlab/triage/network.rb
298
300
  - lib/gitlab/triage/network_adapters/base_adapter.rb
299
301
  - lib/gitlab/triage/network_adapters/graphql_adapter.rb