gitlab-triage 1.13.0 → 1.15.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/.gitlab/CODEOWNERS +1 -1
- data/.gitlab/merge_request_templates/Release.md +1 -1
- data/README.md +72 -0
- data/gitlab-triage.gemspec +2 -0
- data/lib/gitlab/triage/action.rb +8 -4
- data/lib/gitlab/triage/action/comment_on_summary.rb +83 -0
- data/lib/gitlab/triage/action/summarize.rb +7 -1
- data/lib/gitlab/triage/api_query_builders/base_query_param_builder.rb +11 -2
- data/lib/gitlab/triage/api_query_builders/multi_query_param_builder.rb +10 -2
- data/lib/gitlab/triage/engine.rb +64 -2
- data/lib/gitlab/triage/errors/network.rb +1 -0
- data/lib/gitlab/triage/filters/discussions_conditions_filter.rb +58 -0
- data/lib/gitlab/triage/graphql_network.rb +71 -0
- data/lib/gitlab/triage/graphql_queries/query_builder.rb +47 -0
- data/lib/gitlab/triage/graphql_queries/threads_query.rb +23 -0
- data/lib/gitlab/triage/graphql_queries/user_notes_query.rb +23 -0
- data/lib/gitlab/triage/network.rb +3 -1
- data/lib/gitlab/triage/network_adapters/graphql_adapter.rb +92 -0
- data/lib/gitlab/triage/policies/base_policy.rb +12 -1
- data/lib/gitlab/triage/validators/params_validator.rb +1 -1
- data/lib/gitlab/triage/version.rb +1 -1
- metadata +38 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a2927188b4b24b36453f4bac48a82b9af7d2ddc33bb7b5219f533282da9e8370
|
4
|
+
data.tar.gz: 8d35707ba7d219950ab3ba8dda852e89687f16b360cf1f368ae02038ff3ff7cb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 12def4c673ceb025d1a286c9c10a53c410d037364865482b85500bff9215d6490e6d0fcc2737f859a8bbeeba5a302c5e2ff6cbddfc7bab1599e909c243240d67
|
7
|
+
data.tar.gz: b98bbed0aabc5cf72595f5dbb3b60ce8d5bb25ad4af19aff1de46c34642ac15c7dc367db39ad1092833a97a923ac73957768bf14b5ed06ccb8bfd518e5fdbdc2
|
data/.gitlab/CODEOWNERS
CHANGED
@@ -1,2 +1,2 @@
|
|
1
1
|
# The official maintainers
|
2
|
-
* @rymai @godfat @markglenfletcher
|
2
|
+
* @rymai @godfat-gitlab @markglenfletcher
|
@@ -32,4 +32,4 @@ with the latest commit from https://gitlab.com/gitlab-org/gitlab-triage/commits/
|
|
32
32
|
- Checklist after merging:
|
33
33
|
- [ ] [Update the release notes for the newly created tag](docs/release_process.md#how-to).
|
34
34
|
|
35
|
-
/label ~"Engineering Productivity" ~"ep::triage" ~"tooling::workflow"
|
35
|
+
/label ~"Engineering Productivity" ~"ep::triage" ~tooling ~"tooling::workflow"
|
data/README.md
CHANGED
@@ -142,6 +142,7 @@ Available condition types:
|
|
142
142
|
- [`source_branch` condition](#source-branch-condition)
|
143
143
|
- [`target_branch` condition](#target-branch-condition)
|
144
144
|
- [`weight` condition](#weight-condition)
|
145
|
+
- [`discussions` condition](#discussions-condition)
|
145
146
|
- [`ruby` condition](#ruby-condition)
|
146
147
|
|
147
148
|
##### Date condition
|
@@ -486,6 +487,26 @@ conditions:
|
|
486
487
|
weight: Any
|
487
488
|
```
|
488
489
|
|
490
|
+
##### Discussions condition
|
491
|
+
|
492
|
+
Accepts a hash of fields.
|
493
|
+
|
494
|
+
| Field | Type | Values | Required |
|
495
|
+
| --------- | ---- | ---- | -------- |
|
496
|
+
| `attribute` | string | `threads`, `notes` | yes |
|
497
|
+
| `condition` | string | `less_than`, `greater_than` | yes |
|
498
|
+
| `threshold` | integer | integer | yes |
|
499
|
+
|
500
|
+
Example:
|
501
|
+
|
502
|
+
```yml
|
503
|
+
conditions:
|
504
|
+
discussions:
|
505
|
+
attribute: threads
|
506
|
+
condition: greater_than
|
507
|
+
threshold: 15
|
508
|
+
```
|
509
|
+
|
489
510
|
##### Ruby condition
|
490
511
|
|
491
512
|
This condition allows users to write a Ruby expression to be evaluated for
|
@@ -564,6 +585,7 @@ Available action types:
|
|
564
585
|
- [`comment` action](#comment-action)
|
565
586
|
- [`comment_type` action option](#comment-type-action-option)
|
566
587
|
- [`summarize` action](#summarize-action)
|
588
|
+
- [`comment_on_summary` action](#comment-on-summary-action)
|
567
589
|
|
568
590
|
##### Labels action
|
569
591
|
|
@@ -846,6 +868,56 @@ Which could generate an issue like:
|
|
846
868
|
/label ~"needs attention"
|
847
869
|
```
|
848
870
|
|
871
|
+
##### Comment on summary action
|
872
|
+
|
873
|
+
Generates one comment for each resource, attaching these comments to the summary
|
874
|
+
created by the [`summarize` action](#summarize-action).
|
875
|
+
|
876
|
+
The use case for this is wanting to create a summary with an overview, and then
|
877
|
+
a threaded discussion for each resource, with a header comment starting each
|
878
|
+
discussion.
|
879
|
+
|
880
|
+
Accepts a single string value: the template used to generate the comments. For
|
881
|
+
details of the syntax of this template, see the [comment action](#comment-action).
|
882
|
+
|
883
|
+
Since this action depends on the summary, it is invalid to supply a
|
884
|
+
`comment_on_summary` action without an accompanying `summarize` sibling action.
|
885
|
+
The `summarize` action will always be completed first.
|
886
|
+
|
887
|
+
Just like for [comment action](#comment-action), setting `comment_type` in the
|
888
|
+
`actions` set controls whether the comment must be resolved for merge requests.
|
889
|
+
See: [`comment_type` action option](#comment-type-action-option).
|
890
|
+
|
891
|
+
Example:
|
892
|
+
|
893
|
+
```yml
|
894
|
+
resource_rules:
|
895
|
+
issues:
|
896
|
+
rules:
|
897
|
+
- name: List of issues to discuss
|
898
|
+
limits:
|
899
|
+
most_recent: 15
|
900
|
+
actions:
|
901
|
+
comment_type: thread
|
902
|
+
comment_on_summary: |
|
903
|
+
# {{title}}
|
904
|
+
|
905
|
+
author: {{author}}
|
906
|
+
summarize:
|
907
|
+
title: |
|
908
|
+
#{resource[:type].capitalize} require labels
|
909
|
+
item: |
|
910
|
+
- [ ] [{{title}}]({{web_url}}) {{labels}}
|
911
|
+
summary: |
|
912
|
+
The following {{type}} require labels:
|
913
|
+
|
914
|
+
{{items}}
|
915
|
+
|
916
|
+
Please take care of them before the end of #{7.days.from_now.strftime('%Y-%m-%d')}
|
917
|
+
|
918
|
+
/label ~"needs attention"
|
919
|
+
```
|
920
|
+
|
849
921
|
### Summary policies
|
850
922
|
|
851
923
|
Summary policies are special policies that join multiple rule policies together
|
data/gitlab-triage.gemspec
CHANGED
@@ -20,6 +20,8 @@ Gem::Specification.new do |spec|
|
|
20
20
|
spec.require_paths = ['lib']
|
21
21
|
|
22
22
|
spec.add_dependency 'activesupport', '~> 5.1'
|
23
|
+
spec.add_dependency 'globalid', '~> 0.4'
|
24
|
+
spec.add_dependency 'graphql-client', '~> 0.16'
|
23
25
|
spec.add_dependency 'httparty', '~> 0.17'
|
24
26
|
|
25
27
|
spec.add_development_dependency 'bundler'
|
data/lib/gitlab/triage/action.rb
CHANGED
@@ -1,14 +1,18 @@
|
|
1
1
|
require_relative 'action/summarize'
|
2
2
|
require_relative 'action/comment'
|
3
|
+
require_relative 'action/comment_on_summary'
|
3
4
|
|
4
5
|
module Gitlab
|
5
6
|
module Triage
|
6
7
|
module Action
|
7
8
|
def self.process(policy:, **args)
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
9
|
+
policy.validate!
|
10
|
+
|
11
|
+
[
|
12
|
+
[Summarize, policy.summarize?],
|
13
|
+
[Comment, policy.comment?],
|
14
|
+
[CommentOnSummary, policy.comment_on_summary?]
|
15
|
+
].each do |action, active|
|
12
16
|
act(action: action, policy: policy, **args) if active
|
13
17
|
end
|
14
18
|
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base'
|
4
|
+
require_relative '../command_builders/text_content_builder'
|
5
|
+
require_relative '../command_builders/comment_command_builder'
|
6
|
+
require_relative '../command_builders/label_command_builder'
|
7
|
+
require_relative '../command_builders/remove_label_command_builder'
|
8
|
+
require_relative '../command_builders/cc_command_builder'
|
9
|
+
require_relative '../command_builders/status_command_builder'
|
10
|
+
require_relative '../command_builders/move_command_builder'
|
11
|
+
|
12
|
+
module Gitlab
|
13
|
+
module Triage
|
14
|
+
module Action
|
15
|
+
class CommentOnSummary < Base
|
16
|
+
class Dry < CommentOnSummary
|
17
|
+
def act
|
18
|
+
puts "The following comments would be posted for the rule **#{policy.name}**:\n\n"
|
19
|
+
|
20
|
+
super
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def perform(comment)
|
26
|
+
puts "# #{summary[:web_url]}\n```\n#{comment}\n```\n"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
attr_reader :summary
|
31
|
+
|
32
|
+
def initialize(policy:, network:)
|
33
|
+
super(policy: policy, network: network)
|
34
|
+
@summary = policy.summary
|
35
|
+
end
|
36
|
+
|
37
|
+
def act
|
38
|
+
policy.resources.each do |resource|
|
39
|
+
comment = build_comment(resource).strip
|
40
|
+
|
41
|
+
perform(comment) unless comment.empty?
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def build_comment(resource)
|
48
|
+
CommandBuilders::TextContentBuilder.new(policy.actions[:comment_on_summary], resource: resource, network: network).build_command
|
49
|
+
end
|
50
|
+
|
51
|
+
def perform(comment)
|
52
|
+
network.post_api(build_post_url, body: comment)
|
53
|
+
end
|
54
|
+
|
55
|
+
def build_post_url
|
56
|
+
# POST /projects/:id/issues/:issue_iid/notes
|
57
|
+
post_url = UrlBuilders::UrlBuilder.new(
|
58
|
+
network_options: network.options,
|
59
|
+
source_id: summary['project_id'],
|
60
|
+
resource_type: policy.type,
|
61
|
+
resource_id: summary['iid'],
|
62
|
+
sub_resource_type: sub_resource_type
|
63
|
+
).build
|
64
|
+
|
65
|
+
puts Gitlab::Triage::UI.debug "post_url: #{post_url}" if network.options.debug
|
66
|
+
|
67
|
+
post_url
|
68
|
+
end
|
69
|
+
|
70
|
+
def sub_resource_type
|
71
|
+
case type = policy.actions[:comment_type]
|
72
|
+
when 'comment', nil # nil is default
|
73
|
+
'notes'
|
74
|
+
when 'thread'
|
75
|
+
'discussions'
|
76
|
+
else
|
77
|
+
raise ArgumentError, "Unknown comment type: #{type}"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -10,6 +10,12 @@ module Gitlab
|
|
10
10
|
private
|
11
11
|
|
12
12
|
def perform
|
13
|
+
policy.summary = {
|
14
|
+
web_url: '[the-created-issue-url]',
|
15
|
+
project_id: 'some-id',
|
16
|
+
iid: 'some-iid'
|
17
|
+
}.with_indifferent_access
|
18
|
+
|
13
19
|
if group_summary_without_destination?
|
14
20
|
puts Gitlab::Triage::UI.warn("No issue will be created: No summary destination specified when source is 'groups'.")
|
15
21
|
return
|
@@ -35,7 +41,7 @@ module Gitlab
|
|
35
41
|
return
|
36
42
|
end
|
37
43
|
|
38
|
-
network.post_api(post_issue_url, post_issue_body)
|
44
|
+
policy.summary = network.post_api(post_issue_url, post_issue_body)
|
39
45
|
end
|
40
46
|
|
41
47
|
def issue
|
@@ -2,16 +2,25 @@ module Gitlab
|
|
2
2
|
module Triage
|
3
3
|
module APIQueryBuilders
|
4
4
|
class BaseQueryParamBuilder
|
5
|
-
attr_reader :param_name, :param_contents
|
5
|
+
attr_reader :param_name, :param_contents, :allowed_values
|
6
6
|
|
7
|
-
def initialize(param_name, param_contents)
|
7
|
+
def initialize(param_name, param_contents, allowed_values: nil)
|
8
8
|
@param_name = param_name
|
9
9
|
@param_contents = param_contents
|
10
|
+
@allowed_values = allowed_values
|
11
|
+
|
12
|
+
validate_allowed_values! if allowed_values
|
10
13
|
end
|
11
14
|
|
12
15
|
def build_param
|
13
16
|
"&#{param_name}=#{param_content.strip}"
|
14
17
|
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def validate_allowed_values!
|
22
|
+
ParamsValidator.new([{ name: param_name, type: String, values: allowed_values }], { param_name => param_contents }).validate!
|
23
|
+
end
|
15
24
|
end
|
16
25
|
end
|
17
26
|
end
|
@@ -6,14 +6,22 @@ module Gitlab
|
|
6
6
|
class MultiQueryParamBuilder < BaseQueryParamBuilder
|
7
7
|
attr_reader :separator
|
8
8
|
|
9
|
-
def initialize(param_name, param_contents, separator)
|
9
|
+
def initialize(param_name, param_contents, separator, allowed_values: nil)
|
10
10
|
@separator = separator
|
11
|
-
super(param_name, param_contents)
|
11
|
+
super(param_name, Array(param_contents), allowed_values: allowed_values)
|
12
12
|
end
|
13
13
|
|
14
14
|
def param_content
|
15
15
|
param_contents.map(&:strip).join(separator)
|
16
16
|
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def validate_allowed_values!
|
21
|
+
param_contents.each do |param|
|
22
|
+
ParamsValidator.new([{ name: param_name, type: String, values: allowed_values }], { param_name => param }).validate!
|
23
|
+
end
|
24
|
+
end
|
17
25
|
end
|
18
26
|
end
|
19
27
|
end
|
data/lib/gitlab/triage/engine.rb
CHANGED
@@ -7,6 +7,7 @@ require_relative 'filters/votes_conditions_filter'
|
|
7
7
|
require_relative 'filters/no_additional_labels_conditions_filter'
|
8
8
|
require_relative 'filters/author_member_conditions_filter'
|
9
9
|
require_relative 'filters/assignee_member_conditions_filter'
|
10
|
+
require_relative 'filters/discussions_conditions_filter'
|
10
11
|
require_relative 'filters/ruby_conditions_filter'
|
11
12
|
require_relative 'limiters/date_field_limiter'
|
12
13
|
require_relative 'action'
|
@@ -19,7 +20,10 @@ require_relative 'api_query_builders/single_query_param_builder'
|
|
19
20
|
require_relative 'api_query_builders/multi_query_param_builder'
|
20
21
|
require_relative 'url_builders/url_builder'
|
21
22
|
require_relative 'network'
|
23
|
+
require_relative 'graphql_network'
|
22
24
|
require_relative 'network_adapters/httparty_adapter'
|
25
|
+
require_relative 'network_adapters/graphql_adapter'
|
26
|
+
require_relative 'graphql_queries/query_builder'
|
23
27
|
require_relative 'ui'
|
24
28
|
|
25
29
|
module Gitlab
|
@@ -27,7 +31,14 @@ module Gitlab
|
|
27
31
|
class Engine
|
28
32
|
attr_reader :per_page, :policies, :options
|
29
33
|
|
30
|
-
|
34
|
+
DEFAULT_NETWORK_ADAPTER = Gitlab::Triage::NetworkAdapters::HttpartyAdapter
|
35
|
+
DEFAULT_GRAPHQL_ADAPTER = Gitlab::Triage::NetworkAdapters::GraphqlAdapter
|
36
|
+
ALLOWED_STATE_VALUES = {
|
37
|
+
issues: %w[opened closed],
|
38
|
+
merge_requests: %w[opened closed merged]
|
39
|
+
}.with_indifferent_access.freeze
|
40
|
+
|
41
|
+
def initialize(policies:, options:, network_adapter_class: DEFAULT_NETWORK_ADAPTER, graphql_network_adapter_class: DEFAULT_GRAPHQL_ADAPTER)
|
31
42
|
options.host_url = policies.delete(:host_url) { options.host_url }
|
32
43
|
options.api_version = policies.delete(:api_version) { 'v4' }
|
33
44
|
options.dry_run = ENV['TEST'] == 'true' if options.dry_run.nil?
|
@@ -36,6 +47,7 @@ module Gitlab
|
|
36
47
|
@policies = policies
|
37
48
|
@options = options
|
38
49
|
@network_adapter_class = network_adapter_class
|
50
|
+
@graphql_network_adapter_class = graphql_network_adapter_class
|
39
51
|
|
40
52
|
assert_all!
|
41
53
|
assert_project_id!
|
@@ -62,6 +74,10 @@ module Gitlab
|
|
62
74
|
@network ||= Network.new(network_adapter)
|
63
75
|
end
|
64
76
|
|
77
|
+
def graphql_network
|
78
|
+
@graphql_network ||= GraphqlNetwork.new(graphql_network_adapter)
|
79
|
+
end
|
80
|
+
|
65
81
|
private
|
66
82
|
|
67
83
|
def assert_project_id!
|
@@ -94,6 +110,10 @@ module Gitlab
|
|
94
110
|
@network_adapter ||= @network_adapter_class.new(options)
|
95
111
|
end
|
96
112
|
|
113
|
+
def graphql_network_adapter
|
114
|
+
@graphql_network_adapter ||= @graphql_network_adapter_class.new(options)
|
115
|
+
end
|
116
|
+
|
97
117
|
def rule_conditions(rule)
|
98
118
|
rule.fetch(:conditions) { {} }
|
99
119
|
end
|
@@ -158,8 +178,13 @@ module Gitlab
|
|
158
178
|
# retrieving the resources for every rule is inefficient
|
159
179
|
# however, previous rules may affect those upcoming
|
160
180
|
resources = network.query_api(build_get_url(resource_type, conditions))
|
181
|
+
iids = resources.pluck('iid').map(&:to_s)
|
182
|
+
|
183
|
+
graphql_query = build_graphql_query(resource_type, conditions)
|
184
|
+
graphql_resources = graphql_network.query(graphql_query, source: source_full_path, iids: iids) if graphql_query.present?
|
161
185
|
# In some filters/actions we want to know which resource type it is
|
162
186
|
attach_resource_type(resources, resource_type)
|
187
|
+
decorate_resources_with_graphql_data(resources, graphql_resources)
|
163
188
|
|
164
189
|
puts "\n\n* Found #{resources.count} resources..."
|
165
190
|
print "* Filtering resources..."
|
@@ -180,6 +205,13 @@ module Gitlab
|
|
180
205
|
resources.each { |resource| resource[:type] ||= resource_type }
|
181
206
|
end
|
182
207
|
|
208
|
+
def decorate_resources_with_graphql_data(resources, graphql_resources)
|
209
|
+
return if graphql_resources.nil?
|
210
|
+
|
211
|
+
graphql_resources_by_id = graphql_resources.to_h { |resource| [resource[:id], resource] }
|
212
|
+
resources.each { |resource| resource.merge!(graphql_resources_by_id[resource[:id]].to_h) }
|
213
|
+
end
|
214
|
+
|
183
215
|
def process_action(policy)
|
184
216
|
Action.process(
|
185
217
|
policy: policy,
|
@@ -213,6 +245,10 @@ module Gitlab
|
|
213
245
|
results << Filters::AssigneeMemberConditionsFilter.new(resource, conditions[:assignee_member], network).calculate
|
214
246
|
end
|
215
247
|
|
248
|
+
if conditions[:discussions]
|
249
|
+
results << Filters::DiscussionsConditionsFilter.new(resource, conditions[:discussions]).calculate
|
250
|
+
end
|
251
|
+
|
216
252
|
if conditions[:ruby]
|
217
253
|
results << Filters::RubyConditionsFilter.new(resource, conditions, network).calculate
|
218
254
|
end
|
@@ -244,7 +280,13 @@ module Gitlab
|
|
244
280
|
condition_builders << APIQueryBuilders::MultiQueryParamBuilder.new('not[labels]', conditions[:forbidden_labels], ',')
|
245
281
|
end
|
246
282
|
|
247
|
-
|
283
|
+
if conditions[:state]
|
284
|
+
condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new(
|
285
|
+
'state',
|
286
|
+
conditions[:state],
|
287
|
+
allowed_values: ALLOWED_STATE_VALUES[resource_type])
|
288
|
+
end
|
289
|
+
|
248
290
|
condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('milestone', Array(conditions[:milestone])[0]) if conditions[:milestone]
|
249
291
|
condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('source_branch', conditions[:source_branch]) if conditions[:source_branch]
|
250
292
|
condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('target_branch', conditions[:target_branch]) if conditions[:target_branch]
|
@@ -270,6 +312,26 @@ module Gitlab
|
|
270
312
|
params: params
|
271
313
|
).build
|
272
314
|
end
|
315
|
+
|
316
|
+
def build_graphql_query(resource_type, conditions)
|
317
|
+
Gitlab::Triage::GraphqlQueries::QueryBuilder
|
318
|
+
.new(options.source, resource_type, conditions)
|
319
|
+
end
|
320
|
+
|
321
|
+
def source_full_path
|
322
|
+
@source_full_path ||= fetch_source_full_path
|
323
|
+
end
|
324
|
+
|
325
|
+
def fetch_source_full_path
|
326
|
+
return options.source_id unless /\A\d+\z/.match?(options.source_id)
|
327
|
+
|
328
|
+
source_details = network.query_api(build_get_url(nil, {})).first
|
329
|
+
full_path = source_details['full_path'] || source_details['path_with_namespace']
|
330
|
+
|
331
|
+
raise ArgumentError, 'A source with given source_id was not found!' if full_path.blank?
|
332
|
+
|
333
|
+
full_path
|
334
|
+
end
|
273
335
|
end
|
274
336
|
end
|
275
337
|
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require_relative 'base_conditions_filter'
|
2
|
+
|
3
|
+
module Gitlab
|
4
|
+
module Triage
|
5
|
+
module Filters
|
6
|
+
class DiscussionsConditionsFilter < BaseConditionsFilter
|
7
|
+
ATTRIBUTES = %w[notes threads].freeze
|
8
|
+
CONDITIONS = %w[greater_than less_than].freeze
|
9
|
+
|
10
|
+
def self.filter_parameters
|
11
|
+
[
|
12
|
+
{
|
13
|
+
name: :attribute,
|
14
|
+
type: String,
|
15
|
+
values: ATTRIBUTES
|
16
|
+
},
|
17
|
+
{
|
18
|
+
name: :condition,
|
19
|
+
type: String,
|
20
|
+
values: CONDITIONS
|
21
|
+
},
|
22
|
+
{
|
23
|
+
name: :threshold,
|
24
|
+
type: Numeric
|
25
|
+
}
|
26
|
+
]
|
27
|
+
end
|
28
|
+
|
29
|
+
def initialize_variables(condition)
|
30
|
+
@attribute = condition[:attribute].to_sym
|
31
|
+
@condition = condition[:condition].to_sym
|
32
|
+
@threshold = condition[:threshold]
|
33
|
+
end
|
34
|
+
|
35
|
+
def resource_value
|
36
|
+
if @attribute == :notes
|
37
|
+
@resource[:user_notes_count]
|
38
|
+
else
|
39
|
+
@resource[:user_discussions_count]
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def condition_value
|
44
|
+
@threshold
|
45
|
+
end
|
46
|
+
|
47
|
+
def calculate
|
48
|
+
case @condition
|
49
|
+
when :greater_than
|
50
|
+
resource_value.to_i > condition_value
|
51
|
+
when :less_than
|
52
|
+
resource_value.to_i < condition_value
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
require 'active_support/all'
|
2
|
+
require 'net/protocol'
|
3
|
+
require 'globalid'
|
4
|
+
|
5
|
+
require_relative 'retryable'
|
6
|
+
require_relative 'ui'
|
7
|
+
require_relative 'errors'
|
8
|
+
|
9
|
+
module Gitlab
|
10
|
+
module Triage
|
11
|
+
class GraphqlNetwork
|
12
|
+
attr_reader :options, :adapter
|
13
|
+
|
14
|
+
MINIMUM_RATE_LIMIT = 25
|
15
|
+
|
16
|
+
def initialize(adapter)
|
17
|
+
@adapter = adapter
|
18
|
+
@options = adapter.options
|
19
|
+
end
|
20
|
+
|
21
|
+
def query(graphql_query, variables = {})
|
22
|
+
return if graphql_query.blank?
|
23
|
+
|
24
|
+
response = {}
|
25
|
+
resources = []
|
26
|
+
|
27
|
+
parsed_graphql_query = adapter.parse(graphql_query.query)
|
28
|
+
|
29
|
+
begin
|
30
|
+
print '.'
|
31
|
+
|
32
|
+
response = adapter.query(
|
33
|
+
parsed_graphql_query,
|
34
|
+
resource_path: graphql_query.resource_path,
|
35
|
+
variables: variables.merge(after: response.delete(:end_cursor))
|
36
|
+
)
|
37
|
+
|
38
|
+
rate_limit_debug(response) if options.debug
|
39
|
+
rate_limit_wait(response)
|
40
|
+
|
41
|
+
resources.concat(Array.wrap(response.delete(:results)))
|
42
|
+
end while response.delete(:more_pages)
|
43
|
+
|
44
|
+
resources
|
45
|
+
.map { |resource| resource.deep_transform_keys(&:underscore) }
|
46
|
+
.map(&:with_indifferent_access)
|
47
|
+
.map { |resource| resource.merge(id: extract_id_from_global_id(resource[:id])) }
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def extract_id_from_global_id(global_id)
|
53
|
+
return if global_id.blank?
|
54
|
+
|
55
|
+
GlobalID.parse(global_id).model_id.to_i
|
56
|
+
end
|
57
|
+
|
58
|
+
def rate_limit_debug(response)
|
59
|
+
rate_limit_infos = "Rate limit remaining: #{response[:ratelimit_remaining]} (reset at #{response[:ratelimit_reset_at]})"
|
60
|
+
puts Gitlab::Triage::UI.debug "rate_limit_infos: #{rate_limit_infos}"
|
61
|
+
end
|
62
|
+
|
63
|
+
def rate_limit_wait(response)
|
64
|
+
return unless response.delete(:ratelimit_remaining) < MINIMUM_RATE_LIMIT
|
65
|
+
|
66
|
+
puts Gitlab::Triage::UI.debug "Rate limit almost exceeded, sleeping for #{response[:ratelimit_reset_at] - Time.now} seconds" if options.debug
|
67
|
+
sleep(1) until Time.now >= response[:ratelimit_reset_at]
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require_relative 'user_notes_query'
|
2
|
+
require_relative 'threads_query'
|
3
|
+
|
4
|
+
module Gitlab
|
5
|
+
module Triage
|
6
|
+
module GraphqlQueries
|
7
|
+
class QueryBuilder
|
8
|
+
def initialize(source_type, resource_type, conditions)
|
9
|
+
@source_type = source_type.to_s.singularize
|
10
|
+
@resource_type = resource_type
|
11
|
+
@conditions = conditions
|
12
|
+
end
|
13
|
+
|
14
|
+
def resource_path
|
15
|
+
[source_type, resource_type]
|
16
|
+
end
|
17
|
+
|
18
|
+
def query
|
19
|
+
return if query_template.nil?
|
20
|
+
|
21
|
+
format(query_template, source_type: source_type, resource_type: resource_type.to_s.camelize(:lower), group_query: group_query)
|
22
|
+
end
|
23
|
+
|
24
|
+
delegate :present?, to: :query_template
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
attr_reader :source_type, :resource_type, :conditions
|
29
|
+
|
30
|
+
def query_template
|
31
|
+
case conditions.dig(:discussions, :attribute).to_s
|
32
|
+
when 'notes'
|
33
|
+
UserNotesQuery
|
34
|
+
when 'threads'
|
35
|
+
ThreadsQuery
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def group_query
|
40
|
+
return if source_type != 'group'
|
41
|
+
|
42
|
+
', includeSubgroups: true'
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Gitlab
|
2
|
+
module Triage
|
3
|
+
module GraphqlQueries
|
4
|
+
ThreadsQuery = <<-GRAPHQL.freeze # rubocop:disable Naming/ConstantName
|
5
|
+
query($source: ID!, $after: String, $iids: [String!]) {
|
6
|
+
%{source_type}(fullPath: $source) {
|
7
|
+
id
|
8
|
+
%{resource_type}(after: $after, iids: $iids%{group_query}) {
|
9
|
+
pageInfo {
|
10
|
+
hasNextPage
|
11
|
+
endCursor
|
12
|
+
}
|
13
|
+
nodes {
|
14
|
+
id
|
15
|
+
userDiscussionsCount
|
16
|
+
}
|
17
|
+
}
|
18
|
+
}
|
19
|
+
}
|
20
|
+
GRAPHQL
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Gitlab
|
2
|
+
module Triage
|
3
|
+
module GraphqlQueries
|
4
|
+
UserNotesQuery = <<-GRAPHQL.freeze # rubocop:disable Naming/ConstantName
|
5
|
+
query($source: ID!, $after: String, $iids: [String!]) {
|
6
|
+
%{source_type}(fullPath: $source) {
|
7
|
+
id
|
8
|
+
%{resource_type}(after: $after, iids: $iids%{group_query}) {
|
9
|
+
pageInfo {
|
10
|
+
hasNextPage
|
11
|
+
endCursor
|
12
|
+
}
|
13
|
+
nodes {
|
14
|
+
id
|
15
|
+
userNotesCount
|
16
|
+
}
|
17
|
+
}
|
18
|
+
}
|
19
|
+
}
|
20
|
+
GRAPHQL
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -44,8 +44,10 @@ module Gitlab
|
|
44
44
|
case results
|
45
45
|
when Array
|
46
46
|
resources.concat(results)
|
47
|
-
|
47
|
+
when Hash
|
48
48
|
resources << results
|
49
|
+
else
|
50
|
+
raise Errors::Network::UnexpectedResponse, "Unexpected response: #{results.inspect}"
|
49
51
|
end
|
50
52
|
|
51
53
|
rate_limit_debug(response) if options.debug
|
@@ -0,0 +1,92 @@
|
|
1
|
+
require 'httparty'
|
2
|
+
require 'graphql/client'
|
3
|
+
require 'graphql/client/http'
|
4
|
+
|
5
|
+
require_relative 'base_adapter'
|
6
|
+
require_relative '../ui'
|
7
|
+
require_relative '../errors'
|
8
|
+
|
9
|
+
module Gitlab
|
10
|
+
module Triage
|
11
|
+
module NetworkAdapters
|
12
|
+
class GraphqlAdapter < BaseAdapter
|
13
|
+
Client = GraphQL::Client
|
14
|
+
|
15
|
+
def query(graphql_query, resource_path: [], variables: {})
|
16
|
+
response = client.query(graphql_query, variables: variables, context: { token: options.token })
|
17
|
+
|
18
|
+
raise_on_error!(response)
|
19
|
+
|
20
|
+
parsed_response = parse_response(response, resource_path)
|
21
|
+
headers = response.extensions.fetch('headers', {})
|
22
|
+
|
23
|
+
graphql_response = {
|
24
|
+
ratelimit_remaining: headers['ratelimit-remaining'].to_i,
|
25
|
+
ratelimit_reset_at: Time.at(headers['ratelimit-reset'].to_i)
|
26
|
+
}
|
27
|
+
|
28
|
+
return graphql_response.merge(results: {}) if parsed_response.nil?
|
29
|
+
return graphql_response.merge(results: parsed_response.map(&:to_h)) if parsed_response.is_a?(Client::List)
|
30
|
+
return graphql_response.merge(results: parsed_response.to_h) unless parsed_response.nodes?
|
31
|
+
|
32
|
+
graphql_response.merge(
|
33
|
+
more_pages: parsed_response.page_info.has_next_page,
|
34
|
+
end_cursor: parsed_response.page_info.end_cursor,
|
35
|
+
results: parsed_response.nodes.map(&:to_h)
|
36
|
+
)
|
37
|
+
end
|
38
|
+
|
39
|
+
delegate :parse, to: :client
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def parse_response(response, resource_path)
|
44
|
+
resource_path.reduce(response.data) { |data, resource| data&.send(resource) }
|
45
|
+
end
|
46
|
+
|
47
|
+
def raise_on_error!(response)
|
48
|
+
return if response.errors.blank?
|
49
|
+
|
50
|
+
puts Gitlab::Triage::UI.debug response.inspect if options.debug
|
51
|
+
|
52
|
+
raise "There was an error: #{response.errors.messages.to_json}"
|
53
|
+
end
|
54
|
+
|
55
|
+
def http_client
|
56
|
+
Client::HTTP.new("#{options.host_url}/api/graphql") do
|
57
|
+
def execute(document:, operation_name: nil, variables: {}, context: {}) # rubocop:disable Lint/NestedMethodDefinition
|
58
|
+
body = {}
|
59
|
+
body['query'] = document.to_query_string
|
60
|
+
body['variables'] = variables if variables.any?
|
61
|
+
body['operationName'] = operation_name if operation_name
|
62
|
+
|
63
|
+
response = HTTParty.post(
|
64
|
+
uri,
|
65
|
+
body: body.to_json,
|
66
|
+
headers: {
|
67
|
+
'Content-type' => 'application/json',
|
68
|
+
'PRIVATE-TOKEN' => context[:token]
|
69
|
+
}
|
70
|
+
)
|
71
|
+
|
72
|
+
case response.code
|
73
|
+
when 200, 400
|
74
|
+
JSON.parse(response.body).merge('extensions' => { 'headers' => response.headers })
|
75
|
+
else
|
76
|
+
{ 'errors' => [{ 'message' => "#{response.code} #{response.message}" }] }
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def schema
|
83
|
+
@schema ||= Client.load_schema(http_client)
|
84
|
+
end
|
85
|
+
|
86
|
+
def client
|
87
|
+
@client ||= Client.new(schema: schema, execute: http_client).tap { |client| client.allow_dynamic_queries = true }
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -4,7 +4,10 @@ module Gitlab
|
|
4
4
|
module Triage
|
5
5
|
module Policies
|
6
6
|
class BasePolicy
|
7
|
+
InvalidPolicyError = Class.new(StandardError)
|
8
|
+
|
7
9
|
attr_reader :type, :policy_spec, :resources, :network
|
10
|
+
attr_accessor :summary
|
8
11
|
|
9
12
|
def initialize(type, policy_spec, resources, network)
|
10
13
|
@type = type
|
@@ -13,6 +16,10 @@ module Gitlab
|
|
13
16
|
@network = network
|
14
17
|
end
|
15
18
|
|
19
|
+
def validate!
|
20
|
+
raise InvalidPolicyError, 'Policies that comment_on_summary must include summarize action' if comment_on_summary? && !summarize?
|
21
|
+
end
|
22
|
+
|
16
23
|
def name
|
17
24
|
@name ||= (policy_spec[:name] || "#{type}-#{object_id}")
|
18
25
|
end
|
@@ -25,9 +32,13 @@ module Gitlab
|
|
25
32
|
actions.key?(:summarize)
|
26
33
|
end
|
27
34
|
|
35
|
+
def comment_on_summary?
|
36
|
+
actions.key?(:comment_on_summary)
|
37
|
+
end
|
38
|
+
|
28
39
|
def comment?
|
29
40
|
# The actual keys are strings
|
30
|
-
(actions.keys.map(&:to_sym) - [:summarize]).any?
|
41
|
+
(actions.keys.map(&:to_sym) - [:summarize, :comment_on_summary]).any?
|
31
42
|
end
|
32
43
|
|
33
44
|
def build_issue
|
@@ -34,7 +34,7 @@ module Gitlab
|
|
34
34
|
def validate_parameter_content(value)
|
35
35
|
@parameter_definitions.each do |param|
|
36
36
|
if param[:values]
|
37
|
-
raise InvalidParameter, "#{param[:name]} must be
|
37
|
+
raise InvalidParameter, "#{param[:name]} must be one of #{param[:values].join(',')}" unless param[:values].include?(value[param[:name]])
|
38
38
|
end
|
39
39
|
end
|
40
40
|
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.15.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- GitLab
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-01-20 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -24,6 +24,34 @@ dependencies:
|
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: '5.1'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: globalid
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0.4'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0.4'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: graphql-client
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0.16'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0.16'
|
27
55
|
- !ruby/object:Gem::Dependency
|
28
56
|
name: httparty
|
29
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -134,6 +162,7 @@ files:
|
|
134
162
|
- lib/gitlab/triage/action.rb
|
135
163
|
- lib/gitlab/triage/action/base.rb
|
136
164
|
- lib/gitlab/triage/action/comment.rb
|
165
|
+
- lib/gitlab/triage/action/comment_on_summary.rb
|
137
166
|
- lib/gitlab/triage/action/summarize.rb
|
138
167
|
- lib/gitlab/triage/api_query_builders/base_query_param_builder.rb
|
139
168
|
- lib/gitlab/triage/api_query_builders/date_query_param_builder.rb
|
@@ -158,16 +187,22 @@ files:
|
|
158
187
|
- lib/gitlab/triage/filters/assignee_member_conditions_filter.rb
|
159
188
|
- lib/gitlab/triage/filters/author_member_conditions_filter.rb
|
160
189
|
- lib/gitlab/triage/filters/base_conditions_filter.rb
|
190
|
+
- lib/gitlab/triage/filters/discussions_conditions_filter.rb
|
161
191
|
- lib/gitlab/triage/filters/member_conditions_filter.rb
|
162
192
|
- lib/gitlab/triage/filters/merge_request_date_conditions_filter.rb
|
163
193
|
- lib/gitlab/triage/filters/name_conditions_filter.rb
|
164
194
|
- lib/gitlab/triage/filters/no_additional_labels_conditions_filter.rb
|
165
195
|
- lib/gitlab/triage/filters/ruby_conditions_filter.rb
|
166
196
|
- lib/gitlab/triage/filters/votes_conditions_filter.rb
|
197
|
+
- lib/gitlab/triage/graphql_network.rb
|
198
|
+
- lib/gitlab/triage/graphql_queries/query_builder.rb
|
199
|
+
- lib/gitlab/triage/graphql_queries/threads_query.rb
|
200
|
+
- lib/gitlab/triage/graphql_queries/user_notes_query.rb
|
167
201
|
- lib/gitlab/triage/limiters/base_limiter.rb
|
168
202
|
- lib/gitlab/triage/limiters/date_field_limiter.rb
|
169
203
|
- lib/gitlab/triage/network.rb
|
170
204
|
- lib/gitlab/triage/network_adapters/base_adapter.rb
|
205
|
+
- lib/gitlab/triage/network_adapters/graphql_adapter.rb
|
171
206
|
- lib/gitlab/triage/network_adapters/httparty_adapter.rb
|
172
207
|
- lib/gitlab/triage/network_adapters/test_adapter.rb
|
173
208
|
- lib/gitlab/triage/option_parser.rb
|
@@ -213,7 +248,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
213
248
|
- !ruby/object:Gem::Version
|
214
249
|
version: '0'
|
215
250
|
requirements: []
|
216
|
-
rubygems_version: 3.1.
|
251
|
+
rubygems_version: 3.1.4
|
217
252
|
signing_key:
|
218
253
|
specification_version: 4
|
219
254
|
summary: GitLab triage automation project.
|