gitlab-triage 1.50.0 → 1.51.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: '066092d8567b3b76494b99f0c244c565d1c9b65938082e741fe955742c89e88e'
4
- data.tar.gz: 3fc1d9bf0c94a47b6d6a39002014bb60fe52ac93624cb50f6a06cc2f73d11528
3
+ metadata.gz: 8153f61407dcda901bf6dbdeb8390a72e2af03e4562c723b770b0470951af96e
4
+ data.tar.gz: 97d626248297908df831d00ef897ff796db3262f42c4495f50dcc88ccb27a471
5
5
  SHA512:
6
- metadata.gz: cd1f5e1088ec9cbe4e21ed6c968ce47489be3d83c95fffd07fd8e59a8eb92240ed44172f09f34390901dd43241a70320e859b5350b1afd20007550736d4bad75
7
- data.tar.gz: ef7c028266e48926aba06c4fc908798eb7b9b60d3a16cfe7fe05a6134e5756374419961f52baf6c41e12a38f61b5a9c66f11c7e771b725d640619bc13db9bf04
6
+ metadata.gz: 39bc408dfa35b181e095c642ecf13c99d64acf1d0d0d7bc2bffd9d2a0635ade7c11e9089172375474eedc984a43aca507bd630aeeadbf363300d81ce528f1ad2
7
+ data.tar.gz: 8afe7f1cee9e5739d960e41e9bf1049e50f56aa6ab34010a65fd50ba52dc84211164b197875191d0efbd1e9557c758c429711243867f99bdfc6fc1ee0f1368d5
data/.rubocop_todo.yml CHANGED
@@ -1,12 +1,12 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config --exclude-limit 10000`
3
- # on 2024-06-13 12:56:53 UTC using RuboCop version 1.62.1.
3
+ # on 2026-06-12 14:43:35 UTC using RuboCop version 1.62.1.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
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: 67
9
+ # Offense count: 78
10
10
  CodeReuse/ActiveRecord:
11
11
  Exclude:
12
12
  - 'lib/gitlab/triage/engine.rb'
@@ -25,17 +25,6 @@ CodeReuse/ActiveRecord:
25
25
  - 'spec/support/shared_examples/label_command_shared_examples.rb'
26
26
  - 'spec/support/stub_api.rb'
27
27
 
28
- # Offense count: 4
29
- # This cop supports unsafe autocorrection (--autocorrect-all).
30
- # Configuration parameters: Categories, ExpectedOrder.
31
- # ExpectedOrder: module_inclusion, constants, public_class_methods, initializer, public_methods, protected_methods, private_methods
32
- Layout/ClassStructure:
33
- Exclude:
34
- - 'lib/gitlab/triage/filters/base_conditions_filter.rb'
35
- - 'lib/gitlab/triage/filters/member_conditions_filter.rb'
36
- - 'lib/gitlab/triage/graphql_queries/query_builder.rb'
37
- - 'lib/gitlab/triage/limiters/base_limiter.rb'
38
-
39
28
  # Offense count: 1
40
29
  Lint/ToEnumArguments:
41
30
  Exclude:
@@ -49,12 +38,12 @@ Performance/MethodObjectAsBlock:
49
38
  - 'lib/gitlab/triage/expand_condition/expansion.rb'
50
39
  - 'lib/gitlab/triage/limiters/date_field_limiter.rb'
51
40
 
52
- # Offense count: 167
41
+ # Offense count: 175
53
42
  # Configuration parameters: AllowSubject.
54
43
  RSpec/MultipleMemoizedHelpers:
55
44
  Max: 10
56
45
 
57
- # Offense count: 305
46
+ # Offense count: 331
58
47
  # Configuration parameters: EnforcedStyle, IgnoreSharedExamples.
59
48
  # SupportedStyles: always, named_only
60
49
  RSpec/NamedSubject:
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- gitlab-triage (1.50.0)
4
+ gitlab-triage (1.51.0)
5
5
  activesupport (>= 5.1)
6
6
  globalid (~> 1.0, >= 1.0.1)
7
7
  graphql (< 2.1.0)
data/README.md CHANGED
@@ -722,6 +722,41 @@ conditions:
722
722
  See [Ruby expression API](#ruby-expression-api) for the list of currently
723
723
  available API.
724
724
 
725
+ ##### Work item status condition
726
+
727
+ **Requires GitLab 18.0 or later.** On earlier versions, the GitLab API rejects
728
+ the request and the triage run fails with an error.
729
+
730
+ Filters work items by their [status](https://docs.gitlab.com/user/work_items/status/).
731
+
732
+ Accepts a string or an array of strings, where each string is a status name
733
+ (for example, `To do`, `In progress`, `Done`, `Won't do`, `Duplicate`). When
734
+ an array is given, a work item matches if its status is any of the listed
735
+ values. Matching is case-insensitive.
736
+
737
+ When this condition is present, matching statuses are pre-filtered server-side
738
+ before the main fetch, so only matching work items are loaded.
739
+
740
+ This condition is distinct from the [`status` action](#status-action), which
741
+ changes the open or closed state of a work item. Work item status is a separate
742
+ lifecycle attribute.
743
+
744
+ Example:
745
+
746
+ ```yml
747
+ conditions:
748
+ work_item_status: In progress
749
+ ```
750
+
751
+ Filtering by more than one status:
752
+
753
+ ```yml
754
+ conditions:
755
+ work_item_status:
756
+ - To do
757
+ - In progress
758
+ ```
759
+
725
760
  #### Limits field
726
761
 
727
762
  Limits restrict the number of resources on which an action is carried out. They
@@ -767,6 +802,7 @@ Available action types:
767
802
  - [`labels` action](#labels-action)
768
803
  - [`remove_labels` action](#remove-labels-action)
769
804
  - [`status` action](#status-action)
805
+ - [`work_item_status` action](#work-item-status-action)
770
806
  - [`mention` action](#mention-action)
771
807
  - [`move` action](#move-action)
772
808
  - [`comment` action](#comment-action)
@@ -817,7 +853,10 @@ actions:
817
853
 
818
854
  ##### Status action
819
855
 
820
- Changes the status of the resource.
856
+ Changes the open or closed state of the resource.
857
+
858
+ This sets the open or closed state, not the work item lifecycle status. To
859
+ change work item status, see the [`work_item_status` action](#work-item-status-action).
821
860
 
822
861
  Accepts a string.
823
862
 
@@ -833,6 +872,29 @@ actions:
833
872
  status: close
834
873
  ```
835
874
 
875
+ ##### Work item status action
876
+
877
+ **Requires GitLab 19.1 or later.** On earlier versions, the action emits an
878
+ error and is skipped.
879
+
880
+ Sets the [status](https://docs.gitlab.com/user/work_items/status/) of a work
881
+ item.
882
+
883
+ This is distinct from the [`status` action](#status-action), which changes the
884
+ open or closed state. Work item status is a separate lifecycle attribute.
885
+
886
+ Accepts a string, the name of the target status (for example, `In progress`).
887
+ The name is resolved per work item against its type's own lifecycle, so the
888
+ valid values depend on the type. If the name is not valid for a given work
889
+ item, the automation reports an error.
890
+
891
+ Example:
892
+
893
+ ```yml
894
+ actions:
895
+ work_item_status: In progress
896
+ ```
897
+
836
898
  ##### Mention action
837
899
 
838
900
  Mentions a number of users.
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../resource/instance_version'
5
+
6
+ module Gitlab
7
+ module Triage
8
+ module Action
9
+ class WorkItemStatus < Base
10
+ MINIMUM_VERSION = '19.1'
11
+ class Dry < WorkItemStatus
12
+ def act
13
+ puts "The following work items would have their status updated for the rule **#{policy.name}**:\n\n"
14
+
15
+ super
16
+ end
17
+
18
+ private
19
+
20
+ def perform(resource, status_value)
21
+ puts "# #{resource[:web_url]}"
22
+ puts "Status would be set to: #{status_value}"
23
+ puts "Work Item ID: gid://gitlab/WorkItem/#{resource[:id]}\n\n"
24
+ end
25
+ end
26
+
27
+ def act
28
+ unless policy.type == 'issues'
29
+ puts Gitlab::Triage::UI.warn "Work item statuses are only available for issues. The action will NOT be performed\n\n"
30
+ return
31
+ end
32
+
33
+ unless supported_version?
34
+ puts Gitlab::Triage::UI.error "Setting work item status requires GitLab #{MINIMUM_VERSION} or later; " \
35
+ "this instance is #{instance_version.version_short}. The action will NOT be performed\n\n"
36
+ return
37
+ end
38
+
39
+ status_value = policy.actions[:work_item_status]
40
+ return unless status_value
41
+
42
+ policy.resources.each do |resource|
43
+ perform(resource, status_value)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def supported_version?
50
+ Gem::Version.new(instance_version.version_short) >= Gem::Version.new(MINIMUM_VERSION)
51
+ end
52
+
53
+ def instance_version
54
+ @instance_version ||= Gitlab::Triage::Resource::InstanceVersion.new(network: network)
55
+ end
56
+
57
+ def perform(resource, status_value)
58
+ mutation_query = <<~GRAPHQL
59
+ mutation($input: WorkItemUpdateInput!) {
60
+ workItemUpdate(input: $input) {
61
+ workItem {
62
+ id
63
+ }
64
+ errors
65
+ }
66
+ }
67
+ GRAPHQL
68
+
69
+ variables = {
70
+ input: {
71
+ id: "gid://gitlab/WorkItem/#{resource[:id]}",
72
+ statusWidget: {
73
+ name: status_value
74
+ }
75
+ }
76
+ }
77
+
78
+ if network.options.debug
79
+ if policy.actions.fetch(:redact_confidential_resources, true) && resource[:confidential]
80
+ puts Gitlab::Triage::UI.debug "Work item status action resource: (confidential)"
81
+ else
82
+ puts Gitlab::Triage::UI.debug "Work item status action resource: #{resource.inspect}"
83
+ end
84
+ end
85
+
86
+ response = network.mutate_graphql(mutation_query, variables)
87
+ return puts Gitlab::Triage::UI.error "No response received for work item #{resource[:web_url]}" if response.nil?
88
+
89
+ work_item_update = response[:work_item_update]
90
+ return puts Gitlab::Triage::UI.error "No workItemUpdate data in response for #{resource[:web_url]}" unless work_item_update
91
+
92
+ errors = work_item_update[:errors]
93
+ return unless errors&.any?
94
+
95
+ puts Gitlab::Triage::UI.error "Status update failed for #{resource[:web_url]}: #{errors.join(', ')}"
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -5,6 +5,7 @@ require_relative 'action/comment'
5
5
  require_relative 'action/comment_on_summary'
6
6
  require_relative 'action/issue'
7
7
  require_relative 'action/delete'
8
+ require_relative 'action/work_item_status'
8
9
 
9
10
  module Gitlab
10
11
  module Triage
@@ -17,7 +18,8 @@ module Gitlab
17
18
  [Comment, policy.comment?],
18
19
  [CommentOnSummary, policy.comment_on_summary?],
19
20
  [Issue, policy.issue?],
20
- [Delete, policy.delete?]
21
+ [Delete, policy.delete?],
22
+ [WorkItemStatus, policy.work_item_status?]
21
23
  ].each do |action, active|
22
24
  act(action: action, policy: policy, **args) if active
23
25
  end
@@ -14,6 +14,7 @@ require_relative 'filters/author_member_conditions_filter'
14
14
  require_relative 'filters/assignee_member_conditions_filter'
15
15
  require_relative 'filters/discussions_conditions_filter'
16
16
  require_relative 'filters/ruby_conditions_filter'
17
+ require_relative 'filters/work_item_status_conditions_filter'
17
18
  require_relative 'limiters/date_field_limiter'
18
19
  require_relative 'action'
19
20
  require_relative 'policies/rule_policy'
@@ -53,7 +54,8 @@ module Gitlab
53
54
  no_additional_labels: Filters::NoAdditionalLabelsConditionsFilter,
54
55
  ruby: Filters::RubyConditionsFilter,
55
56
  votes: Filters::VotesConditionsFilter,
56
- upvotes: Filters::VotesConditionsFilter
57
+ upvotes: Filters::VotesConditionsFilter,
58
+ work_item_status: Filters::WorkItemStatusConditionsFilter
57
59
  }.freeze
58
60
 
59
61
  DEFAULT_NETWORK_ADAPTER = Gitlab::Triage::NetworkAdapters::HttpartyAdapter
@@ -377,11 +379,15 @@ module Gitlab
377
379
  end
378
380
 
379
381
  ExpandCondition.perform(rule_conditions(rule_definition)) do |expanded_conditions|
382
+ status_pre_filtered = pre_filter_by_work_item_status!(resource_type, expanded_conditions)
383
+
380
384
  # retrieving the resources for every rule is inefficient
381
385
  # however, previous rules may affect those upcoming
382
386
  resources = options.resources ||
383
387
  fetch_resources(resource_type, expanded_conditions, rule_definition)
384
388
 
389
+ apply_work_item_status!(resource_type, expanded_conditions, rule_definition, resources, status_pre_filtered)
390
+
385
391
  # In some filters/actions we want to know which resource type it is
386
392
  attach_resource_type(resources, resource_type)
387
393
 
@@ -409,6 +415,8 @@ module Gitlab
409
415
  if options.resource_reference
410
416
  expanded_conditions[:iids] = options.resource_reference[1..]
411
417
  graphql_query_options[:iids] = [expanded_conditions[:iids]]
418
+ elsif expanded_conditions[:iids].present?
419
+ graphql_query_options[:iids] = expanded_conditions[:iids]
412
420
  end
413
421
 
414
422
  graphql_query = build_graphql_query(resource_type, expanded_conditions, true)
@@ -418,8 +426,14 @@ module Gitlab
418
426
  # FIXME: Epics listing endpoint doesn't support filtering by `iids`, so instead we
419
427
  # get a single epic when `--resource-reference` is given for epics.
420
428
  # Because of that, the query could return a single epic, so we make sure we get an array.
429
+ pre_filter_iids = expanded_conditions.delete(:iids)
430
+
421
431
  resources = Array(network.query_api(build_get_url(resource_type, expanded_conditions)))
422
432
 
433
+ # When pre-filtered IIDs are present (from work_item_status server-side
434
+ # filtering), narrow the REST results to only those IIDs before decoration.
435
+ resources = resources.select { |r| pre_filter_iids.include?(r['iid'].to_s) } if pre_filter_iids.present?
436
+
423
437
  iids = resources.pluck('iid').map(&:to_s)
424
438
  expanded_conditions[:iids] = iids
425
439
 
@@ -650,6 +664,161 @@ module Gitlab
650
664
  APIQueryBuilders::SingleQueryParamBuilder.new('wip', wip)
651
665
  end
652
666
 
667
+ # When filtering by work_item_status, query workItems with server-side
668
+ # status filtering first to narrow the IID set before the main fetch.
669
+ # This avoids fetching all resources just to discard most of them.
670
+ def pre_filter_by_work_item_status!(resource_type, expanded_conditions)
671
+ return false unless resource_type == 'issues' && expanded_conditions.key?(:work_item_status)
672
+
673
+ pre_filter_iids = fetch_work_item_iids_by_status(expanded_conditions[:work_item_status])
674
+
675
+ expanded_conditions[:iids] =
676
+ if expanded_conditions[:iids].present?
677
+ Array(expanded_conditions[:iids]).map(&:to_s) & pre_filter_iids
678
+ else
679
+ pre_filter_iids
680
+ end
681
+
682
+ true
683
+ end
684
+
685
+ def apply_work_item_status!(resource_type, expanded_conditions, rule_definition, resources, status_pre_filtered)
686
+ return unless resource_type == 'issues' && needs_work_item_status?(expanded_conditions, rule_definition)
687
+
688
+ status_values = Array(expanded_conditions[:work_item_status])
689
+
690
+ if status_pre_filtered && status_values.size == 1
691
+ resources.each { |r| r[:work_item_status] = status_values.first }
692
+ else
693
+ decorate_resources_with_work_item_status(resources)
694
+ end
695
+ end
696
+
697
+ def needs_work_item_status?(conditions, _rule_definition)
698
+ conditions.key?(:work_item_status)
699
+ end
700
+
701
+ # Pre-filter: query workItems with server-side status filtering to get only
702
+ # the IIDs that match, avoiding fetching the full resource set when most
703
+ # items won't pass the status filter.
704
+ def fetch_work_item_iids_by_status(status_condition)
705
+ status_names = Array(status_condition)
706
+ all_iids = []
707
+
708
+ status_names.each do |status_name|
709
+ query = <<~GRAPHQL
710
+ query($source: ID!, $after: String, $statusName: String!) {
711
+ #{options.source.singularize}(fullPath: $source) {
712
+ workItems(status: { name: $statusName }, after: $after, first: 100) {
713
+ pageInfo {
714
+ hasNextPage
715
+ endCursor
716
+ }
717
+ nodes {
718
+ iid
719
+ }
720
+ }
721
+ }
722
+ }
723
+ GRAPHQL
724
+
725
+ after_cursor = nil
726
+
727
+ loop do
728
+ response = graphql_network.adapter.query_raw(
729
+ query,
730
+ resource_path: [options.source.singularize, 'workItems'],
731
+ variables: { source: source_full_path, after: after_cursor, statusName: status_name }
732
+ )
733
+
734
+ work_items = Array.wrap(response[:results])
735
+ work_items.each do |wi|
736
+ iid = wi.is_a?(Hash) ? (wi['iid'] || wi[:iid]) : wi.try(:iid)
737
+ all_iids << iid.to_s if iid
738
+ end
739
+
740
+ break unless response[:more_pages]
741
+
742
+ after_cursor = response[:end_cursor]
743
+ end
744
+ end
745
+
746
+ all_iids.uniq
747
+ end
748
+
749
+ # Status is a work item capability not available on the IssueType GraphQL type
750
+ # or the REST API. We fetch it via a separate workItems query and merge it back
751
+ # by IID, similar to how decorate_resources_with_graphql_data works for other fields.
752
+ # We use widgets (not features) for backward compatibility with GitLab versions
753
+ # before the features field was introduced in 18.9.
754
+ def decorate_resources_with_work_item_status(resources)
755
+ return if resources.empty?
756
+
757
+ iids = resources.filter_map { |r| (r['iid'] || r[:iid])&.to_s }
758
+ return if iids.empty?
759
+
760
+ status_by_iid = fetch_work_item_statuses_by_iid(iids)
761
+
762
+ resources.each do |resource|
763
+ iid = (resource['iid'] || resource[:iid]).to_s
764
+ resource[:work_item_status] = status_by_iid[iid]
765
+ end
766
+ end
767
+
768
+ # The workItems connection caps `first` at 100, so we request the status
769
+ # for the given IIDs in batches and paginate within each batch.
770
+ def fetch_work_item_statuses_by_iid(iids)
771
+ query = <<~GRAPHQL
772
+ query($source: ID!, $iids: [String!], $after: String) {
773
+ #{options.source.singularize}(fullPath: $source) {
774
+ workItems(iids: $iids, after: $after, first: 100) {
775
+ pageInfo {
776
+ hasNextPage
777
+ endCursor
778
+ }
779
+ nodes {
780
+ iid
781
+ widgets(onlyTypes: [STATUS]) {
782
+ ... on WorkItemWidgetStatus {
783
+ status {
784
+ id
785
+ name
786
+ }
787
+ }
788
+ }
789
+ }
790
+ }
791
+ }
792
+ }
793
+ GRAPHQL
794
+
795
+ status_by_iid = {}
796
+
797
+ iids.each_slice(100) do |iids_batch|
798
+ after_cursor = nil
799
+
800
+ loop do
801
+ response = graphql_network.adapter.query_raw(
802
+ query,
803
+ resource_path: [options.source.singularize, 'workItems'],
804
+ variables: { source: source_full_path, iids: iids_batch, after: after_cursor }
805
+ )
806
+
807
+ Array.wrap(response[:results]).each do |wi|
808
+ wi = wi.deep_transform_keys(&:underscore).with_indifferent_access
809
+ status_widget = Array.wrap(wi[:widgets]).find { |w| w[:status].present? }
810
+ status_by_iid[wi[:iid].to_s] = status_widget.dig(:status, :name) if status_widget
811
+ end
812
+
813
+ break unless response[:more_pages]
814
+
815
+ after_cursor = response[:end_cursor]
816
+ end
817
+ end
818
+
819
+ status_by_iid
820
+ end
821
+
653
822
  def build_graphql_query(resource_type, conditions, graphql_only = false)
654
823
  Gitlab::Triage::GraphqlQueries::QueryBuilder
655
824
  .new(options.source, resource_type, conditions, graphql_only: graphql_only)
@@ -7,16 +7,6 @@ module Gitlab
7
7
  module Triage
8
8
  module Filters
9
9
  class BaseConditionsFilter
10
- def initialize(resource, condition)
11
- @resource = resource
12
- validate_condition(condition)
13
- initialize_variables(condition)
14
- end
15
-
16
- def calculate
17
- raise NotImplementedError
18
- end
19
-
20
10
  def self.filter_parameters
21
11
  []
22
12
  end
@@ -45,6 +35,16 @@ module Gitlab
45
35
  end
46
36
  end
47
37
 
38
+ def initialize(resource, condition)
39
+ @resource = resource
40
+ validate_condition(condition)
41
+ initialize_variables(condition)
42
+ end
43
+
44
+ def calculate
45
+ raise NotImplementedError
46
+ end
47
+
48
48
  private
49
49
 
50
50
  def validate_condition(condition)
@@ -10,11 +10,6 @@ module Gitlab
10
10
  SOURCES = %w[project group].freeze
11
11
  CONDITIONS = %w[member_of not_member_of].freeze
12
12
 
13
- def initialize(resource, condition, network = nil)
14
- @network = network
15
- super(resource, condition)
16
- end
17
-
18
13
  def self.filter_parameters
19
14
  [
20
15
  {
@@ -34,6 +29,11 @@ module Gitlab
34
29
  ]
35
30
  end
36
31
 
32
+ def initialize(resource, condition, network = nil)
33
+ @network = network
34
+ super(resource, condition)
35
+ end
36
+
37
37
  def initialize_variables(condition)
38
38
  @source = condition[:source].to_sym
39
39
  @condition = condition[:condition].to_sym
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_conditions_filter'
4
+
5
+ module Gitlab
6
+ module Triage
7
+ module Filters
8
+ class WorkItemStatusConditionsFilter < BaseConditionsFilter
9
+ def initialize_variables(condition)
10
+ @expected_statuses = Array(condition).map(&:downcase)
11
+ end
12
+
13
+ def calculate
14
+ return false unless @resource[:work_item_status]
15
+
16
+ @expected_statuses.include?(@resource[:work_item_status].downcase)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -49,6 +49,26 @@ module Gitlab
49
49
  .map { |resource| normalize(resource) }
50
50
  end
51
51
 
52
+ def mutate(graphql_mutation, variables = {})
53
+ return if graphql_mutation.blank?
54
+
55
+ print '.'
56
+
57
+ parsed_mutation = adapter.parse(graphql_mutation)
58
+
59
+ response = adapter.mutate(
60
+ parsed_mutation,
61
+ variables: variables
62
+ )
63
+
64
+ rate_limit_debug(response) if options.debug
65
+ rate_limit_wait(response)
66
+
67
+ response.delete(:results)
68
+ &.deep_transform_keys(&:underscore)
69
+ &.with_indifferent_access
70
+ end
71
+
52
72
  private
53
73
 
54
74
  def normalize(resource)
@@ -75,8 +95,11 @@ module Gitlab
75
95
  def rate_limit_wait(response)
76
96
  return unless response.delete(:ratelimit_remaining) < MINIMUM_RATE_LIMIT
77
97
 
78
- puts Gitlab::Triage::UI.debug "Rate limit almost exceeded, sleeping for #{response[:ratelimit_reset_at] - Time.now} seconds" if options.debug
79
- sleep(1) until Time.now >= response[:ratelimit_reset_at]
98
+ reset_at = response[:ratelimit_reset_at]
99
+ return unless reset_at
100
+
101
+ puts Gitlab::Triage::UI.debug "Rate limit almost exceeded, sleeping for #{reset_at - Time.now} seconds" if options.debug
102
+ sleep(1) until Time.now >= reset_at
80
103
  end
81
104
  end
82
105
  end
@@ -8,6 +8,23 @@ module Gitlab
8
8
  module Triage
9
9
  module GraphqlQueries
10
10
  class QueryBuilder
11
+ BASE_QUERY = <<~GRAPHQL
12
+ query(%{resource_declarations}) {
13
+ %{source_type}(fullPath: $source) {
14
+ id
15
+ %{resource_type}(after: $after%{resource_query}) {
16
+ pageInfo {
17
+ hasNextPage
18
+ endCursor
19
+ }
20
+ nodes {
21
+ id iid title updatedAt createdAt webUrl projectId %{resource_fields}
22
+ }
23
+ }
24
+ }
25
+ }
26
+ GRAPHQL
27
+
11
28
  def initialize(source_type, resource_type, conditions, graphql_only: false)
12
29
  @source_type = source_type.to_s.singularize
13
30
  @resource_type = resource_type
@@ -45,23 +62,6 @@ module Gitlab
45
62
 
46
63
  attr_reader :source_type, :resource_type, :conditions, :graphql_only, :resource_declarations
47
64
 
48
- BASE_QUERY = <<~GRAPHQL
49
- query(%{resource_declarations}) {
50
- %{source_type}(fullPath: $source) {
51
- id
52
- %{resource_type}(after: $after%{resource_query}) {
53
- pageInfo {
54
- hasNextPage
55
- endCursor
56
- }
57
- nodes {
58
- id iid title updatedAt createdAt webUrl projectId %{resource_fields}
59
- }
60
- }
61
- }
62
- }
63
- GRAPHQL
64
-
65
65
  def vote_attribute
66
66
  @vote_attribute ||= (conditions.dig(:votes, :attribute) || conditions.dig(:upvotes, :attribute)).to_s
67
67
  end
@@ -6,6 +6,10 @@ module Gitlab
6
6
  module Triage
7
7
  module Limiters
8
8
  class BaseLimiter
9
+ def self.limiter_parameters
10
+ []
11
+ end
12
+
9
13
  def initialize(resources, limit)
10
14
  @resources = initialize_resources(resources)
11
15
  validate_limit(limit)
@@ -16,10 +20,6 @@ module Gitlab
16
20
  raise NotImplementedError
17
21
  end
18
22
 
19
- def self.limiter_parameters
20
- []
21
- end
22
-
23
23
  private
24
24
 
25
25
  def initialize_variables(limit); end
@@ -14,6 +14,10 @@ module Gitlab
14
14
  graphql.query(...)
15
15
  end
16
16
 
17
+ def mutate_graphql(...)
18
+ graphql.mutate(...)
19
+ end
20
+
17
21
  def query_api_cached(url)
18
22
  restapi.query_api_cached(url)
19
23
  end
@@ -13,34 +13,118 @@ module Gitlab
13
13
  class GraphqlAdapter < BaseAdapter
14
14
  Client = GraphQL::Client
15
15
 
16
+ RawDocument = Struct.new(:query_string) do
17
+ def to_query_string
18
+ query_string
19
+ end
20
+ end
21
+
16
22
  def query(graphql_query, resource_path: [], variables: {})
17
23
  response = client.query(graphql_query, variables: variables, context: { token: options.token })
18
24
 
19
25
  raise_on_error!(response)
20
26
 
21
- parsed_response = parse_response(response, resource_path)
22
27
  headers = response.extensions.fetch('headers', {})
28
+ node = plain_node(parse_response(response, resource_path))
23
29
 
24
- graphql_response = {
25
- ratelimit_remaining: headers['ratelimit-remaining'].to_i,
26
- ratelimit_reset_at: Time.at(headers['ratelimit-reset'].to_i)
27
- }
28
-
29
- return graphql_response.merge(results: {}) if parsed_response.nil?
30
- return graphql_response.merge(results: parsed_response.map(&:to_h)) if parsed_response.is_a?(Client::List)
31
- return graphql_response.merge(results: parsed_response.to_h) unless parsed_response.nodes?
30
+ build_graphql_response(node, headers)
31
+ end
32
32
 
33
- graphql_response.merge(
34
- more_pages: parsed_response.page_info.has_next_page,
35
- end_cursor: parsed_response.page_info.end_cursor,
36
- results: parsed_response.nodes.map(&:to_h)
33
+ # Executes a raw query string directly through the HTTP layer, skipping
34
+ # GraphQL::Client schema validation (client.parse). Required for queries
35
+ # that reference experiment-tagged fields/arguments: GitLab hides those
36
+ # from introspection but executes them at runtime, so the client-side
37
+ # validator would otherwise reject a perfectly valid query.
38
+ #
39
+ # Returns the same shape as #query so callers can be source-agnostic.
40
+ def query_raw(query_string, resource_path: [], variables: {})
41
+ raw = http_client.execute(
42
+ document: RawDocument.new(query_string),
43
+ variables: variables,
44
+ context: { token: options.token }
37
45
  )
46
+
47
+ errors = raw['errors']
48
+ raise "There was an error: #{errors.to_json}" if errors.present?
49
+
50
+ headers = raw.dig('extensions', 'headers') || {}
51
+ node = resource_path.reduce(raw['data']) { |data, segment| data&.fetch(segment, nil) }
52
+
53
+ build_graphql_response(node, headers)
54
+ end
55
+
56
+ def mutate(graphql_mutation, variables: {})
57
+ response = client.query(graphql_mutation, variables: variables, context: { token: options.token })
58
+
59
+ raise_on_error!(response)
60
+
61
+ parsed_response = response.data
62
+ headers = response.extensions.fetch('headers', {})
63
+
64
+ {
65
+ ratelimit_remaining: headers['ratelimit-remaining'].to_i,
66
+ ratelimit_reset_at: Time.at(headers['ratelimit-reset'].to_i),
67
+ results: parsed_response.to_h
68
+ }
38
69
  end
39
70
 
40
71
  delegate :parse, to: :client
41
72
 
42
73
  private
43
74
 
75
+ # Shared response assembly for both #query and #mutate (parsed path) and
76
+ # #query_raw (raw path). Takes a plain-Ruby node (Hash/Array/nil) and the
77
+ # rate-limit headers, and produces the canonical response shape:
78
+ # { ratelimit_*, [more_pages, end_cursor], results }.
79
+ def build_graphql_response(node, headers)
80
+ response = {
81
+ ratelimit_remaining: rate_limit_remaining(headers),
82
+ ratelimit_reset_at: rate_limit_reset_at(headers)
83
+ }
84
+
85
+ return response.merge(results: {}) if node.nil?
86
+
87
+ if node.is_a?(Hash) && node.key?('nodes')
88
+ page_info = node['pageInfo'] || {}
89
+ response.merge(
90
+ more_pages: page_info['hasNextPage'] || false,
91
+ end_cursor: page_info['endCursor'],
92
+ results: node['nodes']
93
+ )
94
+ else
95
+ response.merge(results: node)
96
+ end
97
+ end
98
+
99
+ # When the rate-limit headers are absent (e.g. a server that doesn't send
100
+ # them), treat the limit as not-a-concern rather than coercing a missing
101
+ # header to 0/1970, which would otherwise trip rate_limit_wait.
102
+ def rate_limit_remaining(headers)
103
+ value = headers['ratelimit-remaining']
104
+ value.present? ? value.to_i : Float::INFINITY
105
+ end
106
+
107
+ def rate_limit_reset_at(headers)
108
+ value = headers['ratelimit-reset']
109
+ Time.at(value.to_i) if value.present?
110
+ end
111
+
112
+ # Converts a parsed GraphQL::Client response node into plain Ruby so the
113
+ # shared builder only ever deals with Hash/Array, never client objects.
114
+ def plain_node(parsed_response)
115
+ return if parsed_response.nil?
116
+ return parsed_response.map(&:to_h) if parsed_response.is_a?(Client::List)
117
+ return parsed_response.to_h unless parsed_response.nodes?
118
+
119
+ {
120
+ 'nodes' => parsed_response.nodes.map(&:to_h),
121
+ 'pageInfo' => {
122
+ 'hasNextPage' => parsed_response.page_info.has_next_page,
123
+ 'endCursor' => parsed_response.page_info.end_cursor
124
+ }
125
+ }
126
+ end
127
+
44
128
  def parse_response(response, resource_path)
45
129
  resource_path.reduce(response.data) { |data, resource| data&.send(resource) } # rubocop:disable GitlabSecurity/PublicSend
46
130
  end
@@ -56,7 +56,7 @@ module Gitlab
56
56
 
57
57
  def comment?
58
58
  # The actual keys are strings
59
- (actions.keys.map(&:to_sym) - [:summarize, :comment_on_summary, :delete, :issue]).any?
59
+ (actions.keys.map(&:to_sym) - [:summarize, :comment_on_summary, :delete, :issue, :work_item_status]).any?
60
60
  end
61
61
 
62
62
  def issue?
@@ -67,6 +67,10 @@ module Gitlab
67
67
  actions.key?(:delete) && actions[:delete]
68
68
  end
69
69
 
70
+ def work_item_status?
71
+ actions.key?(:work_item_status)
72
+ end
73
+
70
74
  def build_issue
71
75
  raise NotImplementedError
72
76
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module Triage
5
- VERSION = '1.50.0'
5
+ VERSION = '1.51.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.50.0
4
+ version: 1.51.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-03-27 00:00:00.000000000 Z
11
+ date: 2026-06-24 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_status.rb
242
243
  - lib/gitlab/triage/api_query_builders/base_query_param_builder.rb
243
244
  - lib/gitlab/triage/api_query_builders/date_query_param_builder.rb
244
245
  - lib/gitlab/triage/api_query_builders/multi_query_param_builder.rb
@@ -273,6 +274,7 @@ files:
273
274
  - lib/gitlab/triage/filters/no_additional_labels_conditions_filter.rb
274
275
  - lib/gitlab/triage/filters/ruby_conditions_filter.rb
275
276
  - lib/gitlab/triage/filters/votes_conditions_filter.rb
277
+ - lib/gitlab/triage/filters/work_item_status_conditions_filter.rb
276
278
  - lib/gitlab/triage/graphql_network.rb
277
279
  - lib/gitlab/triage/graphql_queries/query_builder.rb
278
280
  - lib/gitlab/triage/graphql_queries/query_param_builders/array_param_builder.rb