gitlab-triage 1.54.0 → 1.55.1

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: c2e36c5b7ecfbf719e3a7ac2282fc01d0e2f0af214eaeead7d60c913456090f8
4
- data.tar.gz: 3da3785a4e850948e45ce922f6169a1dc9faa2d817eb3ebbd5285476c0290aee
3
+ metadata.gz: 72ed33025703247cca10024af7e8be895ecc03094d5733830b6dcbc73742aae5
4
+ data.tar.gz: da8fc6fc4932bd8038bf69947533cdcfc5bd0efb75d07c3e25c262d23e822411
5
5
  SHA512:
6
- metadata.gz: e4b4c9e46c33d13f835e62e3c0638aa0663d4ab5f0ff9d64b0d404fb64cf738895cc1708346c92a8103cba4b41a089f7c746292baab3c46e04518ac09339aac9
7
- data.tar.gz: cd9dd8c10b7381c41858c1f0de7a64205c87c79669e2e475e2748c40300536469369e05744545f5b55b2aed89d1384b329abaf733bbe4db837bb0a5f0d9732a5
6
+ metadata.gz: 0703eb40a6bd259c8931eb2ec71bdd3492e5bee59d0644737b1919b38c414776f547b06344bed54b9dc6d6825a5fca7bc06ea5e98de2cbcd8d0a1adccf936707
7
+ data.tar.gz: 59768f9c0f1be9f286af3a0e357a3a2be44db55e32b5df7e97381a273a4f397432574c2f65f09854a45eb7e7060ea9fcbad202ee12e076938278dea68df5eda6
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab-triage (1.54.0)
4
+ gitlab-triage (1.55.1)
5
5
  activesupport (>= 5.1)
6
6
  globalid (~> 1.0, >= 1.0.1)
7
7
  graphql (< 2.1.0)
data/README.md CHANGED
@@ -928,7 +928,9 @@ Available action types:
928
928
 
929
929
  Adds a number of labels to the resource.
930
930
 
931
- Accepts an array of strings. Each element is the name of a label to add.
931
+ Accepts an array of strings. Each element is the name of a label to add. An
932
+ element may interpolate to a comma-separated list, which is expanded into one
933
+ label per value.
932
934
 
933
935
  If any of the labels doesn't exist, the automation will stop immediately so
934
936
  that if a label is renamed or deleted, you'll have to explicitly update or remove
@@ -957,7 +959,9 @@ actions:
957
959
 
958
960
  Removes a number of labels from the resource.
959
961
 
960
- Accepts an array of strings. Each element is the name of a label to remove.
962
+ Accepts an array of strings. Each element is the name of a label to remove. An
963
+ element may interpolate to a comma-separated list, which is expanded into one
964
+ label per value.
961
965
 
962
966
  If any of the labels doesn't exist, the automation will stop immediately so
963
967
  that if a label is renamed or deleted, you'll have to explicitly update or remove
@@ -1346,7 +1350,10 @@ Accepts a hash of fields.
1346
1350
  | `destination` | integer or string | The project ID or path to create the generated issue in | no | no | no | source project |
1347
1351
  | `item` | string | Template representing each triaged resource | no | yes | yes | |
1348
1352
  | `summary` | string | The description of the generated issue | no | Only `{{title}}`, `{{items}}`, `{{type}}` | yes | |
1353
+ | `labels` | array of strings | Labels to add to the generated issue. An element may interpolate to a comma-separated list, which is expanded into one label per value | no | yes | yes | |
1354
+ | `assignees` | array of strings | Usernames to assign to the generated issue. An element may interpolate to a comma-separated list, which is expanded into one assignee per value | no | yes | yes | |
1349
1355
  | `redact_confidential_resources` | boolean | Whether redact fields for confidential resources | no | no | no | true |
1356
+ | `confidential` | boolean or string | Create the issue confidential. A string is interpolated and counts as true when it evaluates to `true` | no | no | yes | false |
1350
1357
 
1351
1358
  The following placeholders are supported for `summary`:
1352
1359
 
@@ -1360,14 +1367,16 @@ The following placeholders are supported for `summary`:
1360
1367
  > - Placeholders work regularly for `item`, but for `summary` only `{{title}}`,
1361
1368
  > `{{items}}`, `{{type}}` are supported because it's not tied to a particular
1362
1369
  > resource like the comment action.
1370
+ > - `labels`, `assignees`, `due_date`, `milestone`, and `confidential` can read
1371
+ > `{{type}}`, `{{items}}`, and `resource[:items]`.
1363
1372
  > - No issues will be created if:
1364
1373
  > - the specific policy doesn't yield any resources; or
1365
1374
  > - the source type is a group and `destination` is not set.
1366
1375
  > - `redact_confidential_resources` defaults to `true`, so fields on
1367
1376
  > confidential resources will be converted to `(confidential)` except for
1368
1377
  > `{{web_url}}`. Setting it to `false` will reveal the confidential fields.
1369
- > This will be useful if the summary is confidential itself (not implemented
1370
- > yet), or if we're posting to another private project (not implemented yet).
1378
+ > This is useful when the summary is itself `confidential`, or if we're
1379
+ > posting to another private project (not implemented yet).
1371
1380
 
1372
1381
  Example:
1373
1382
 
@@ -1480,7 +1489,10 @@ Accepts a hash of fields.
1480
1489
  | `title` | string | The title of the generated issue | yes | yes | yes | |
1481
1490
  | `destination` | integer or string | The project ID or path to create the generated issue in | no | no | no | source project |
1482
1491
  | `description` | string | The description of the generated issue | no | yes | yes | |
1492
+ | `labels` | array of strings | Labels to add to the generated issue. An element may interpolate to a comma-separated list, which is expanded into one label per value | no | yes | yes | |
1493
+ | `assignees` | array of strings | Usernames to assign to the generated issue. An element may interpolate to a comma-separated list, which is expanded into one assignee per value | no | yes | yes | |
1483
1494
  | `redact_confidential_resources` | boolean | Whether redact fields for confidential resources | no | no | no | true |
1495
+ | `confidential` | boolean or string | Create the issue confidential. A string is interpolated and counts as true when it evaluates to `true` | no | no | yes | false |
1484
1496
 
1485
1497
  The placeholders available in `title` and `destination` are the properties of the resource being used to generate the issue.
1486
1498
 
@@ -23,6 +23,7 @@ module Gitlab
23
23
  puts "* Assignees: #{issue.assignee_usernames.join(', ')}" if issue.assignee_usernames&.any?
24
24
  puts "* Due date: #{issue.due_date}" if issue.due_date
25
25
  puts "* Milestone: #{issue.milestone_title}" if issue.milestone_title
26
+ puts "* Confidential: true" if issue.confidential?
26
27
  puts "* Description: #{issue.description}"
27
28
  puts ">>>"
28
29
  end
@@ -40,6 +41,9 @@ module Gitlab
40
41
  ensure_labels_exist!(issue.labels, destination_id)
41
42
  resolve_assignee_ids(issue.assignee_usernames) if issue.assignee_usernames.any?
42
43
  resolve_milestone_id(destination_id, issue.milestone_title) if issue.milestone_title
44
+ # Evaluated here too, so a bad confidential: template raises before
45
+ # any action writes rather than mid-way through post_issue_body.
46
+ issue.confidential?
43
47
  end
44
48
  end
45
49
 
@@ -83,6 +87,7 @@ module Gitlab
83
87
  body[:assignee_ids] = resolve_assignee_ids(issue.assignee_usernames) if issue.assignee_usernames.any?
84
88
  body[:due_date] = issue.due_date if issue.due_date
85
89
  body[:milestone_id] = resolve_milestone_id(destination_id, issue.milestone_title) if issue.milestone_title
90
+ body[:confidential] = true if issue.confidential?
86
91
 
87
92
  body
88
93
  end
@@ -31,6 +31,7 @@ module Gitlab
31
31
  puts "* Assignees: #{issue.assignee_usernames.join(', ')}" if issue.assignee_usernames&.any?
32
32
  puts "* Due date: #{issue.due_date}" if issue.due_date
33
33
  puts "* Milestone: #{issue.milestone_title}" if issue.milestone_title
34
+ puts "* Confidential: true" if issue.confidential?
34
35
  puts "* Description: #{issue.description}"
35
36
  puts ">>>"
36
37
  end
@@ -43,6 +44,9 @@ module Gitlab
43
44
  ensure_labels_exist!(issue.labels, destination)
44
45
  resolve_assignee_ids(issue.assignee_usernames) if issue.assignee_usernames.any?
45
46
  resolve_milestone_id(destination, issue.milestone_title) if issue.milestone_title
47
+ # Evaluated here too, so a bad confidential: template raises before
48
+ # any action writes rather than mid-way through post_issue_body.
49
+ issue.confidential?
46
50
  end
47
51
 
48
52
  def act
@@ -97,6 +101,7 @@ module Gitlab
97
101
  body[:assignee_ids] = resolve_assignee_ids(issue.assignee_usernames) if issue.assignee_usernames.any?
98
102
  body[:due_date] = issue.due_date if issue.due_date
99
103
  body[:milestone_id] = resolve_milestone_id(destination, issue.milestone_title) if issue.milestone_title
104
+ body[:confidential] = true if issue.confidential?
100
105
 
101
106
  body
102
107
  end
@@ -314,10 +314,13 @@ module Gitlab
314
314
  "Supported: #{VALID_STATUS_VALUES.join(', ')}."
315
315
  end
316
316
 
317
- # An entry may interpolate to an empty string, so a policy can make a
318
- # label conditional on the resource.
317
+ # An entry may interpolate to a comma-separated list, or to nothing so a
318
+ # policy can make a label conditional on the resource.
319
319
  def eval_labels(labels, resource)
320
- Array(labels).map { |label| eval_value(label, resource) }.reject(&:empty?)
320
+ Array(labels)
321
+ .flat_map { |label| eval_value(label, resource).split(',') }
322
+ .map(&:strip)
323
+ .reject(&:empty?)
321
324
  end
322
325
 
323
326
  def join_labels(labels)
@@ -393,6 +396,7 @@ module Gitlab
393
396
  verify_state!(resource, response) if actions[:status]
394
397
  verify_labels_applied!(resource, response) if actions[:labels]
395
398
  verify_assignees_applied!(resource, response) if actions[:assignees]
399
+ verify_reviewers_applied!(resource, response) if actions[:reviewers]
396
400
  end
397
401
 
398
402
  def verify_labels_applied!(resource, response)
@@ -409,7 +413,7 @@ module Gitlab
409
413
  def verify_assignees_applied!(resource, response)
410
414
  return if response[:assignees].nil?
411
415
 
412
- missing = eval_usernames(actions[:assignees], resource) - response[:assignees].map { |user| user.fetch(:username) }
416
+ missing = missing_usernames(actions[:assignees], resource, response[:assignees])
413
417
  return if missing.empty?
414
418
 
415
419
  raise UpdateNotAppliedError,
@@ -417,6 +421,24 @@ module Gitlab
417
421
  "the token may lack permission."
418
422
  end
419
423
 
424
+ def verify_reviewers_applied!(resource, response)
425
+ return if response[:reviewers].nil?
426
+
427
+ missing = missing_usernames(actions[:reviewers], resource, response[:reviewers])
428
+ return if missing.empty?
429
+
430
+ raise UpdateNotAppliedError,
431
+ "Reviewers #{missing.join(', ')} were not requested on #{resource[:web_url]}; " \
432
+ "the token may lack permission, or the users may not have access to it."
433
+ end
434
+
435
+ # The users lookup ignores case, so the response can spell a username
436
+ # differently from the rule.
437
+ def missing_usernames(requested, resource, returned_users)
438
+ returned = returned_users.map { |user| user.fetch(:username).downcase }
439
+ eval_usernames(requested, resource).reject { |username| returned.include?(username.downcase) }
440
+ end
441
+
420
442
  def verify_state!(resource, response)
421
443
  expected = actions[:status] == 'close' ? 'closed' : 'opened'
422
444
  actual = response[:state]
@@ -240,6 +240,10 @@ module Gitlab
240
240
  rule.fetch(:conditions) { {} }
241
241
  end
242
242
 
243
+ def rule_actions(rule)
244
+ rule.fetch(:actions) { {} }
245
+ end
246
+
243
247
  def rule_limits(rule)
244
248
  rule.fetch(:limits) { {} }
245
249
  end
@@ -456,7 +460,7 @@ module Gitlab
456
460
  graphql_query_options[:iids] = expanded_conditions[:iids]
457
461
  end
458
462
 
459
- graphql_query = build_graphql_query(resource_type, expanded_conditions, true)
463
+ graphql_query = build_graphql_query(resource_type, expanded_conditions, rule_definition, true)
460
464
 
461
465
  resources = graphql_network.query(graphql_query, **graphql_query_options)
462
466
  else
@@ -482,7 +486,7 @@ module Gitlab
482
486
  iids = resources.pluck('iid').map(&:to_s)
483
487
  expanded_conditions[:iids] = iids
484
488
 
485
- graphql_query = build_graphql_query(resource_type, expanded_conditions)
489
+ graphql_query = build_graphql_query(resource_type, expanded_conditions, rule_definition)
486
490
  graphql_resources = graphql_network.query(graphql_query, source: source_full_path, iids: iids) if graphql_query.any?
487
491
 
488
492
  decorate_resources_with_graphql_data(resources, graphql_resources)
@@ -879,9 +883,14 @@ module Gitlab
879
883
  status_by_iid
880
884
  end
881
885
 
882
- def build_graphql_query(resource_type, conditions, graphql_only = false)
886
+ def build_graphql_query(resource_type, conditions, rule_definition, graphql_only = false)
883
887
  Gitlab::Triage::GraphqlQueries::QueryBuilder
884
- .new(options.source, resource_type, conditions, graphql_only: graphql_only)
888
+ .new(
889
+ options.source,
890
+ resource_type,
891
+ conditions,
892
+ graphql_only: graphql_only,
893
+ actions: rule_actions(rule_definition))
885
894
  end
886
895
 
887
896
  def source_full_path
@@ -20,14 +20,26 @@ module Gitlab
20
20
  evaluate_present(@milestone_title)
21
21
  end
22
22
 
23
+ def confidential?
24
+ return @confidential if @confidential == true || @confidential == false
25
+ return false unless @confidential
26
+
27
+ evaluate(@confidential.to_s).strip == 'true'
28
+ end
29
+
23
30
  private
24
31
 
25
- # Returns the entries the policy author wrote with their templates
26
- # interpolated, dropping any that came out empty. The caller treats an
27
- # empty result as "the policy named nothing here", so a conditional entry
28
- # leaves the field off the request rather than clearing it.
32
+ # Returns one value per label or username. An entry may interpolate to
33
+ # a comma-separated list so a policy can compute a list whose length
34
+ # varies, and an entry that comes out empty is dropped. The caller
35
+ # treats an empty result as "the policy named nothing here", so a
36
+ # conditional entry leaves the field off the request rather than
37
+ # clearing it.
29
38
  def evaluate_entries(templates)
30
- Array(templates).map { |template| evaluate(template) }.reject(&:empty?)
39
+ Array(templates)
40
+ .flat_map { |template| evaluate(template).split(',') }
41
+ .map(&:strip)
42
+ .reject(&:empty?)
31
43
  end
32
44
 
33
45
  def init_issue_fields(action)
@@ -35,6 +47,7 @@ module Gitlab
35
47
  @assignee_usernames = action[:assignees]
36
48
  @due_date = action[:due_date]
37
49
  @milestone_title = action[:milestone]
50
+ @confidential = action[:confidential]
38
51
  end
39
52
 
40
53
  # An empty string is truthy, so a template that interpolates to nothing
@@ -58,7 +58,7 @@ module Gitlab
58
58
  private
59
59
 
60
60
  def evaluate(template)
61
- build_text(title_resource, template)
61
+ build_text(title_resource.merge(items: items), template)
62
62
  end
63
63
 
64
64
  def title_present?
@@ -71,24 +71,31 @@ module Gitlab
71
71
 
72
72
  private
73
73
 
74
+ # Builds a resource from one GraphQL node, holding only the fields the response
75
+ # actually carried. The query asks for a field only when the rule's conditions
76
+ # or actions need it, and the caller merges this over the REST resource, so a
77
+ # key written for a field nobody asked for would wipe what REST returned.
74
78
  def normalize(resource)
75
79
  resource
76
80
  .slice(:iid, :title, :state, :author, :merged_at, :user_notes_count, :user_discussions_count, :upvotes, :downvotes, :project_id, :web_url)
77
- .merge(
78
- id: extract_id_from_global_id(resource[:id]),
79
- labels: [*resource.dig(:labels, :nodes)].pluck(:title)
80
- )
81
- .merge(normalize_assignees(resource))
81
+ .merge(id: extract_id_from_global_id(resource[:id]))
82
+ .merge(normalize_labels(resource))
83
+ .merge(normalize_users(resource, :assignees))
84
+ .merge(normalize_users(resource, :reviewers))
85
+ end
86
+
87
+ def normalize_labels(resource)
88
+ nodes = resource.dig(:labels, :nodes)
89
+ return {} unless nodes
90
+
91
+ { labels: nodes.pluck(:title) }
82
92
  end
83
93
 
84
- # The assignees are only queried for an assignee_member condition, and the
85
- # caller merges what comes back over the REST resource. An empty list here
86
- # would wipe the assignees REST already returned.
87
- def normalize_assignees(resource)
88
- nodes = resource.dig(:assignees, :nodes)
94
+ def normalize_users(resource, key)
95
+ nodes = resource.dig(key, :nodes)
89
96
  return {} unless nodes
90
97
 
91
- { assignees: nodes.map { |node| node.merge(id: extract_id_from_global_id(node[:id])) } }
98
+ { key => nodes.map { |node| node.merge(id: extract_id_from_global_id(node[:id])) } }
92
99
  end
93
100
 
94
101
  def extract_id_from_global_id(global_id)
@@ -25,10 +25,17 @@ module Gitlab
25
25
  }
26
26
  GRAPHQL
27
27
 
28
- def initialize(source_type, resource_type, conditions, graphql_only: false)
28
+ # Neither user field exists on every type in GitLab's GraphQL schema: Epic
29
+ # carries no assignees, and reviewers belong to MergeRequest alone.
30
+ # Selecting a field the type lacks makes the whole query fail to parse.
31
+ ASSIGNEES_RESOURCE_TYPES = %w[issues merge_requests].freeze
32
+ REVIEWERS_RESOURCE_TYPES = %w[merge_requests].freeze
33
+
34
+ def initialize(source_type, resource_type, conditions, graphql_only: false, actions: {})
29
35
  @source_type = source_type.to_s.singularize
30
36
  @resource_type = resource_type
31
37
  @conditions = conditions
38
+ @actions = actions || {}
32
39
  @graphql_only = graphql_only
33
40
  @resource_declarations = [
34
41
  '$source: ID!',
@@ -60,7 +67,7 @@ module Gitlab
60
67
 
61
68
  private
62
69
 
63
- attr_reader :source_type, :resource_type, :conditions, :graphql_only, :resource_declarations
70
+ attr_reader :source_type, :resource_type, :conditions, :actions, :graphql_only, :resource_declarations
64
71
 
65
72
  def vote_attribute
66
73
  @vote_attribute ||= (conditions.dig(:votes, :attribute) || conditions.dig(:upvotes, :attribute)).to_s
@@ -75,7 +82,8 @@ module Gitlab
75
82
  if graphql_only
76
83
  fields << 'labels { nodes { title } }'
77
84
  fields << 'author { id name username }'
78
- fields << 'assignees { nodes { id name username } }' if conditions.key?(:assignee_member)
85
+ fields << 'assignees { nodes { id name username } }' if query_assignees?
86
+ fields << 'reviewers { nodes { id name username } }' if query_reviewers?
79
87
  fields << 'upvotes' if vote_attribute == 'upvotes'
80
88
  fields << 'downvotes' if vote_attribute == 'downvotes'
81
89
  fields.push('draft', 'mergedAt') if resource_type == 'merge_requests'
@@ -84,6 +92,41 @@ module Gitlab
84
92
  fields
85
93
  end
86
94
 
95
+ def query_assignees?
96
+ return false unless ASSIGNEES_RESOURCE_TYPES.include?(resource_type)
97
+
98
+ conditions.key?(:assignee_member) || actions_need?(:assignees)
99
+ end
100
+
101
+ def query_reviewers?
102
+ return false unless REVIEWERS_RESOURCE_TYPES.include?(resource_type)
103
+
104
+ actions_need?(:reviewers)
105
+ end
106
+
107
+ # An action needs a user field when it removes from that field, or when its
108
+ # text carries the field's placeholder. Either way the users have to be on
109
+ # the resource before the action runs, so the rule's actions decide these
110
+ # fields as much as its conditions do. The placeholder test matches the one
111
+ # in TextContentBuilder#replace_placeholders, so a policy that reaches for
112
+ # the users through Ruby interpolation instead is not covered.
113
+ def actions_need?(field)
114
+ actions[:"remove_#{field}"].present? || action_text.include?("{{#{field}}}")
115
+ end
116
+
117
+ def action_text
118
+ @action_text ||= collect_strings(actions).join("\n")
119
+ end
120
+
121
+ def collect_strings(value)
122
+ case value
123
+ when String then [value]
124
+ when Hash then value.values.flat_map { |nested| collect_strings(nested) }
125
+ when Array then value.flat_map { |nested| collect_strings(nested) }
126
+ else []
127
+ end
128
+ end
129
+
87
130
  def resource_query
88
131
  condition_queries = []
89
132
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module Triage
5
- VERSION = '1.54.0'
5
+ VERSION = '1.55.1'
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.54.0
4
+ version: 1.55.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-24 00:00:00.000000000 Z
11
+ date: 2026-09-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport