gitlab-triage 1.14.2 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/.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/filters/discussions_conditions_filter.rb +1 -3
  14. data/lib/gitlab/triage/graphql_network.rb +28 -1
  15. data/lib/gitlab/triage/graphql_queries/query_builder.rb +72 -16
  16. data/lib/gitlab/triage/graphql_queries/query_param_builders/base_param_builder.rb +25 -0
  17. data/lib/gitlab/triage/graphql_queries/query_param_builders/date_param_builder.rb +35 -0
  18. data/lib/gitlab/triage/graphql_queries/query_param_builders/labels_param_builder.rb +18 -0
  19. data/lib/gitlab/triage/network.rb +9 -3
  20. data/lib/gitlab/triage/network_adapters/base_adapter.rb +3 -1
  21. data/lib/gitlab/triage/network_adapters/graphql_adapter.rb +33 -10
  22. data/lib/gitlab/triage/network_adapters/httparty_adapter.rb +12 -0
  23. data/lib/gitlab/triage/option_parser.rb +2 -0
  24. data/lib/gitlab/triage/param_builders/date_param_builder.rb +58 -0
  25. data/lib/gitlab/triage/policies/base_policy.rb +30 -1
  26. data/lib/gitlab/triage/resource/context.rb +1 -0
  27. data/lib/gitlab/triage/resource/epic.rb +24 -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 -31
  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: c40a53d7edad2e8472cb3a7a503af2d45d31dfb4b7192e12b170016cd47b0b23
4
- data.tar.gz: 35c4cfc534faee10e6d1d02b6108d3300ad4cee20883e6e1299d97c5315a0cde
3
+ metadata.gz: '0609b18bd6e787fbf4e4c586b8631bc5f8c83c0a9c1a90047da3f953b558a6d6'
4
+ data.tar.gz: dec00b0fe73a998726c0c42a845aa5fa48e5b590597df4f054ed26200d4a47ca
5
5
  SHA512:
6
- metadata.gz: 27424e4cab78096d186c11b9508340b50f70b04fdf9ee942bc1063dd3e4a0b14e4f10ebe7651947f3e32bd891cff98eb33db2315cd021aaef86e1f38e3dc9552
7
- data.tar.gz: 3c4e60ac07b46c9c755d8b85c39fed6addc39370b268885090e7cf822456877e1f3bf241adf7288c5f5df6cda802284ca05cfa8c91d79d92dc04086c36d8a182
6
+ metadata.gz: bb63d114b513c6a8705e47b432e5c7612fc4cbe9bf729d625286917b14341a0bfffc18ce70b06968a347b61ae8a2a629ed2c76f0e8d0c37b35a611c97b7d1045
7
+ data.tar.gz: e0752eb15014713b4ef6f1ad64494a6b3607348be75107a93a59e11659a2fa8fca4a80b4e45c8a9eca9fb7fe4ecd2dcd0923fd27bfae8d09de9ee3bf45c8e2f8
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