gitlab-triage 1.10.1 → 1.14.1
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-ci.yml +6 -25
- data/.gitlab/CODEOWNERS +1 -1
- data/Guardfile +1 -1
- data/README.md +55 -7
- data/gitlab-triage.gemspec +2 -0
- data/lib/gitlab/triage/action/comment.rb +2 -2
- data/lib/gitlab/triage/command_builders/base_command_builder.rb +7 -3
- data/lib/gitlab/triage/command_builders/label_command_builder.rb +17 -0
- data/lib/gitlab/triage/command_builders/text_content_builder.rb +1 -5
- data/lib/gitlab/triage/engine.rb +47 -6
- data/lib/gitlab/triage/filters/discussions_conditions_filter.rb +60 -0
- data/lib/gitlab/triage/graphql_network.rb +54 -0
- data/lib/gitlab/triage/graphql_queries/query_builder.rb +47 -0
- data/lib/gitlab/triage/graphql_queries/threads_query.rb +31 -0
- data/lib/gitlab/triage/graphql_queries/user_notes_query.rb +23 -0
- data/lib/gitlab/triage/network_adapters/graphql_adapter.rb +69 -0
- data/lib/gitlab/triage/policies_resources/rule_resources.rb +2 -11
- data/lib/gitlab/triage/policies_resources/summary_resources.rb +2 -11
- data/lib/gitlab/triage/resource/base.rb +5 -1
- data/lib/gitlab/triage/resource/label.rb +15 -0
- data/lib/gitlab/triage/url_builders/url_builder.rb +7 -3
- data/lib/gitlab/triage/version.rb +1 -1
- data/support/.gitlab-ci.example.yml +2 -2
- metadata +37 -4
- data/lib/gitlab/triage/filters/forbidden_labels_conditions_filter.rb +0 -32
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fbce3d75b063c517b80bbbfa5a0cf8ef2cf0f6ffe9eabcebe743f7ea5e6fbc23
|
4
|
+
data.tar.gz: '09687c2d578d80f351a54a614113bcf36b70a15144f848409b8143be53c95f9b'
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f88d9507fd36e15941fa9b1345887e138893e8e7e5ae1b872408cfea449c2565c48c4267c1df278fa0f1ddf3f1c857050e4868eef88268b4acf9c7078dda0a53
|
7
|
+
data.tar.gz: 77dfd58b8e61d3115e9057b688d7f2b91b8473dd4e7a177a32cad0585f1f42c115d371f9d10f10a0a27fd3ee1b4230ee757982dcc85207395ebef82274165e70
|
data/.gitlab-ci.yml
CHANGED
@@ -2,7 +2,7 @@ stages:
|
|
2
2
|
- prepare
|
3
3
|
- test
|
4
4
|
- triage
|
5
|
-
-
|
5
|
+
- deploy
|
6
6
|
|
7
7
|
default:
|
8
8
|
image: ruby:2.7
|
@@ -38,6 +38,7 @@ workflow:
|
|
38
38
|
services:
|
39
39
|
- docker:${DOCKER_VERSION}-dind
|
40
40
|
variables:
|
41
|
+
DOCKER_VERSION: "19.03.0"
|
41
42
|
DOCKER_DRIVER: overlay2
|
42
43
|
DOCKER_HOST: tcp://docker:2375
|
43
44
|
DOCKER_TLS_CERTDIR: ""
|
@@ -118,7 +119,7 @@ dry-run:gitlab-triage:
|
|
118
119
|
- gitlab-triage --version
|
119
120
|
- gitlab-triage --help
|
120
121
|
- gitlab-triage --init
|
121
|
-
- gitlab-triage --dry-run --debug --token $
|
122
|
+
- gitlab-triage --dry-run --debug --token $GITLAB_API_TOKEN --source-id $CI_PROJECT_PATH
|
122
123
|
|
123
124
|
# This job requires allows to override the `CI_PROJECT_PATH` variable when triggered.
|
124
125
|
dry-run:custom:
|
@@ -127,26 +128,6 @@ dry-run:custom:
|
|
127
128
|
- when: manual
|
128
129
|
allow_failure: true
|
129
130
|
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
release:
|
134
|
-
stage: release
|
135
|
-
rules:
|
136
|
-
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push"'
|
137
|
-
changes: ["lib/gitlab/triage/version.rb"]
|
138
|
-
- if: '$CI_MERGE_REQUEST_TITLE =~ /RELEASE/'
|
139
|
-
when: manual
|
140
|
-
before_script: []
|
141
|
-
script:
|
142
|
-
- version=$(ruby -r ./lib/gitlab/triage/version -e 'puts Gitlab::Triage::VERSION' | tr -d "\n")
|
143
|
-
- tag="v${version}"
|
144
|
-
- message="Version ${version}."
|
145
|
-
# TODO: Add release notes from the Release MR?
|
146
|
-
- 'curl --request POST --header "PRIVATE-TOKEN: ${API_TOKEN}" -d "tag_name=${tag}" -d "ref=${CI_COMMIT_SHA}" -d "message=${message}" "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/repository/tags"'
|
147
|
-
- gem build gitlab-triage.gemspec
|
148
|
-
- gem push "gitlab-triage-${version}.gem"
|
149
|
-
artifacts:
|
150
|
-
paths:
|
151
|
-
- gitlab-triage*.gem
|
152
|
-
expire_in: 30 days
|
131
|
+
include:
|
132
|
+
- project: 'gitlab-org/quality/pipeline-common'
|
133
|
+
file: '/ci/gem-release.yml'
|
data/.gitlab/CODEOWNERS
CHANGED
@@ -1,2 +1,2 @@
|
|
1
1
|
# The official maintainers
|
2
|
-
* @rymai @godfat @markglenfletcher
|
2
|
+
* @rymai @godfat-gitlab @markglenfletcher
|
data/Guardfile
CHANGED
@@ -24,7 +24,7 @@
|
|
24
24
|
# * zeus: 'zeus rspec' (requires the server to be started separately)
|
25
25
|
# * 'just' rspec: 'rspec'
|
26
26
|
|
27
|
-
guard :rspec, cmd: "bundle exec rspec" do
|
27
|
+
guard :rspec, cmd: "bundle exec rspec -f doc" do
|
28
28
|
require "guard/rspec/dsl"
|
29
29
|
dsl = Guard::RSpec::Dsl.new(self)
|
30
30
|
|
data/README.md
CHANGED
@@ -141,6 +141,8 @@ Available condition types:
|
|
141
141
|
- [`assignee_member` condition](#assignee-member-condition)
|
142
142
|
- [`source_branch` condition](#source-branch-condition)
|
143
143
|
- [`target_branch` condition](#target-branch-condition)
|
144
|
+
- [`weight` condition](#weight-condition)
|
145
|
+
- [`discussions` condition](#discussions-condition)
|
144
146
|
- [`ruby` condition](#ruby-condition)
|
145
147
|
|
146
148
|
##### Date condition
|
@@ -467,6 +469,44 @@ conditions:
|
|
467
469
|
target_branch: 'master'
|
468
470
|
```
|
469
471
|
|
472
|
+
##### Weight condition
|
473
|
+
|
474
|
+
Accepts a string per the [API documentation](https://docs.gitlab.com/ee/api/issues.html#list-issues).
|
475
|
+
This condition is only applicable for issues (not merge requests).
|
476
|
+
|
477
|
+
| State | Type | Value |
|
478
|
+
| --------- | ---- | ------ |
|
479
|
+
| Any weight | string | `Any` |
|
480
|
+
| No weight | string | `None` |
|
481
|
+
| Specific weight | integer | integer |
|
482
|
+
|
483
|
+
Example:
|
484
|
+
|
485
|
+
```yml
|
486
|
+
conditions:
|
487
|
+
weight: Any
|
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
|
+
|
470
510
|
##### Ruby condition
|
471
511
|
|
472
512
|
This condition allows users to write a Ruby expression to be evaluated for
|
@@ -552,6 +592,10 @@ Adds a number of labels to the resource.
|
|
552
592
|
|
553
593
|
Accepts an array of strings. Each element is the name of a label to add.
|
554
594
|
|
595
|
+
If any of the labels doesn't exist, the automation will stop immediately so
|
596
|
+
that if a label is renamed or deleted, you'll have to explicitly update or remove
|
597
|
+
it in your policy file.
|
598
|
+
|
555
599
|
Example:
|
556
600
|
|
557
601
|
```yml
|
@@ -567,6 +611,10 @@ Removes a number of labels from the resource.
|
|
567
611
|
|
568
612
|
Accepts an array of strings. Each element is the name of a label to remove.
|
569
613
|
|
614
|
+
If any of the labels doesn't exist, the automation will stop immediately so
|
615
|
+
that if a label is renamed or deleted, you'll have to explicitly update or remove
|
616
|
+
it in your policy file.
|
617
|
+
|
570
618
|
Example:
|
571
619
|
|
572
620
|
```yml
|
@@ -1016,22 +1064,22 @@ Usage: gitlab-triage [options]
|
|
1016
1064
|
Triaging against a specific project:
|
1017
1065
|
|
1018
1066
|
```
|
1019
|
-
gitlab-triage --dry-run --token $
|
1067
|
+
gitlab-triage --dry-run --token $GITLAB_API_TOKEN --source-id gitlab-org/triage
|
1020
1068
|
```
|
1021
1069
|
|
1022
1070
|
Triaging against a whole group:
|
1023
1071
|
|
1024
1072
|
```
|
1025
|
-
gitlab-triage --dry-run --token $
|
1073
|
+
gitlab-triage --dry-run --token $GITLAB_API_TOKEN --source-id gitlab-org --source groups
|
1026
1074
|
```
|
1027
1075
|
|
1028
1076
|
Triaging against an entire instance:
|
1029
1077
|
|
1030
1078
|
```
|
1031
|
-
gitlab-triage --dry-run --token $
|
1079
|
+
gitlab-triage --dry-run --token $GITLAB_API_TOKEN --all-projects
|
1032
1080
|
```
|
1033
1081
|
|
1034
|
-
> **Note:** The `--all-projects` option will process all resources for all projects visible to the specified `$
|
1082
|
+
> **Note:** The `--all-projects` option will process all resources for all projects visible to the specified `$GITLAB_API_TOKEN`
|
1035
1083
|
|
1036
1084
|
#### Running on GitLab CI pipeline
|
1037
1085
|
|
@@ -1042,7 +1090,7 @@ run:triage:triage:
|
|
1042
1090
|
stage: triage
|
1043
1091
|
script:
|
1044
1092
|
- gem install gitlab-triage
|
1045
|
-
- gitlab-triage --token $
|
1093
|
+
- gitlab-triage --token $GITLAB_API_TOKEN --source-id $CI_PROJECT_PATH
|
1046
1094
|
only:
|
1047
1095
|
- schedules
|
1048
1096
|
```
|
@@ -1056,7 +1104,7 @@ Yes, you can override the host url using the following options:
|
|
1056
1104
|
##### CLI
|
1057
1105
|
|
1058
1106
|
```
|
1059
|
-
gitlab-triage --dry-run --token $
|
1107
|
+
gitlab-triage --dry-run --token $GITLAB_API_TOKEN --source-id gitlab-org/triage --host-url https://gitlab.host.com
|
1060
1108
|
```
|
1061
1109
|
|
1062
1110
|
##### Policy file
|
@@ -1093,7 +1141,7 @@ Gitlab::Triage::Resource::Context.include MyPlugin
|
|
1093
1141
|
And then run it with:
|
1094
1142
|
|
1095
1143
|
```shell
|
1096
|
-
gitlab-triage -r ./my_plugin.rb --token $
|
1144
|
+
gitlab-triage -r ./my_plugin.rb --token $GITLAB_API_TOKEN --source-id gitlab-org/triage
|
1097
1145
|
```
|
1098
1146
|
|
1099
1147
|
This allows you to use `has_severity_label?` in the Ruby condition:
|
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'
|
@@ -41,8 +41,8 @@ module Gitlab
|
|
41
41
|
CommandBuilders::CommentCommandBuilder.new(
|
42
42
|
[
|
43
43
|
CommandBuilders::TextContentBuilder.new(policy.actions[:comment], resource: resource, network: network).build_command,
|
44
|
-
CommandBuilders::LabelCommandBuilder.new(policy.actions[:labels]).build_command,
|
45
|
-
CommandBuilders::RemoveLabelCommandBuilder.new(policy.actions[:remove_labels]).build_command,
|
44
|
+
CommandBuilders::LabelCommandBuilder.new(policy.actions[:labels], resource: resource, network: network).build_command,
|
45
|
+
CommandBuilders::RemoveLabelCommandBuilder.new(policy.actions[:remove_labels], resource: resource, network: network).build_command,
|
46
46
|
CommandBuilders::CcCommandBuilder.new(policy.actions[:mention]).build_command,
|
47
47
|
CommandBuilders::MoveCommandBuilder.new(policy.actions[:move]).build_command,
|
48
48
|
CommandBuilders::StatusCommandBuilder.new(policy.actions[:status]).build_command
|
@@ -2,13 +2,15 @@ module Gitlab
|
|
2
2
|
module Triage
|
3
3
|
module CommandBuilders
|
4
4
|
class BaseCommandBuilder
|
5
|
-
def initialize(items)
|
5
|
+
def initialize(items, resource: nil, network: nil)
|
6
6
|
@items = Array.wrap(items)
|
7
7
|
@items.delete('')
|
8
|
+
@resource = resource&.with_indifferent_access
|
9
|
+
@network = network
|
8
10
|
end
|
9
11
|
|
10
12
|
def build_command
|
11
|
-
if
|
13
|
+
if items.any?
|
12
14
|
[slash_command_string, content_string].compact.join(separator)
|
13
15
|
else
|
14
16
|
""
|
@@ -17,6 +19,8 @@ module Gitlab
|
|
17
19
|
|
18
20
|
private
|
19
21
|
|
22
|
+
attr_reader :items, :resource, :network
|
23
|
+
|
20
24
|
def separator
|
21
25
|
' '
|
22
26
|
end
|
@@ -26,7 +30,7 @@ module Gitlab
|
|
26
30
|
end
|
27
31
|
|
28
32
|
def content_string
|
29
|
-
|
33
|
+
items.map do |item|
|
30
34
|
format_item(item)
|
31
35
|
end.join(separator)
|
32
36
|
end
|
@@ -4,8 +4,25 @@ module Gitlab
|
|
4
4
|
module Triage
|
5
5
|
module CommandBuilders
|
6
6
|
class LabelCommandBuilder < BaseCommandBuilder
|
7
|
+
def build_command
|
8
|
+
ensure_labels_exist!
|
9
|
+
|
10
|
+
super
|
11
|
+
end
|
12
|
+
|
7
13
|
private
|
8
14
|
|
15
|
+
def ensure_labels_exist!
|
16
|
+
items.each do |label|
|
17
|
+
label_opts = { project_id: resource[:project_id], name: label }
|
18
|
+
|
19
|
+
unless Resource::Label.new(label_opts, network: network).exist?
|
20
|
+
raise Resource::Label::LabelDoesntExistError,
|
21
|
+
"Label `#{label}` doesn't exist!"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
9
26
|
def slash_command_string
|
10
27
|
"/label"
|
11
28
|
end
|
@@ -34,13 +34,9 @@ module Gitlab
|
|
34
34
|
}.freeze
|
35
35
|
PLACEHOLDER_REGEX = /{{([\w\.]+)}}/.freeze
|
36
36
|
|
37
|
-
attr_reader :resource, :network
|
38
|
-
|
39
37
|
def initialize(
|
40
38
|
items, resource: nil, network: nil, redact_confidentials: true)
|
41
|
-
super(items)
|
42
|
-
@resource = resource&.with_indifferent_access
|
43
|
-
@network = network
|
39
|
+
super(items, resource: resource, network: network)
|
44
40
|
@redact_confidentials = redact_confidentials
|
45
41
|
end
|
46
42
|
|
data/lib/gitlab/triage/engine.rb
CHANGED
@@ -4,10 +4,10 @@ require 'active_support/inflector'
|
|
4
4
|
require_relative 'expand_condition'
|
5
5
|
require_relative 'filters/merge_request_date_conditions_filter'
|
6
6
|
require_relative 'filters/votes_conditions_filter'
|
7
|
-
require_relative 'filters/forbidden_labels_conditions_filter'
|
8
7
|
require_relative 'filters/no_additional_labels_conditions_filter'
|
9
8
|
require_relative 'filters/author_member_conditions_filter'
|
10
9
|
require_relative 'filters/assignee_member_conditions_filter'
|
10
|
+
require_relative 'filters/discussions_conditions_filter'
|
11
11
|
require_relative 'filters/ruby_conditions_filter'
|
12
12
|
require_relative 'limiters/date_field_limiter'
|
13
13
|
require_relative 'action'
|
@@ -20,7 +20,10 @@ require_relative 'api_query_builders/single_query_param_builder'
|
|
20
20
|
require_relative 'api_query_builders/multi_query_param_builder'
|
21
21
|
require_relative 'url_builders/url_builder'
|
22
22
|
require_relative 'network'
|
23
|
+
require_relative 'graphql_network'
|
23
24
|
require_relative 'network_adapters/httparty_adapter'
|
25
|
+
require_relative 'network_adapters/graphql_adapter'
|
26
|
+
require_relative 'graphql_queries/query_builder'
|
24
27
|
require_relative 'ui'
|
25
28
|
|
26
29
|
module Gitlab
|
@@ -28,7 +31,10 @@ module Gitlab
|
|
28
31
|
class Engine
|
29
32
|
attr_reader :per_page, :policies, :options
|
30
33
|
|
31
|
-
|
34
|
+
DEFAULT_NETWORK_ADAPTER = Gitlab::Triage::NetworkAdapters::HttpartyAdapter
|
35
|
+
DEFAULT_GRAPHQL_ADAPTER = Gitlab::Triage::NetworkAdapters::GraphqlAdapter
|
36
|
+
|
37
|
+
def initialize(policies:, options:, network_adapter_class: DEFAULT_NETWORK_ADAPTER, graphql_network_adapter_class: DEFAULT_GRAPHQL_ADAPTER)
|
32
38
|
options.host_url = policies.delete(:host_url) { options.host_url }
|
33
39
|
options.api_version = policies.delete(:api_version) { 'v4' }
|
34
40
|
options.dry_run = ENV['TEST'] == 'true' if options.dry_run.nil?
|
@@ -37,6 +43,7 @@ module Gitlab
|
|
37
43
|
@policies = policies
|
38
44
|
@options = options
|
39
45
|
@network_adapter_class = network_adapter_class
|
46
|
+
@graphql_network_adapter_class = graphql_network_adapter_class
|
40
47
|
|
41
48
|
assert_all!
|
42
49
|
assert_project_id!
|
@@ -63,6 +70,10 @@ module Gitlab
|
|
63
70
|
@network ||= Network.new(network_adapter)
|
64
71
|
end
|
65
72
|
|
73
|
+
def graphql_network
|
74
|
+
@graphql_network ||= GraphqlNetwork.new(graphql_network_adapter)
|
75
|
+
end
|
76
|
+
|
66
77
|
private
|
67
78
|
|
68
79
|
def assert_project_id!
|
@@ -95,6 +106,10 @@ module Gitlab
|
|
95
106
|
@network_adapter ||= @network_adapter_class.new(options)
|
96
107
|
end
|
97
108
|
|
109
|
+
def graphql_network_adapter
|
110
|
+
@graphql_network_adapter ||= @graphql_network_adapter_class.new(options)
|
111
|
+
end
|
112
|
+
|
98
113
|
def rule_conditions(rule)
|
99
114
|
rule.fetch(:conditions) { {} }
|
100
115
|
end
|
@@ -159,8 +174,13 @@ module Gitlab
|
|
159
174
|
# retrieving the resources for every rule is inefficient
|
160
175
|
# however, previous rules may affect those upcoming
|
161
176
|
resources = network.query_api(build_get_url(resource_type, conditions))
|
177
|
+
iids = resources.pluck('iid').map(&:to_s)
|
178
|
+
|
179
|
+
graphql_query = build_graphql_query(resource_type, conditions)
|
180
|
+
graphql_resources = graphql_network.query(graphql_query, source: options.source_id, iids: iids) if graphql_query.present?
|
162
181
|
# In some filters/actions we want to know which resource type it is
|
163
182
|
attach_resource_type(resources, resource_type)
|
183
|
+
decorate_resources_with_graphql_data(resources, graphql_resources)
|
164
184
|
|
165
185
|
puts "\n\n* Found #{resources.count} resources..."
|
166
186
|
print "* Filtering resources..."
|
@@ -181,6 +201,13 @@ module Gitlab
|
|
181
201
|
resources.each { |resource| resource[:type] ||= resource_type }
|
182
202
|
end
|
183
203
|
|
204
|
+
def decorate_resources_with_graphql_data(resources, graphql_resources)
|
205
|
+
return if graphql_resources.nil?
|
206
|
+
|
207
|
+
graphql_resources_by_id = graphql_resources.to_h { |resource| [resource[:id], resource] }
|
208
|
+
resources.each { |resource| resource.merge!(graphql_resources_by_id[resource[:id]].to_h) }
|
209
|
+
end
|
210
|
+
|
184
211
|
def process_action(policy)
|
185
212
|
Action.process(
|
186
213
|
policy: policy,
|
@@ -202,10 +229,6 @@ module Gitlab
|
|
202
229
|
results << Filters::VotesConditionsFilter.new(resource, conditions[:upvotes]).calculate
|
203
230
|
end
|
204
231
|
|
205
|
-
if conditions[:forbidden_labels]
|
206
|
-
results << Filters::ForbiddenLabelsConditionsFilter.new(resource, conditions[:forbidden_labels]).calculate
|
207
|
-
end
|
208
|
-
|
209
232
|
if conditions[:no_additional_labels]
|
210
233
|
results << Filters::NoAdditionalLabelsConditionsFilter.new(resource, conditions.fetch(:labels) { [] }).calculate
|
211
234
|
end
|
@@ -218,6 +241,10 @@ module Gitlab
|
|
218
241
|
results << Filters::AssigneeMemberConditionsFilter.new(resource, conditions[:assignee_member], network).calculate
|
219
242
|
end
|
220
243
|
|
244
|
+
if conditions[:discussions]
|
245
|
+
results << Filters::DiscussionsConditionsFilter.new(resource, conditions[:discussions]).calculate
|
246
|
+
end
|
247
|
+
|
221
248
|
if conditions[:ruby]
|
222
249
|
results << Filters::RubyConditionsFilter.new(resource, conditions, network).calculate
|
223
250
|
end
|
@@ -244,6 +271,11 @@ module Gitlab
|
|
244
271
|
|
245
272
|
condition_builders = []
|
246
273
|
condition_builders << APIQueryBuilders::MultiQueryParamBuilder.new('labels', conditions[:labels], ',') if conditions[:labels]
|
274
|
+
|
275
|
+
if conditions[:forbidden_labels]
|
276
|
+
condition_builders << APIQueryBuilders::MultiQueryParamBuilder.new('not[labels]', conditions[:forbidden_labels], ',')
|
277
|
+
end
|
278
|
+
|
247
279
|
condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('state', conditions[:state]) if conditions[:state]
|
248
280
|
condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('milestone', Array(conditions[:milestone])[0]) if conditions[:milestone]
|
249
281
|
condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('source_branch', conditions[:source_branch]) if conditions[:source_branch]
|
@@ -253,6 +285,10 @@ module Gitlab
|
|
253
285
|
condition_builders << APIQueryBuilders::DateQueryParamBuilder.new(conditions.delete(:date))
|
254
286
|
end
|
255
287
|
|
288
|
+
if conditions[:weight] && resource_type.to_sym == :issues
|
289
|
+
condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('weight', conditions[:weight])
|
290
|
+
end
|
291
|
+
|
256
292
|
condition_builders.each do |condition_builder|
|
257
293
|
params[condition_builder.param_name] = condition_builder.param_content
|
258
294
|
end
|
@@ -266,6 +302,11 @@ module Gitlab
|
|
266
302
|
params: params
|
267
303
|
).build
|
268
304
|
end
|
305
|
+
|
306
|
+
def build_graphql_query(resource_type, conditions)
|
307
|
+
Gitlab::Triage::GraphqlQueries::QueryBuilder
|
308
|
+
.new(options.source, resource_type, conditions)
|
309
|
+
end
|
269
310
|
end
|
270
311
|
end
|
271
312
|
end
|
@@ -0,0 +1,60 @@
|
|
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.dig(:discussions, :nodes)&.count do |node|
|
40
|
+
!node&.dig(:notes, :nodes, 0, :system)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def condition_value
|
46
|
+
@threshold
|
47
|
+
end
|
48
|
+
|
49
|
+
def calculate
|
50
|
+
case @condition
|
51
|
+
when :greater_than
|
52
|
+
resource_value.to_i > condition_value
|
53
|
+
when :less_than
|
54
|
+
resource_value.to_i < condition_value
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,54 @@
|
|
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
|
+
def initialize(adapter)
|
15
|
+
@adapter = adapter
|
16
|
+
@options = adapter.options
|
17
|
+
end
|
18
|
+
|
19
|
+
def query(graphql_query, variables = {})
|
20
|
+
return if graphql_query.blank?
|
21
|
+
|
22
|
+
response = {}
|
23
|
+
resources = []
|
24
|
+
|
25
|
+
parsed_graphql_query = adapter.parse(graphql_query.query)
|
26
|
+
|
27
|
+
begin
|
28
|
+
print '.'
|
29
|
+
|
30
|
+
response = adapter.query(
|
31
|
+
parsed_graphql_query,
|
32
|
+
resource_path: graphql_query.resource_path,
|
33
|
+
variables: variables.merge(after: response.delete(:end_cursor))
|
34
|
+
)
|
35
|
+
|
36
|
+
resources.concat(Array.wrap(response.delete(:results)))
|
37
|
+
end while response.delete(:more_pages)
|
38
|
+
|
39
|
+
resources
|
40
|
+
.map { |resource| resource.deep_transform_keys(&:underscore) }
|
41
|
+
.map(&:with_indifferent_access)
|
42
|
+
.map { |resource| resource.merge(id: extract_id_from_global_id(resource[:id])) }
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def extract_id_from_global_id(global_id)
|
48
|
+
return if global_id.blank?
|
49
|
+
|
50
|
+
GlobalID.parse(global_id).model_id.to_i
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
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,31 @@
|
|
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
|
+
discussions {
|
16
|
+
nodes {
|
17
|
+
notes {
|
18
|
+
nodes {
|
19
|
+
system
|
20
|
+
}
|
21
|
+
}
|
22
|
+
}
|
23
|
+
}
|
24
|
+
}
|
25
|
+
}
|
26
|
+
}
|
27
|
+
}
|
28
|
+
GRAPHQL
|
29
|
+
end
|
30
|
+
end
|
31
|
+
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
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'graphql/client'
|
2
|
+
require 'graphql/client/http'
|
3
|
+
|
4
|
+
require_relative 'base_adapter'
|
5
|
+
require_relative '../ui'
|
6
|
+
require_relative '../errors'
|
7
|
+
|
8
|
+
module Gitlab
|
9
|
+
module Triage
|
10
|
+
module NetworkAdapters
|
11
|
+
class GraphqlAdapter < BaseAdapter
|
12
|
+
Client = GraphQL::Client
|
13
|
+
|
14
|
+
def query(graphql_query, resource_path: [], variables: {})
|
15
|
+
response = client.query(graphql_query, variables: variables, context: { token: options.token })
|
16
|
+
|
17
|
+
raise_on_error!(response)
|
18
|
+
|
19
|
+
parsed_response = parse_response(response, resource_path)
|
20
|
+
|
21
|
+
return { results: {} } if parsed_response.nil?
|
22
|
+
return { results: parsed_response.map(&:to_h) } if parsed_response.is_a?(Client::List)
|
23
|
+
return { results: parsed_response.to_h } unless parsed_response.nodes?
|
24
|
+
|
25
|
+
{
|
26
|
+
more_pages: parsed_response.page_info.has_next_page,
|
27
|
+
end_cursor: parsed_response.page_info.end_cursor,
|
28
|
+
results: parsed_response.nodes.map(&:to_h)
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
32
|
+
delegate :parse, to: :client
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def parse_response(response, resource_path)
|
37
|
+
resource_path.reduce(response.data) { |data, resource| data&.send(resource) }
|
38
|
+
end
|
39
|
+
|
40
|
+
def raise_on_error!(response)
|
41
|
+
return if response.errors.blank?
|
42
|
+
|
43
|
+
puts Gitlab::Triage::UI.debug response.inspect if options.debug
|
44
|
+
|
45
|
+
raise "There was an error: #{response.errors.messages.to_json}"
|
46
|
+
end
|
47
|
+
|
48
|
+
def http_client
|
49
|
+
Client::HTTP.new("#{options.host_url}/api/graphql") do
|
50
|
+
def headers(context) # rubocop:disable Lint/NestedMethodDefinition
|
51
|
+
{
|
52
|
+
'Content-type' => 'application/json',
|
53
|
+
'PRIVATE-TOKEN' => context[:token]
|
54
|
+
}
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def schema
|
60
|
+
@schema ||= Client.load_schema(http_client)
|
61
|
+
end
|
62
|
+
|
63
|
+
def client
|
64
|
+
@client ||= Client.new(schema: schema, execute: http_client).tap { |client| client.allow_dynamic_queries = true }
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -1,20 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'delegate'
|
4
4
|
|
5
5
|
module Gitlab
|
6
6
|
module Triage
|
7
7
|
module PoliciesResources
|
8
|
-
|
9
|
-
include Enumerable
|
10
|
-
extend Forwardable
|
11
|
-
|
12
|
-
def initialize(new_resources)
|
13
|
-
@resources = new_resources
|
14
|
-
end
|
15
|
-
|
16
|
-
def_delegator :@resources, :each
|
17
|
-
end
|
8
|
+
RuleResources = Class.new(SimpleDelegator)
|
18
9
|
end
|
19
10
|
end
|
20
11
|
end
|
@@ -1,20 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'delegate'
|
4
4
|
|
5
5
|
module Gitlab
|
6
6
|
module Triage
|
7
7
|
module PoliciesResources
|
8
|
-
|
9
|
-
include Enumerable
|
10
|
-
extend Forwardable
|
11
|
-
|
12
|
-
def initialize(new_rule_to_resources)
|
13
|
-
@rule_to_resources = new_rule_to_resources
|
14
|
-
end
|
15
|
-
|
16
|
-
def_delegator :@rule_to_resources, :each
|
17
|
-
end
|
8
|
+
SummaryResources = Class.new(SimpleDelegator)
|
18
9
|
end
|
19
10
|
end
|
20
11
|
end
|
@@ -58,11 +58,15 @@ module Gitlab
|
|
58
58
|
build_url(params: params)
|
59
59
|
end
|
60
60
|
|
61
|
+
def resource_id
|
62
|
+
resource[:iid]
|
63
|
+
end
|
64
|
+
|
61
65
|
def resource_url(params: {}, sub_resource_type: nil)
|
62
66
|
build_url(
|
63
67
|
params: params,
|
64
68
|
options: {
|
65
|
-
resource_id:
|
69
|
+
resource_id: resource_id,
|
66
70
|
sub_resource_type: sub_resource_type
|
67
71
|
}
|
68
72
|
)
|
@@ -8,6 +8,8 @@ module Gitlab
|
|
8
8
|
module Triage
|
9
9
|
module Resource
|
10
10
|
class Label < Base
|
11
|
+
LabelDoesntExistError = Class.new(StandardError)
|
12
|
+
|
11
13
|
FIELDS = %i[
|
12
14
|
id
|
13
15
|
project_id
|
@@ -35,6 +37,19 @@ module Gitlab
|
|
35
37
|
Time.parse(value) if value
|
36
38
|
end
|
37
39
|
end
|
40
|
+
|
41
|
+
def exist?
|
42
|
+
label = network.query_api_cached(resource_url).first
|
43
|
+
return false unless label
|
44
|
+
|
45
|
+
label[:name] == name
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def resource_id
|
51
|
+
name
|
52
|
+
end
|
38
53
|
end
|
39
54
|
end
|
40
55
|
end
|
@@ -17,7 +17,7 @@ module Gitlab
|
|
17
17
|
|
18
18
|
def build
|
19
19
|
url = base_url
|
20
|
-
url << "/#{@resource_id}" if @resource_id
|
20
|
+
url << "/#{percent_encode(@resource_id.to_s)}" if @resource_id
|
21
21
|
url << "/#{@sub_resource_type}" if @sub_resource_type
|
22
22
|
url << params_string if @params
|
23
23
|
url
|
@@ -31,16 +31,20 @@ module Gitlab
|
|
31
31
|
|
32
32
|
def base_url
|
33
33
|
url = host_with_api_url
|
34
|
-
url << "/#{@source}/#{
|
34
|
+
url << "/#{@source}/#{percent_encode(@source_id.to_s)}" unless @all
|
35
35
|
url << "/#{@resource_type}" if @resource_type
|
36
36
|
url
|
37
37
|
end
|
38
38
|
|
39
39
|
def params_string
|
40
40
|
"?" << @params.map do |k, v|
|
41
|
-
"#{k}=#{v}"
|
41
|
+
"#{percent_encode(k.to_s)}=#{percent_encode(v.to_s)}"
|
42
42
|
end.join("&")
|
43
43
|
end
|
44
|
+
|
45
|
+
def percent_encode(str)
|
46
|
+
CGI.escape(str).gsub('+', '%20')
|
47
|
+
end
|
44
48
|
end
|
45
49
|
end
|
46
50
|
end
|
@@ -8,7 +8,7 @@ dry-run:triage:
|
|
8
8
|
script:
|
9
9
|
- gem install gitlab-triage
|
10
10
|
- gitlab-triage --help
|
11
|
-
- gitlab-triage --dry-run --token $
|
11
|
+
- gitlab-triage --dry-run --token $GITLAB_API_TOKEN --source projects --source-id $CI_PROJECT_PATH
|
12
12
|
when: manual
|
13
13
|
except:
|
14
14
|
- schedules
|
@@ -17,6 +17,6 @@ run:triage:
|
|
17
17
|
stage: triage
|
18
18
|
script:
|
19
19
|
- gem install gitlab-triage
|
20
|
-
- gitlab-triage --token $
|
20
|
+
- gitlab-triage --token $GITLAB_API_TOKEN --source projects --source-id $CI_PROJECT_PATH
|
21
21
|
only:
|
22
22
|
- schedules
|
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.14.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- GitLab
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-10-27 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
|
@@ -158,17 +186,22 @@ files:
|
|
158
186
|
- lib/gitlab/triage/filters/assignee_member_conditions_filter.rb
|
159
187
|
- lib/gitlab/triage/filters/author_member_conditions_filter.rb
|
160
188
|
- lib/gitlab/triage/filters/base_conditions_filter.rb
|
161
|
-
- lib/gitlab/triage/filters/
|
189
|
+
- lib/gitlab/triage/filters/discussions_conditions_filter.rb
|
162
190
|
- lib/gitlab/triage/filters/member_conditions_filter.rb
|
163
191
|
- lib/gitlab/triage/filters/merge_request_date_conditions_filter.rb
|
164
192
|
- lib/gitlab/triage/filters/name_conditions_filter.rb
|
165
193
|
- lib/gitlab/triage/filters/no_additional_labels_conditions_filter.rb
|
166
194
|
- lib/gitlab/triage/filters/ruby_conditions_filter.rb
|
167
195
|
- lib/gitlab/triage/filters/votes_conditions_filter.rb
|
196
|
+
- lib/gitlab/triage/graphql_network.rb
|
197
|
+
- lib/gitlab/triage/graphql_queries/query_builder.rb
|
198
|
+
- lib/gitlab/triage/graphql_queries/threads_query.rb
|
199
|
+
- lib/gitlab/triage/graphql_queries/user_notes_query.rb
|
168
200
|
- lib/gitlab/triage/limiters/base_limiter.rb
|
169
201
|
- lib/gitlab/triage/limiters/date_field_limiter.rb
|
170
202
|
- lib/gitlab/triage/network.rb
|
171
203
|
- lib/gitlab/triage/network_adapters/base_adapter.rb
|
204
|
+
- lib/gitlab/triage/network_adapters/graphql_adapter.rb
|
172
205
|
- lib/gitlab/triage/network_adapters/httparty_adapter.rb
|
173
206
|
- lib/gitlab/triage/network_adapters/test_adapter.rb
|
174
207
|
- lib/gitlab/triage/option_parser.rb
|
@@ -214,7 +247,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
214
247
|
- !ruby/object:Gem::Version
|
215
248
|
version: '0'
|
216
249
|
requirements: []
|
217
|
-
rubygems_version: 3.1.
|
250
|
+
rubygems_version: 3.1.4
|
218
251
|
signing_key:
|
219
252
|
specification_version: 4
|
220
253
|
summary: GitLab triage automation project.
|
@@ -1,32 +0,0 @@
|
|
1
|
-
require_relative 'base_conditions_filter'
|
2
|
-
|
3
|
-
module Gitlab
|
4
|
-
module Triage
|
5
|
-
module Filters
|
6
|
-
class ForbiddenLabelsConditionsFilter < BaseConditionsFilter
|
7
|
-
def validate_condition(condition)
|
8
|
-
raise ArgumentError, 'condition must be an array containing forbidden label values' unless condition.is_a?(Array)
|
9
|
-
end
|
10
|
-
|
11
|
-
def initialize_variables(forbidden_labels)
|
12
|
-
@attribute = :labels
|
13
|
-
@forbidden_labels = forbidden_labels
|
14
|
-
end
|
15
|
-
|
16
|
-
def resource_value
|
17
|
-
@resource[@attribute]
|
18
|
-
end
|
19
|
-
|
20
|
-
def calculate
|
21
|
-
label_intersection.empty?
|
22
|
-
end
|
23
|
-
|
24
|
-
private
|
25
|
-
|
26
|
-
def label_intersection
|
27
|
-
resource_value & @forbidden_labels
|
28
|
-
end
|
29
|
-
end
|
30
|
-
end
|
31
|
-
end
|
32
|
-
end
|