pager_judy 0.2.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 +7 -0
- data/.buildkite/pipeline.yml +17 -0
- data/.buildkite/upload-pipeline +3 -0
- data/.dockerignore +3 -0
- data/.gitignore +53 -0
- data/.rspec +2 -0
- data/.rubocop.yml +49 -0
- data/CHANGES.md +29 -0
- data/Dockerfile +22 -0
- data/Gemfile +20 -0
- data/Gemfile.lock +109 -0
- data/README.md +5 -0
- data/Rakefile +9 -0
- data/auto/build +7 -0
- data/auto/dev-environment +11 -0
- data/auto/test +3 -0
- data/docker-compose.yml +16 -0
- data/exe/pagerjudy +10 -0
- data/lib/pager_judy/api/client.rb +61 -0
- data/lib/pager_judy/api/collection.rb +95 -0
- data/lib/pager_judy/api/errors.rb +20 -0
- data/lib/pager_judy/api/fake_api_app.rb +171 -0
- data/lib/pager_judy/api/item.rb +56 -0
- data/lib/pager_judy/api/resource.rb +81 -0
- data/lib/pager_judy/cli/collection_behaviour.rb +49 -0
- data/lib/pager_judy/cli/data_display.rb +52 -0
- data/lib/pager_judy/cli/item_behaviour.rb +31 -0
- data/lib/pager_judy/cli/main_command.rb +321 -0
- data/lib/pager_judy/cli/time_filtering.rb +46 -0
- data/lib/pager_judy/cli.rb +1 -0
- data/lib/pager_judy/sync/config.rb +69 -0
- data/lib/pager_judy/sync/syncer.rb +23 -0
- data/lib/pager_judy/sync.rb +12 -0
- data/lib/pager_judy/version.rb +5 -0
- data/pager_judy.gemspec +26 -0
- metadata +93 -0
@@ -0,0 +1,321 @@
|
|
1
|
+
require "clamp"
|
2
|
+
require "console_logger"
|
3
|
+
require "pager_judy/api/client"
|
4
|
+
require "pager_judy/cli/collection_behaviour"
|
5
|
+
require "pager_judy/cli/item_behaviour"
|
6
|
+
require "pager_judy/cli/time_filtering"
|
7
|
+
require "pager_judy/sync"
|
8
|
+
require "pager_judy/version"
|
9
|
+
|
10
|
+
module PagerJudy
|
11
|
+
module CLI
|
12
|
+
|
13
|
+
class MainCommand < Clamp::Command
|
14
|
+
|
15
|
+
option "--debug", :flag, "enable debugging"
|
16
|
+
option "--dry-run", :flag, "enable dry-run mode"
|
17
|
+
|
18
|
+
option "--version", :flag, "display version" do
|
19
|
+
puts PagerJudy::VERSION
|
20
|
+
exit 0
|
21
|
+
end
|
22
|
+
|
23
|
+
option "--api-key", "KEY", "PagerDuty API key",
|
24
|
+
environment_variable: "PAGER_DUTY_API_KEY"
|
25
|
+
|
26
|
+
subcommand ["escalation-policy", "ep"], "Display escalation policy" do
|
27
|
+
|
28
|
+
parameter "ID", "escalation_policy ID"
|
29
|
+
|
30
|
+
include ItemBehaviour
|
31
|
+
|
32
|
+
def item
|
33
|
+
client.escalation_policies[id]
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
37
|
+
|
38
|
+
subcommand ["escalation_policies", "eps"], "Display escalation policies" do
|
39
|
+
|
40
|
+
option %w[-q --query], "FILTER", "name filter"
|
41
|
+
|
42
|
+
include CollectionBehaviour
|
43
|
+
|
44
|
+
def collection
|
45
|
+
client.escalation_policies.with(query: query)
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
|
50
|
+
subcommand ["extension", "ext"], "Display extension" do
|
51
|
+
|
52
|
+
parameter "ID", "extension ID"
|
53
|
+
|
54
|
+
include ItemBehaviour
|
55
|
+
|
56
|
+
def item
|
57
|
+
client.extensions[id]
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
|
62
|
+
subcommand ["extensions", "exts"], "Display extensions" do
|
63
|
+
|
64
|
+
option %w[-q --query], "FILTER", "name filter"
|
65
|
+
|
66
|
+
include CollectionBehaviour
|
67
|
+
|
68
|
+
def collection
|
69
|
+
client.extensions.with(query: query)
|
70
|
+
end
|
71
|
+
|
72
|
+
end
|
73
|
+
|
74
|
+
subcommand ["incidents"], "Display incidents" do
|
75
|
+
|
76
|
+
option %w[-s --status], "STATUS", "status", :multivalued => true
|
77
|
+
|
78
|
+
include CollectionBehaviour
|
79
|
+
include TimeFiltering
|
80
|
+
|
81
|
+
def collection
|
82
|
+
client.incidents.with(filters)
|
83
|
+
end
|
84
|
+
|
85
|
+
def filters
|
86
|
+
time_filters.merge("statuses[]" => status_list).select { |_,v| v }
|
87
|
+
end
|
88
|
+
|
89
|
+
end
|
90
|
+
|
91
|
+
subcommand ["notifications"], "Display notifications" do
|
92
|
+
|
93
|
+
include TimeFiltering
|
94
|
+
|
95
|
+
self.default_subcommand = "data"
|
96
|
+
|
97
|
+
subcommand ["data", "d"], "Full details" do
|
98
|
+
include CollectionBehaviour::DataSubcommand
|
99
|
+
end
|
100
|
+
|
101
|
+
def collection
|
102
|
+
client.notifications.with(time_filters)
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
|
107
|
+
subcommand "schedule", "Display schedule" do
|
108
|
+
|
109
|
+
parameter "ID", "schedule ID"
|
110
|
+
|
111
|
+
include ItemBehaviour
|
112
|
+
|
113
|
+
def item
|
114
|
+
client.schedules[id]
|
115
|
+
end
|
116
|
+
|
117
|
+
end
|
118
|
+
|
119
|
+
subcommand "schedules", "Display schedules" do
|
120
|
+
|
121
|
+
option %w[-q --query], "FILTER", "name filter"
|
122
|
+
|
123
|
+
include CollectionBehaviour
|
124
|
+
|
125
|
+
def collection
|
126
|
+
client.schedules.with(query: query)
|
127
|
+
end
|
128
|
+
|
129
|
+
end
|
130
|
+
|
131
|
+
subcommand "service", "Display service" do
|
132
|
+
|
133
|
+
option %w[--include], "TYPE", "linked objects to include", :multivalued => true
|
134
|
+
|
135
|
+
parameter "ID", "service ID"
|
136
|
+
|
137
|
+
include ItemBehaviour
|
138
|
+
|
139
|
+
def item
|
140
|
+
client.services[id].with(
|
141
|
+
"include[]" => include_list
|
142
|
+
)
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
146
|
+
|
147
|
+
subcommand "services", "Display services" do
|
148
|
+
|
149
|
+
option %w[-q --query], "FILTER", "name filter"
|
150
|
+
option %w[--team], "ID", "team ID", :multivalued => true
|
151
|
+
option %w[--include], "TYPE", "linked objects to include", :multivalued => true
|
152
|
+
|
153
|
+
include CollectionBehaviour
|
154
|
+
|
155
|
+
def collection
|
156
|
+
client.services.with(
|
157
|
+
"query" => query,
|
158
|
+
"team_ids[]" => team_list,
|
159
|
+
"include[]" => include_list
|
160
|
+
)
|
161
|
+
end
|
162
|
+
|
163
|
+
end
|
164
|
+
|
165
|
+
subcommand "team", "Display team" do
|
166
|
+
|
167
|
+
parameter "ID", "team ID"
|
168
|
+
|
169
|
+
include ItemBehaviour
|
170
|
+
|
171
|
+
def item
|
172
|
+
client.teams[id]
|
173
|
+
end
|
174
|
+
|
175
|
+
end
|
176
|
+
|
177
|
+
subcommand "teams", "Display teams" do
|
178
|
+
|
179
|
+
option %w[-q --query], "FILTER", "name filter"
|
180
|
+
|
181
|
+
include CollectionBehaviour
|
182
|
+
|
183
|
+
def collection
|
184
|
+
client.teams.with(query: query)
|
185
|
+
end
|
186
|
+
|
187
|
+
end
|
188
|
+
|
189
|
+
subcommand "user", "Display user" do
|
190
|
+
|
191
|
+
parameter "ID", "user ID"
|
192
|
+
|
193
|
+
include ItemBehaviour
|
194
|
+
|
195
|
+
def item
|
196
|
+
client.users[id]
|
197
|
+
end
|
198
|
+
|
199
|
+
end
|
200
|
+
|
201
|
+
subcommand "users", "User operations" do
|
202
|
+
|
203
|
+
include CollectionBehaviour
|
204
|
+
|
205
|
+
def collection
|
206
|
+
client.users
|
207
|
+
end
|
208
|
+
|
209
|
+
end
|
210
|
+
|
211
|
+
subcommand "vendor", "Specific vendor" do
|
212
|
+
|
213
|
+
parameter "ID", "vendor ID"
|
214
|
+
|
215
|
+
include ItemBehaviour
|
216
|
+
|
217
|
+
def item
|
218
|
+
client.vendors[id]
|
219
|
+
end
|
220
|
+
|
221
|
+
end
|
222
|
+
|
223
|
+
subcommand "vendors", "Vendor list" do
|
224
|
+
|
225
|
+
include CollectionBehaviour
|
226
|
+
|
227
|
+
def collection
|
228
|
+
client.vendors
|
229
|
+
end
|
230
|
+
|
231
|
+
end
|
232
|
+
|
233
|
+
subcommand "viz", "Generate Graphviz Dot diagram" do
|
234
|
+
|
235
|
+
def execute
|
236
|
+
services = client.services
|
237
|
+
escalation_policies = client.escalation_policies
|
238
|
+
puts %(digraph pagerduty {)
|
239
|
+
puts %(rankdir=LR;)
|
240
|
+
integrations = []
|
241
|
+
services.each do |service|
|
242
|
+
service.fetch('integrations').each do |integration|
|
243
|
+
integrations << integration
|
244
|
+
puts %(#{integration.fetch('id')} -> #{service.fetch('id')};)
|
245
|
+
puts %(#{integration.fetch('id')} [label="#{integration.fetch('summary')}",shape=box];)
|
246
|
+
end
|
247
|
+
end
|
248
|
+
puts same_rank(integrations)
|
249
|
+
services.each do |service|
|
250
|
+
puts %(#{service.fetch('id')} [label="#{service.fetch('name')}",shape=box,style=filled,color=lightgrey];)
|
251
|
+
ep = service['escalation_policy']
|
252
|
+
if ep
|
253
|
+
puts %(#{service.fetch('id')} -> #{ep.fetch('id')};)
|
254
|
+
end
|
255
|
+
end
|
256
|
+
puts same_rank(services)
|
257
|
+
escalation_targets = {}
|
258
|
+
escalation_policies.each do |ep|
|
259
|
+
puts %{#{ep.fetch('id')} [label="#{ep.fetch('name')}",shape=box,style=filled,color=lightgrey];}
|
260
|
+
ep.fetch('escalation_rules').each do |rule|
|
261
|
+
rule.fetch('targets').each do |target|
|
262
|
+
puts %(#{ep.fetch('id')} -> #{target.fetch('id')};)
|
263
|
+
escalation_targets[target.fetch('id')] = target.fetch('summary')
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
puts same_rank(escalation_policies)
|
268
|
+
escalation_targets.each do |id, summary|
|
269
|
+
puts %(#{id} [label="#{summary}",shape=box];)
|
270
|
+
end
|
271
|
+
puts %(})
|
272
|
+
end
|
273
|
+
|
274
|
+
private
|
275
|
+
|
276
|
+
def same_rank(things)
|
277
|
+
ids = things.map { |thing| thing.fetch('id') }
|
278
|
+
"{" + ["rank=same", *ids].join(';') + "}"
|
279
|
+
end
|
280
|
+
|
281
|
+
end
|
282
|
+
|
283
|
+
subcommand "configure", "Apply config" do
|
284
|
+
|
285
|
+
option "--check", :flag, "just validate the config"
|
286
|
+
|
287
|
+
parameter "SOURCE", "config file"
|
288
|
+
|
289
|
+
def execute
|
290
|
+
config = PagerJudy::Sync::Config.from(source)
|
291
|
+
return if check?
|
292
|
+
PagerJudy::Sync.sync(client: client, config: config)
|
293
|
+
end
|
294
|
+
|
295
|
+
end
|
296
|
+
|
297
|
+
def run(*args)
|
298
|
+
super(*args)
|
299
|
+
rescue PagerJudy::API::HttpError => e
|
300
|
+
$stderr.puts e.response.body
|
301
|
+
signal_error e.message
|
302
|
+
rescue ConfigMapper::MappingError => e
|
303
|
+
signal_error e.message
|
304
|
+
end
|
305
|
+
|
306
|
+
private
|
307
|
+
|
308
|
+
def client
|
309
|
+
signal_error "no --api-key provided" unless api_key
|
310
|
+
HTTPI.log = false
|
311
|
+
@client ||= PagerJudy::API::Client.new(api_key, logger: logger, dry_run: dry_run?)
|
312
|
+
end
|
313
|
+
|
314
|
+
def logger
|
315
|
+
@logger ||= ConsoleLogger.new(STDERR, debug?)
|
316
|
+
end
|
317
|
+
|
318
|
+
end
|
319
|
+
|
320
|
+
end
|
321
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require "clamp"
|
2
|
+
require "time"
|
3
|
+
|
4
|
+
module PagerJudy
|
5
|
+
module CLI
|
6
|
+
|
7
|
+
module TimeFiltering
|
8
|
+
|
9
|
+
DAY = 24 * 60 * 60
|
10
|
+
|
11
|
+
extend Clamp::Option::Declaration
|
12
|
+
|
13
|
+
option %w[-a --after], "DATETIME", "start date/time", default: "24 hours ago", attribute_name: :after
|
14
|
+
option %w[-b --before], "DATETIME", "end date/time", attribute_name: :before
|
15
|
+
|
16
|
+
protected
|
17
|
+
|
18
|
+
def time_filters
|
19
|
+
{
|
20
|
+
since: after,
|
21
|
+
until: before
|
22
|
+
}.select { |_,v| v }
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def after=(s)
|
28
|
+
@after = Time.parse(s)
|
29
|
+
end
|
30
|
+
|
31
|
+
def before=(s)
|
32
|
+
@before = Time.parse(s)
|
33
|
+
end
|
34
|
+
|
35
|
+
def default_after
|
36
|
+
Time.now - DAY
|
37
|
+
end
|
38
|
+
|
39
|
+
def default_before
|
40
|
+
Time.now
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require "pager_judy/cli/main_command"
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require "config_hound"
|
2
|
+
require "config_mapper"
|
3
|
+
require "config_mapper/config_struct"
|
4
|
+
|
5
|
+
module PagerJudy
|
6
|
+
module Sync
|
7
|
+
|
8
|
+
# load and validate our configuration
|
9
|
+
class Config < ConfigMapper::ConfigStruct
|
10
|
+
|
11
|
+
class << self
|
12
|
+
|
13
|
+
# Load configuration.
|
14
|
+
#
|
15
|
+
# Multiple "sources" can be specified. First one wins.
|
16
|
+
# Sources may be a file-name, a URI, or a raw Ruby Hash.
|
17
|
+
#
|
18
|
+
def from(*config_sources)
|
19
|
+
config_data = data_from(*config_sources)
|
20
|
+
config_data.delete("var")
|
21
|
+
from_data(config_data)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Load configuration data.
|
25
|
+
#
|
26
|
+
# Includes are processed; defaults are included, references are expanded.
|
27
|
+
#
|
28
|
+
def data_from(*sources)
|
29
|
+
ConfigHound.load(sources, expand_refs: true, include_key: "include")
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
component_dict :escalation_policies do
|
35
|
+
|
36
|
+
attribute :description
|
37
|
+
|
38
|
+
end
|
39
|
+
|
40
|
+
component_dict :services do
|
41
|
+
|
42
|
+
attribute :description
|
43
|
+
|
44
|
+
attribute :acknowledgement_timeout, default: 1800
|
45
|
+
|
46
|
+
attribute :auto_resolve_timeout, default: 14400
|
47
|
+
|
48
|
+
component :escalation_policy do
|
49
|
+
|
50
|
+
attribute :id
|
51
|
+
|
52
|
+
def to_h
|
53
|
+
super.merge("type" => "escalation_policy_reference")
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
|
58
|
+
component_dict :integrations do
|
59
|
+
|
60
|
+
attribute :type
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module PagerJudy
|
2
|
+
module Sync
|
3
|
+
|
4
|
+
class Syncer
|
5
|
+
|
6
|
+
def initialize(client:, config:)
|
7
|
+
@client = client
|
8
|
+
@config = config
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :client
|
12
|
+
attr_reader :config
|
13
|
+
|
14
|
+
def sync
|
15
|
+
config.services.each do |name, detail|
|
16
|
+
client.services.create_or_update_by_name(name, detail.to_h)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
end
|
data/pager_judy.gemspec
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require "pager_judy/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "pager_judy"
|
8
|
+
spec.version = PagerJudy::VERSION
|
9
|
+
spec.authors = ["Mike Williams"]
|
10
|
+
spec.email = ["mdub@dogbiscuit.org"]
|
11
|
+
|
12
|
+
spec.summary = %q{a Ruby client and CLI for PagerDuty}
|
13
|
+
spec.homepage = "https://github.com/mdub/pager_judy"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
# Specify which files should be added to the gem when it is released.
|
17
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
18
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
19
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
20
|
+
end
|
21
|
+
spec.bindir = "exe"
|
22
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
23
|
+
spec.require_paths = ["lib"]
|
24
|
+
|
25
|
+
spec.add_development_dependency "bundler", "~> 1.16"
|
26
|
+
end
|
metadata
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pager_judy
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mike Williams
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-12-26 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.16'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.16'
|
27
|
+
description:
|
28
|
+
email:
|
29
|
+
- mdub@dogbiscuit.org
|
30
|
+
executables:
|
31
|
+
- pagerjudy
|
32
|
+
extensions: []
|
33
|
+
extra_rdoc_files: []
|
34
|
+
files:
|
35
|
+
- ".buildkite/pipeline.yml"
|
36
|
+
- ".buildkite/upload-pipeline"
|
37
|
+
- ".dockerignore"
|
38
|
+
- ".gitignore"
|
39
|
+
- ".rspec"
|
40
|
+
- ".rubocop.yml"
|
41
|
+
- CHANGES.md
|
42
|
+
- Dockerfile
|
43
|
+
- Gemfile
|
44
|
+
- Gemfile.lock
|
45
|
+
- README.md
|
46
|
+
- Rakefile
|
47
|
+
- auto/build
|
48
|
+
- auto/dev-environment
|
49
|
+
- auto/test
|
50
|
+
- docker-compose.yml
|
51
|
+
- exe/pagerjudy
|
52
|
+
- lib/pager_judy/api/client.rb
|
53
|
+
- lib/pager_judy/api/collection.rb
|
54
|
+
- lib/pager_judy/api/errors.rb
|
55
|
+
- lib/pager_judy/api/fake_api_app.rb
|
56
|
+
- lib/pager_judy/api/item.rb
|
57
|
+
- lib/pager_judy/api/resource.rb
|
58
|
+
- lib/pager_judy/cli.rb
|
59
|
+
- lib/pager_judy/cli/collection_behaviour.rb
|
60
|
+
- lib/pager_judy/cli/data_display.rb
|
61
|
+
- lib/pager_judy/cli/item_behaviour.rb
|
62
|
+
- lib/pager_judy/cli/main_command.rb
|
63
|
+
- lib/pager_judy/cli/time_filtering.rb
|
64
|
+
- lib/pager_judy/sync.rb
|
65
|
+
- lib/pager_judy/sync/config.rb
|
66
|
+
- lib/pager_judy/sync/syncer.rb
|
67
|
+
- lib/pager_judy/version.rb
|
68
|
+
- pager_judy.gemspec
|
69
|
+
homepage: https://github.com/mdub/pager_judy
|
70
|
+
licenses:
|
71
|
+
- MIT
|
72
|
+
metadata: {}
|
73
|
+
post_install_message:
|
74
|
+
rdoc_options: []
|
75
|
+
require_paths:
|
76
|
+
- lib
|
77
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '0'
|
82
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
83
|
+
requirements:
|
84
|
+
- - ">="
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: '0'
|
87
|
+
requirements: []
|
88
|
+
rubyforge_project:
|
89
|
+
rubygems_version: 2.7.6
|
90
|
+
signing_key:
|
91
|
+
specification_version: 4
|
92
|
+
summary: a Ruby client and CLI for PagerDuty
|
93
|
+
test_files: []
|