gitlab-triage 1.53.1 → 1.55.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/Gemfile.lock +4 -3
  4. data/README.md +173 -5
  5. data/lib/gitlab/triage/action/base.rb +62 -0
  6. data/lib/gitlab/triage/action/comment.rb +1 -9
  7. data/lib/gitlab/triage/action/comment_on_summary.rb +0 -6
  8. data/lib/gitlab/triage/action/issue.rb +36 -3
  9. data/lib/gitlab/triage/action/summarize.rb +26 -1
  10. data/lib/gitlab/triage/action/update.rb +454 -0
  11. data/lib/gitlab/triage/action/work_item.rb +0 -27
  12. data/lib/gitlab/triage/action.rb +18 -2
  13. data/lib/gitlab/triage/command_builders/cc_command_builder.rb +0 -4
  14. data/lib/gitlab/triage/engine.rb +13 -4
  15. data/lib/gitlab/triage/entity_builders/issue_builder.rb +8 -0
  16. data/lib/gitlab/triage/entity_builders/issue_fields.rb +65 -0
  17. data/lib/gitlab/triage/entity_builders/summary_builder.rb +8 -0
  18. data/lib/gitlab/triage/graphql_network.rb +22 -5
  19. data/lib/gitlab/triage/graphql_queries/query_builder.rb +46 -3
  20. data/lib/gitlab/triage/network.rb +4 -0
  21. data/lib/gitlab/triage/network_adapters/httparty_adapter.rb +24 -0
  22. data/lib/gitlab/triage/network_adapters/test_adapter.rb +8 -0
  23. data/lib/gitlab/triage/policies/base_policy.rb +10 -2
  24. data/lib/gitlab/triage/policies/rule_policy.rb +18 -8
  25. data/lib/gitlab/triage/policies/summary_policy.rb +9 -0
  26. data/lib/gitlab/triage/rest_api_network.rb +22 -0
  27. data/lib/gitlab/triage/validators/policy_validator.rb +1 -3
  28. data/lib/gitlab/triage/version.rb +1 -1
  29. metadata +4 -6
  30. data/lib/gitlab/triage/command_builders/label_command_builder.rb +0 -42
  31. data/lib/gitlab/triage/command_builders/move_command_builder.rb +0 -21
  32. data/lib/gitlab/triage/command_builders/remove_label_command_builder.rb +0 -17
  33. data/lib/gitlab/triage/command_builders/status_command_builder.rb +0 -25
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../command_builders/text_content_builder'
4
+ require_relative 'issue_fields'
4
5
 
5
6
  module Gitlab
6
7
  module Triage
7
8
  module EntityBuilders
8
9
  class SummaryBuilder
10
+ include IssueFields
11
+
9
12
  def initialize(
10
13
  type:, action:, resources:, network:,
11
14
  policy_spec: {}, separator: "\n")
@@ -21,6 +24,7 @@ module Gitlab
21
24
  @network = network
22
25
  @separator = separator
23
26
  @is_custom = policy_spec[:type] == 'custom'
27
+ init_issue_fields(action)
24
28
  end
25
29
 
26
30
  def title
@@ -53,6 +57,10 @@ module Gitlab
53
57
 
54
58
  private
55
59
 
60
+ def evaluate(template)
61
+ build_text(title_resource, template)
62
+ end
63
+
56
64
  def title_present?
57
65
  /\S+/.match?(title)
58
66
  end
@@ -71,14 +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
- assignees: [*resource.dig(:assignees, :nodes)]
81
- )
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) }
92
+ end
93
+
94
+ def normalize_users(resource, key)
95
+ nodes = resource.dig(key, :nodes)
96
+ return {} unless nodes
97
+
98
+ { key => nodes.map { |node| node.merge(id: extract_id_from_global_id(node[:id])) } }
82
99
  end
83
100
 
84
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
 
@@ -41,6 +41,10 @@ module Gitlab
41
41
  restapi.patch_api(...)
42
42
  end
43
43
 
44
+ def put_api(...)
45
+ restapi.put_api(...)
46
+ end
47
+
44
48
  def delete_api(...)
45
49
  restapi.delete_api(...)
46
50
  end
@@ -86,6 +86,30 @@ module Gitlab
86
86
  }
87
87
  end
88
88
 
89
+ def put(token, url, body)
90
+ response = HTTParty.put(
91
+ url,
92
+ body: body.to_json,
93
+ headers: {
94
+ 'User-Agent' => USER_AGENT,
95
+ 'Content-type' => 'application/json',
96
+ 'PRIVATE-TOKEN' => token
97
+ }
98
+ )
99
+
100
+ raise_on_unauthorized_error!(response)
101
+ raise_on_internal_server_error!(response)
102
+ raise_on_too_many_requests!(response)
103
+
104
+ tracker&.record('PUT', url)
105
+
106
+ {
107
+ results: response.parsed_response,
108
+ ratelimit_remaining: response.headers['ratelimit-remaining'].to_i,
109
+ ratelimit_reset_at: Time.at(response.headers['ratelimit-reset'].to_i)
110
+ }
111
+ end
112
+
89
113
  def delete(token, url)
90
114
  response = HTTParty.delete(
91
115
  url,
@@ -35,6 +35,14 @@ module Gitlab
35
35
  ratelimit_reset_at: Time.now
36
36
  }
37
37
  end
38
+
39
+ def put(_token, _url, _body)
40
+ {
41
+ results: {},
42
+ ratelimit_remaining: 600,
43
+ ratelimit_reset_at: Time.now
44
+ }
45
+ end
38
46
  end
39
47
  end
40
48
  end
@@ -6,6 +6,11 @@ module Gitlab
6
6
  class BasePolicy
7
7
  InvalidPolicyError = Class.new(StandardError)
8
8
 
9
+ UPDATE_ACTION_KEYS = %i[
10
+ labels remove_labels assignees reviewers remove_assignees remove_reviewers
11
+ milestone due_date weight health_status title status discussion_locked move
12
+ ].freeze
13
+
9
14
  attr_reader :type, :policy_spec, :resources, :network
10
15
  attr_accessor :summary
11
16
 
@@ -55,8 +60,11 @@ module Gitlab
55
60
  end
56
61
 
57
62
  def comment?
58
- # The actual keys are strings
59
- (actions.keys.map(&:to_sym) - [:summarize, :comment_on_summary, :delete, :issue, :work_item_status]).any?
63
+ actions.key?(:comment) || actions.key?(:mention)
64
+ end
65
+
66
+ def update?
67
+ (actions.keys.map(&:to_sym) & UPDATE_ACTION_KEYS).any?
60
68
  end
61
69
 
62
70
  def issue?
@@ -9,27 +9,37 @@ module Gitlab
9
9
  module Policies
10
10
  class RulePolicy < BasePolicy
11
11
  # Build a summary from a single rule policy
12
+ #
13
+ # The builder is kept for the life of the policy. Every action that asks
14
+ # for it gets the same one, so the title and description templates are
15
+ # evaluated once for the run rather than once per action.
12
16
  def build_summary
13
- action = actions.fetch(:summarize, {})
14
-
15
- EntityBuilders::SummaryBuilder.new(
17
+ @build_summary ||= EntityBuilders::SummaryBuilder.new(
16
18
  type: type,
17
19
  policy_spec: policy_spec,
18
- action: action,
20
+ action: actions.fetch(:summarize, {}),
19
21
  resources: resources,
20
22
  network: network)
21
23
  end
22
24
 
25
+ # The builder for one resource, kept for the life of the policy the same
26
+ # way build_summary is. Keyed on the resource itself, which the policy
27
+ # holds for the whole run, so two actions handed the same resource share
28
+ # one builder.
23
29
  def build_issue(resource)
24
- action = actions.fetch(:issue, {})
25
-
26
- EntityBuilders::IssueBuilder.new(
30
+ issue_builders[resource] ||= EntityBuilders::IssueBuilder.new(
27
31
  type: type,
28
32
  policy_spec: policy_spec,
29
- action: action,
33
+ action: actions.fetch(:issue, {}),
30
34
  resource: resource,
31
35
  network: network)
32
36
  end
37
+
38
+ private
39
+
40
+ def issue_builders
41
+ @issue_builders ||= {}.compare_by_identity
42
+ end
33
43
  end
34
44
  end
35
45
  end
@@ -8,7 +8,16 @@ module Gitlab
8
8
  module Policies
9
9
  class SummaryPolicy < BasePolicy
10
10
  # Build a summary from several rules policies
11
+ #
12
+ # Kept for the life of the policy, like the single-rule version, so the
13
+ # child summaries are built once for the run.
11
14
  def build_summary
15
+ @build_summary ||= build_combined_summary
16
+ end
17
+
18
+ private
19
+
20
+ def build_combined_summary
12
21
  action = actions[:summarize]
13
22
  child_summaries = resources.map do |inner_policy_spec, inner_resources|
14
23
  Policies::RulePolicy.new(
@@ -112,6 +112,28 @@ module Gitlab
112
112
  end
113
113
  end
114
114
 
115
+ def put_api(url, body)
116
+ response = execute_with_retry(
117
+ exception_types: [Net::ReadTimeout, Errors::Network::InternalServerError],
118
+ backoff_exceptions: Errors::Network::TooManyRequests, debug: options.debug) do
119
+ puts Gitlab::Triage::UI.debug "put_api: #{url}" if options.debug
120
+
121
+ @adapter.put(token, url, body)
122
+ end
123
+
124
+ rate_limit_debug(response) if options.debug
125
+ rate_limit_wait(response)
126
+
127
+ results = response.delete(:results)
128
+
129
+ case results
130
+ when Hash
131
+ results.with_indifferent_access
132
+ else
133
+ raise_unexpected_response(results)
134
+ end
135
+ end
136
+
115
137
  def delete_api(url)
116
138
  response = execute_with_retry(
117
139
  exception_types: [Net::ReadTimeout, Errors::Network::InternalServerError],
@@ -17,9 +17,7 @@ module Gitlab
17
17
  'Use the issues policy type for comment actions.'
18
18
 
19
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.
20
+ # Anything outside this list would silently do nothing, so reject it up front.
23
21
  SUPPORTED_WORK_ITEM_ACTIONS = %i[labels assignees summarize comment_on_summary].freeze
24
22
 
25
23
  def initialize(resource_rules)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module Triage
5
- VERSION = '1.53.1'
5
+ VERSION = '1.55.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.53.1
4
+ version: 1.55.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-09-02 00:00:00.000000000 Z
11
+ date: 2026-09-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -245,6 +245,7 @@ files:
245
245
  - lib/gitlab/triage/action/delete.rb
246
246
  - lib/gitlab/triage/action/issue.rb
247
247
  - lib/gitlab/triage/action/summarize.rb
248
+ - lib/gitlab/triage/action/update.rb
248
249
  - lib/gitlab/triage/action/work_item.rb
249
250
  - lib/gitlab/triage/action/work_item_status.rb
250
251
  - lib/gitlab/triage/api_observability.rb
@@ -255,13 +256,10 @@ files:
255
256
  - lib/gitlab/triage/command_builders/base_command_builder.rb
256
257
  - lib/gitlab/triage/command_builders/cc_command_builder.rb
257
258
  - lib/gitlab/triage/command_builders/comment_command_builder.rb
258
- - lib/gitlab/triage/command_builders/label_command_builder.rb
259
- - lib/gitlab/triage/command_builders/move_command_builder.rb
260
- - lib/gitlab/triage/command_builders/remove_label_command_builder.rb
261
- - lib/gitlab/triage/command_builders/status_command_builder.rb
262
259
  - lib/gitlab/triage/command_builders/text_content_builder.rb
263
260
  - lib/gitlab/triage/engine.rb
264
261
  - lib/gitlab/triage/entity_builders/issue_builder.rb
262
+ - lib/gitlab/triage/entity_builders/issue_fields.rb
265
263
  - lib/gitlab/triage/entity_builders/summary_builder.rb
266
264
  - lib/gitlab/triage/errors.rb
267
265
  - lib/gitlab/triage/errors/network.rb
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'base_command_builder'
4
-
5
- module Gitlab
6
- module Triage
7
- module CommandBuilders
8
- class LabelCommandBuilder < BaseCommandBuilder
9
- def build_command
10
- ensure_labels_exist!
11
-
12
- super
13
- end
14
-
15
- private
16
-
17
- def ensure_labels_exist!
18
- items.each do |label|
19
- source_id_key = resource.key?(:group_id) ? :group_id : :project_id
20
- label_opts = {
21
- source_id_key => resource[source_id_key],
22
- name: label
23
- }
24
-
25
- unless Resource::Label.new(label_opts, network: network).exist?
26
- raise Resource::Label::LabelDoesntExistError,
27
- "Label `#{label}` doesn't exist!"
28
- end
29
- end
30
- end
31
-
32
- def slash_command_string
33
- "/label"
34
- end
35
-
36
- def format_item(item)
37
- "~\"#{item}\""
38
- end
39
- end
40
- end
41
- end
42
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'base_command_builder'
4
-
5
- module Gitlab
6
- module Triage
7
- module CommandBuilders
8
- class MoveCommandBuilder < BaseCommandBuilder
9
- private
10
-
11
- def slash_command_string
12
- "/move"
13
- end
14
-
15
- def format_item(item)
16
- item
17
- end
18
- end
19
- end
20
- end
21
- end
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'base_command_builder'
4
-
5
- module Gitlab
6
- module Triage
7
- module CommandBuilders
8
- class RemoveLabelCommandBuilder < LabelCommandBuilder
9
- private
10
-
11
- def slash_command_string
12
- '/unlabel'
13
- end
14
- end
15
- end
16
- end
17
- end
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'base_command_builder'
4
-
5
- module Gitlab
6
- module Triage
7
- module CommandBuilders
8
- class StatusCommandBuilder < BaseCommandBuilder
9
- private
10
-
11
- def separator
12
- ''
13
- end
14
-
15
- def slash_command_string
16
- "/"
17
- end
18
-
19
- def format_item(item)
20
- item
21
- end
22
- end
23
- end
24
- end
25
- end