gitlab-triage 1.14.3 → 1.19.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/README.md +75 -4
  4. data/bin/gitlab-triage +5 -2
  5. data/lib/gitlab/triage/action.rb +8 -4
  6. data/lib/gitlab/triage/action/comment.rb +17 -5
  7. data/lib/gitlab/triage/action/comment_on_summary.rb +83 -0
  8. data/lib/gitlab/triage/action/summarize.rb +7 -1
  9. data/lib/gitlab/triage/api_query_builders/date_query_param_builder.rb +13 -50
  10. data/lib/gitlab/triage/command_builders/label_command_builder.rb +5 -1
  11. data/lib/gitlab/triage/engine.rb +46 -10
  12. data/lib/gitlab/triage/errors/network.rb +2 -0
  13. data/lib/gitlab/triage/graphql_network.rb +28 -1
  14. data/lib/gitlab/triage/graphql_queries/query_builder.rb +72 -16
  15. data/lib/gitlab/triage/graphql_queries/query_param_builders/base_param_builder.rb +25 -0
  16. data/lib/gitlab/triage/graphql_queries/query_param_builders/date_param_builder.rb +35 -0
  17. data/lib/gitlab/triage/graphql_queries/query_param_builders/labels_param_builder.rb +18 -0
  18. data/lib/gitlab/triage/network.rb +9 -3
  19. data/lib/gitlab/triage/network_adapters/base_adapter.rb +3 -1
  20. data/lib/gitlab/triage/network_adapters/graphql_adapter.rb +33 -10
  21. data/lib/gitlab/triage/network_adapters/httparty_adapter.rb +12 -0
  22. data/lib/gitlab/triage/option_parser.rb +2 -0
  23. data/lib/gitlab/triage/param_builders/date_param_builder.rb +58 -0
  24. data/lib/gitlab/triage/policies/base_policy.rb +30 -1
  25. data/lib/gitlab/triage/resource/context.rb +1 -0
  26. data/lib/gitlab/triage/resource/epic.rb +24 -0
  27. data/lib/gitlab/triage/resource/shared/issuable.rb +2 -0
  28. data/lib/gitlab/triage/retryable.rb +7 -5
  29. data/lib/gitlab/triage/utils.rb +13 -0
  30. data/lib/gitlab/triage/version.rb +1 -1
  31. metadata +10 -5
  32. data/lib/gitlab/triage/graphql_queries/threads_query.rb +0 -23
  33. data/lib/gitlab/triage/graphql_queries/user_notes_query.rb +0 -23
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 274f8c4fdb85670a259e32f6c378512e93a52619184714baea85372f77e54c9b
4
- data.tar.gz: 469563e881bb1678de111984c70cdd53c64abe241ab27b69ac206c262a10d078
3
+ metadata.gz: 75bd839eb28f1b1feee624b5591b623966520685ecf9577d66bd08eb68e25227
4
+ data.tar.gz: 6fe5843105229f7488bc6c5e54fb58269a928bd80df3b579163636639cd64f6b
5
5
  SHA512:
6
- metadata.gz: 8d850e51292669d2763dfbf7f313fe49b24e99c1bce44a12322901a8dc9f164f1a09c96ff56c6b9fcd798fbc82f43aeed06ed6cf80ede7fb2ec517edbe49d631
7
- data.tar.gz: ef78109d3c19698308979c36ef43d3f8e163c5d053f45e093f56dd8da05f6a1c34b5463339a6631944718c561b01ded46845b35924a3d1b1454ed0379faa3310
6
+ metadata.gz: d0f70cecc7cd0315143783856920a36970f63d834838ad26ca3210e2077f60859a54ae1208cb1c21d7ead6c718cae36900ad47724e43cbb3c1c3ad095441d715
7
+ data.tar.gz: 9044cb1de497e46b1b24f0e8ddf73da2fdd9d1dde178c4c0b1a5ff62e599c0581cb965f946ea14802a861180c9f37f72ba84fc972545b759644b528bb1865204
data/.rubocop.yml CHANGED
@@ -3,7 +3,7 @@ inherit_gem:
3
3
  - rubocop-default.yml
4
4
 
5
5
  AllCops:
6
- TargetRubyVersion: 2.6
6
+ TargetRubyVersion: 2.7
7
7
  Exclude:
8
8
  - 'vendor/**/*'
9
9
  - 'tmp/**/*'
data/README.md CHANGED
@@ -38,6 +38,7 @@ The format of the file is [YAML](https://en.wikipedia.org/wiki/YAML).
38
38
  project.
39
39
 
40
40
  Select which resource to add the policy to:
41
+ - `epics`
41
42
  - `issues`
42
43
  - `merge_requests`
43
44
 
@@ -47,9 +48,28 @@ For example:
47
48
 
48
49
  ```yml
49
50
  resource_rules:
51
+ epics:
52
+ rules:
53
+ - name: My epic policy
54
+ conditions:
55
+ date:
56
+ attribute: updated_at
57
+ condition: older_than
58
+ interval_type: days
59
+ interval: 5
60
+ state: opened
61
+ labels:
62
+ - None
63
+ actions:
64
+ labels:
65
+ - needs attention
66
+ mention:
67
+ - markglenfletcher
68
+ comment: |
69
+ {{author}} This epic is unlabelled after 5 days. It needs attention. Please take care of this before the end of #{2.days.from_now.strftime('%Y-%m-%d')}
50
70
  issues:
51
71
  rules:
52
- - name: My policy
72
+ - name: My issue policy
53
73
  conditions:
54
74
  date:
55
75
  attribute: updated_at
@@ -86,7 +106,7 @@ resource_rules:
86
106
  /label ~"needs attention"
87
107
  merge_requests:
88
108
  rules:
89
- - name: My policy
109
+ - name: My merge request policy
90
110
  conditions:
91
111
  state: opened
92
112
  labels:
@@ -585,6 +605,7 @@ Available action types:
585
605
  - [`comment` action](#comment-action)
586
606
  - [`comment_type` action option](#comment-type-action-option)
587
607
  - [`summarize` action](#summarize-action)
608
+ - [`comment_on_summary` action](#comment-on-summary-action)
588
609
 
589
610
  ##### Labels action
590
611
 
@@ -796,8 +817,8 @@ Accepts a hash of fields.
796
817
 
797
818
  | Field | Type | Description | Required | Placeholders | Ruby expression | Default |
798
819
  | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
799
- | `title` | string | The title of the generated issue | yes | yes | no | |
800
- | `destination` | integer or string | The project ID or path to create the generated issue in | no | no | no | source project |
820
+ | `title` | string | The title of the generated issue | yes | yes | yes | |
821
+ | `destination` | integer or string | The project ID or path to create the generated issue in | no | no | no | source project |
801
822
  | `item` | string | Template representing each triaged resource | no | yes | yes | |
802
823
  | `summary` | string | The description of the generated issue | no | Only `{{title}}`, `{{items}}`, `{{type}}` | yes | |
803
824
  | `redact_confidential_resources` | boolean | Whether redact fields for confidential resources | no | no | no | true |
@@ -867,6 +888,56 @@ Which could generate an issue like:
867
888
  /label ~"needs attention"
868
889
  ```
869
890
 
891
+ ##### Comment on summary action
892
+
893
+ Generates one comment for each resource, attaching these comments to the summary
894
+ created by the [`summarize` action](#summarize-action).
895
+
896
+ The use case for this is wanting to create a summary with an overview, and then
897
+ a threaded discussion for each resource, with a header comment starting each
898
+ discussion.
899
+
900
+ Accepts a single string value: the template used to generate the comments. For
901
+ details of the syntax of this template, see the [comment action](#comment-action).
902
+
903
+ Since this action depends on the summary, it is invalid to supply a
904
+ `comment_on_summary` action without an accompanying `summarize` sibling action.
905
+ The `summarize` action will always be completed first.
906
+
907
+ Just like for [comment action](#comment-action), setting `comment_type` in the
908
+ `actions` set controls whether the comment must be resolved for merge requests.
909
+ See: [`comment_type` action option](#comment-type-action-option).
910
+
911
+ Example:
912
+
913
+ ```yml
914
+ resource_rules:
915
+ issues:
916
+ rules:
917
+ - name: List of issues to discuss
918
+ limits:
919
+ most_recent: 15
920
+ actions:
921
+ comment_type: thread
922
+ comment_on_summary: |
923
+ # {{title}}
924
+
925
+ author: {{author}}
926
+ summarize:
927
+ title: |
928
+ #{resource[:type].capitalize} require labels
929
+ item: |
930
+ - [ ] [{{title}}]({{web_url}}) {{labels}}
931
+ summary: |
932
+ The following {{type}} require labels:
933
+
934
+ {{items}}
935
+
936
+ Please take care of them before the end of #{7.days.from_now.strftime('%Y-%m-%d')}
937
+
938
+ /label ~"needs attention"
939
+ ```
940
+
870
941
  ### Summary policies
871
942
 
872
943
  Summary policies are special policies that join multiple rule policies together
data/bin/gitlab-triage CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'yaml'
4
4
  require_relative '../lib/gitlab/triage/option_parser'
5
5
  require_relative '../lib/gitlab/triage/engine'
6
+ require_relative '../lib/gitlab/triage/ui'
6
7
 
7
8
  options = Gitlab::Triage::OptionParser.parse(ARGV)
8
9
  options.policies_files << '.triage-policies.yml' if options.policies_files.empty?
@@ -10,7 +11,9 @@ options.policies_files << '.triage-policies.yml' if options.policies_files.empty
10
11
  options.policies_files.each do |policies_file|
11
12
  policies = HashWithIndifferentAccess.new(YAML.load_file(policies_file))
12
13
 
13
- Gitlab::Triage::Engine
14
+ policy_engine = Gitlab::Triage::Engine
14
15
  .new(policies: policies, options: options)
15
- .perform
16
+
17
+ puts Gitlab::Triage::UI.header("Executing policies from #{policies_file}.", char: '*')
18
+ policy_engine.perform
16
19
  end
@@ -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
- Summarize => policy.summarize?,
10
- Comment => policy.comment?
11
- }.compact.each do |action, active|
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
@@ -57,14 +57,17 @@ module Gitlab
57
57
  end
58
58
 
59
59
  def build_post_url(resource)
60
- # POST /projects/:id/issues/:issue_iid/notes
61
- post_url = UrlBuilders::UrlBuilder.new(
60
+ url_builder_opts = {
62
61
  network_options: network.options,
63
- source_id: resource[:project_id],
62
+ source: policy.source,
63
+ source_id: resource[policy.source_id_sym],
64
64
  resource_type: policy.type,
65
- resource_id: resource['iid'],
65
+ resource_id: resource_id(resource),
66
66
  sub_resource_type: sub_resource_type
67
- ).build
67
+ }
68
+
69
+ # POST /(groups|projects)/:id/(epics|issues|merge_requests)/:iid/notes
70
+ post_url = UrlBuilders::UrlBuilder.new(url_builder_opts).build
68
71
 
69
72
  puts Gitlab::Triage::UI.debug "post_url: #{post_url}" if network.options.debug
70
73
 
@@ -81,6 +84,15 @@ module Gitlab
81
84
  raise ArgumentError, "Unknown comment type: #{type}"
82
85
  end
83
86
  end
87
+
88
+ def resource_id(resource)
89
+ case policy.type
90
+ when 'epics'
91
+ resource['id']
92
+ else
93
+ resource['iid']
94
+ end
95
+ end
84
96
  end
85
97
  end
86
98
  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
@@ -1,57 +1,32 @@
1
- require_relative '../validators/params_validator'
1
+ require_relative '../param_builders/date_param_builder'
2
+ require_relative 'base_query_param_builder'
2
3
 
3
4
  module Gitlab
4
5
  module Triage
5
6
  module APIQueryBuilders
6
- class DateQueryParamBuilder
7
+ class DateQueryParamBuilder < BaseQueryParamBuilder
7
8
  ATTRIBUTES = %w[updated_at created_at].freeze
8
- CONDITIONS = %w[older_than newer_than].freeze
9
- INTERVAL_TYPES = %w[days weeks months years].freeze
10
-
11
- def self.filter_parameters
12
- [
13
- {
14
- name: :attribute,
15
- type: String,
16
- values: ATTRIBUTES
17
- },
18
- {
19
- name: :condition,
20
- type: String,
21
- values: CONDITIONS
22
- },
23
- {
24
- name: :interval_type,
25
- type: String,
26
- values: INTERVAL_TYPES
27
- },
28
- {
29
- name: :interval,
30
- type: Numeric
31
- }
32
- ]
33
- end
34
9
 
35
10
  def self.applicable?(condition)
36
11
  ATTRIBUTES.include?(condition[:attribute].to_s)
37
12
  end
38
13
 
39
14
  def initialize(condition_hash)
40
- @attribute = condition_hash[:attribute].to_s
41
- @interval_condition = condition_hash[:condition].to_sym
42
- @interval_type = condition_hash[:interval_type]
43
- @interval = condition_hash[:interval]
44
- validate_condition(condition_hash)
15
+ date_param_builder = ParamBuilders::DateParamBuilder.new(ATTRIBUTES, condition_hash)
16
+
17
+ super(build_param_name(condition_hash), date_param_builder.param_content)
45
18
  end
46
19
 
47
- def validate_condition(condition)
48
- ParamsValidator.new(self.class.filter_parameters, condition).validate!
20
+ def param_content
21
+ param_contents
49
22
  end
50
23
 
51
- def param_name
52
- prefix = attribute.sub(/_at\z/, '')
24
+ private
25
+
26
+ def build_param_name(condition_hash)
27
+ prefix = condition_hash[:attribute].to_s.sub(/_at\z/, '')
53
28
  suffix =
54
- case interval_condition
29
+ case condition_hash[:condition].to_sym
55
30
  when :older_than
56
31
  'before'
57
32
  when :newer_than
@@ -60,18 +35,6 @@ module Gitlab
60
35
 
61
36
  "#{prefix}_#{suffix}"
62
37
  end
63
-
64
- def param_content
65
- interval.public_send(interval_type).ago.to_date # rubocop:disable GitlabSecurity/PublicSend
66
- end
67
-
68
- def build_param
69
- "&#{param_name}=#{param_content.strip}"
70
- end
71
-
72
- private
73
-
74
- attr_reader :condition_hash, :attribute, :interval_condition, :interval_type, :interval
75
38
  end
76
39
  end
77
40
  end
@@ -14,7 +14,11 @@ module Gitlab
14
14
 
15
15
  def ensure_labels_exist!
16
16
  items.each do |label|
17
- label_opts = { project_id: resource[:project_id], name: label }
17
+ source_id_key = resource.key?(:group_id) ? :group_id : :project_id
18
+ label_opts = {
19
+ source_id_key => resource[source_id_key],
20
+ name: label
21
+ }
18
22
 
19
23
  unless Resource::Label.new(label_opts, network: network).exist?
20
24
  raise Resource::Label::LabelDoesntExistError,
@@ -37,6 +37,7 @@ module Gitlab
37
37
  issues: %w[opened closed],
38
38
  merge_requests: %w[opened closed merged]
39
39
  }.with_indifferent_access.freeze
40
+ EpicsTriagingForProjectImpossibleError = Class.new(StandardError)
40
41
 
41
42
  def initialize(policies:, options:, network_adapter_class: DEFAULT_NETWORK_ADAPTER, graphql_network_adapter_class: DEFAULT_GRAPHQL_ADAPTER)
42
43
  options.host_url = policies.delete(:host_url) { options.host_url }
@@ -62,6 +63,10 @@ module Gitlab
62
63
  puts
63
64
 
64
65
  resource_rules.each do |resource_type, resource|
66
+ if resource_type == 'epics' && options.source != :groups
67
+ raise(EpicsTriagingForProjectImpossibleError, "Epics can only be triaged at the group level. Please set the `--source groups` option.")
68
+ end
69
+
65
70
  puts Gitlab::Triage::UI.header("Processing rules for #{resource_type}", char: '-')
66
71
  puts
67
72
 
@@ -177,14 +182,23 @@ module Gitlab
177
182
  ExpandCondition.perform(rule_conditions(rule)) do |conditions|
178
183
  # retrieving the resources for every rule is inefficient
179
184
  # however, previous rules may affect those upcoming
180
- resources = network.query_api(build_get_url(resource_type, conditions))
181
- iids = resources.pluck('iid').map(&:to_s)
185
+ resources = []
186
+
187
+ if rule[:api] == 'graphql'
188
+ graphql_query = build_graphql_query(resource_type, conditions, true)
189
+ resources = graphql_network.query(graphql_query, source: source_full_path)
190
+ else
191
+ resources = network.query_api(build_get_url(resource_type, conditions))
192
+ iids = resources.pluck('iid').map(&:to_s)
193
+
194
+ graphql_query = build_graphql_query(resource_type, conditions)
195
+ graphql_resources = graphql_network.query(graphql_query, source: source_full_path, iids: iids) if graphql_query.any?
196
+
197
+ decorate_resources_with_graphql_data(resources, graphql_resources)
198
+ end
182
199
 
183
- graphql_query = build_graphql_query(resource_type, conditions)
184
- graphql_resources = graphql_network.query(graphql_query, source: options.source_id, iids: iids) if graphql_query.present?
185
200
  # In some filters/actions we want to know which resource type it is
186
201
  attach_resource_type(resources, resource_type)
187
- decorate_resources_with_graphql_data(resources, graphql_resources)
188
202
 
189
203
  puts "\n\n* Found #{resources.count} resources..."
190
204
  print "* Filtering resources..."
@@ -199,10 +213,17 @@ module Gitlab
199
213
  end
200
214
  end
201
215
 
202
- # We don't have to do this once the response will contain the type
203
- # of the resource. For now let's just attach it.
204
216
  def attach_resource_type(resources, resource_type)
205
- resources.each { |resource| resource[:type] ||= resource_type }
217
+ resources.each { |resource| resource[:type] = resource_type }
218
+ # TODO: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
219
+ # We should not overwrite the attribute here, but we need to
220
+ # fix it first. We should instead use something like
221
+ # gitlab_triage_resource_type so it won't conflict with the
222
+ # existing fields.
223
+ # And we need to retain the backward compatibility that using
224
+ # {{type}} will give us this value, rather than from the REST API,
225
+ # which will give us ISSUE from:
226
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59648
206
227
  end
207
228
 
208
229
  def decorate_resources_with_graphql_data(resources, graphql_resources)
@@ -313,9 +334,24 @@ module Gitlab
313
334
  ).build
314
335
  end
315
336
 
316
- def build_graphql_query(resource_type, conditions)
337
+ def build_graphql_query(resource_type, conditions, graphql_only = false)
317
338
  Gitlab::Triage::GraphqlQueries::QueryBuilder
318
- .new(options.source, resource_type, conditions)
339
+ .new(options.source, resource_type, conditions, graphql_only: graphql_only)
340
+ end
341
+
342
+ def source_full_path
343
+ @source_full_path ||= fetch_source_full_path
344
+ end
345
+
346
+ def fetch_source_full_path
347
+ return options.source_id unless /\A\d+\z/.match?(options.source_id)
348
+
349
+ source_details = network.query_api(build_get_url(nil, {})).first
350
+ full_path = source_details['full_path'] || source_details['path_with_namespace']
351
+
352
+ raise ArgumentError, 'A source with given source_id was not found!' if full_path.blank?
353
+
354
+ full_path
319
355
  end
320
356
  end
321
357
  end