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 +4 -4
- data/.rubocop_todo.yml +4 -15
- data/Gemfile.lock +1 -1
- data/README.md +63 -1
- data/lib/gitlab/triage/action/work_item_status.rb +100 -0
- data/lib/gitlab/triage/action.rb +3 -1
- data/lib/gitlab/triage/engine.rb +170 -1
- data/lib/gitlab/triage/filters/base_conditions_filter.rb +10 -10
- data/lib/gitlab/triage/filters/member_conditions_filter.rb +5 -5
- data/lib/gitlab/triage/filters/work_item_status_conditions_filter.rb +21 -0
- data/lib/gitlab/triage/graphql_network.rb +25 -2
- data/lib/gitlab/triage/graphql_queries/query_builder.rb +17 -17
- data/lib/gitlab/triage/limiters/base_limiter.rb +4 -4
- data/lib/gitlab/triage/network.rb +4 -0
- data/lib/gitlab/triage/network_adapters/graphql_adapter.rb +97 -13
- data/lib/gitlab/triage/policies/base_policy.rb +5 -1
- data/lib/gitlab/triage/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8153f61407dcda901bf6dbdeb8390a72e2af03e4562c723b770b0470951af96e
|
|
4
|
+
data.tar.gz: 97d626248297908df831d00ef897ff796db3262f42c4495f50dcc88ccb27a471
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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:
|
|
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:
|
|
41
|
+
# Offense count: 175
|
|
53
42
|
# Configuration parameters: AllowSubject.
|
|
54
43
|
RSpec/MultipleMemoizedHelpers:
|
|
55
44
|
Max: 10
|
|
56
45
|
|
|
57
|
-
# Offense count:
|
|
46
|
+
# Offense count: 331
|
|
58
47
|
# Configuration parameters: EnforcedStyle, IgnoreSharedExamples.
|
|
59
48
|
# SupportedStyles: always, named_only
|
|
60
49
|
RSpec/NamedSubject:
|
data/Gemfile.lock
CHANGED
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
|
|
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
|
data/lib/gitlab/triage/action.rb
CHANGED
|
@@ -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
|
data/lib/gitlab/triage/engine.rb
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
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
|
|
@@ -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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
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.
|
|
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-
|
|
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
|