doggy 2.0.30 → 2.0.31

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 665ef43b3af2fce1b6ce594bdbd7abb681b7c776
4
- data.tar.gz: 775570e213d753cd8aac9d92f7a38c1dd9533b34
3
+ metadata.gz: 3b3e656b1e0b4fea21117a302fd8869d14b3809c
4
+ data.tar.gz: 832835e942ae432a1b7d22fa56a648b7f1cbfb8b
5
5
  SHA512:
6
- metadata.gz: a18b022fc91c4afe36754ea188488bcee89ab9931462c5c6143c2890da1f714660a60433f9852c1d543558133167fac76af0df828367491626bc02ff04cc9540
7
- data.tar.gz: 79a52ce5dd756d4d2cbd3afbf0f7a3d263f107f90780f7a98539cea96c63dfc5ed8a6106f05e3ba264c2a7a6f26b4b24f72a76dcf988c391d6a3885a7d884b00
6
+ metadata.gz: b6a817add2afe0244c40460d3a8e101745cc43fe08d0baa1a21beff09f8412ff1128d10d9f88b02bb74a344e3931eb00ccd3e8b4dc144b699c17929d9126eb5e
7
+ data.tar.gz: b68b78da576175baec808d12b38e23b235b3fbd4a1b1e97bfea6bb9606b8ce137cb0a38e794b65986d358eee3156cb726778ddf5613ec62f3e6c7fa07772a186
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- doggy (2.0.30)
4
+ doggy (2.0.31)
5
5
  json (~> 1.8.3)
6
6
  parallel (~> 1.6.1)
7
7
  rugged (~> 0.23.2)
data/README.md CHANGED
@@ -44,30 +44,28 @@ If you're feeling adventurous, just put plaintext `secrets.json` in your root ob
44
44
  ## Usage
45
45
 
46
46
  ```bash
47
- # Download selected items from DataDog
48
- $ doggy pull ID ID
47
+ # Syncs local changes to Datadog since last deploy.
48
+ $ doggy sync
49
49
 
50
- # Download all items
51
- $ doggy pull
50
+ # Download items. If no ID is given it will download all the items managed by dog.
51
+ $ doggy pull [IDs]
52
52
 
53
- # Upload selected items to DataDog
54
- $ doggy push ID ID ID
53
+ # Upload items to Datadog. If no ID is given it will push all items.
54
+ $ doggy push [IDs]
55
55
 
56
- # Upload all items to DataDog
57
- $ doggy push
58
-
59
- # Edit a dashboard in WYSIWYG
56
+ # Edit an item in WYSIWYG
60
57
  $ doggy edit ID
61
58
 
62
- # Delete selected items from both DataDog and local storage
63
- $ doggy delete ID ID ID
59
+ # Delete selected items from both Datadog and local storage
60
+ $ doggy delete IDs
64
61
 
65
62
  # Mute monitor(s) forever
66
- $ doggy mute ID ID ID
63
+ $ doggy mute IDs
67
64
 
68
65
  # Unmute monitor(s)
69
- $ doggy unmute ID ID ID
66
+ $ doggy unmute IDs
70
67
  ```
68
+ Multiple IDs should be separated by space.
71
69
 
72
70
  ## Development
73
71
 
data/lib/doggy.rb CHANGED
@@ -10,6 +10,7 @@ require "doggy/cli/mute"
10
10
  require "doggy/cli/pull"
11
11
  require "doggy/cli/push"
12
12
  require "doggy/cli/unmute"
13
+ require "doggy/cli/delete"
13
14
  require "doggy/model"
14
15
  require "doggy/models/dashboard"
15
16
  require "doggy/models/monitor"
@@ -39,7 +40,7 @@ module Doggy
39
40
  current_dir = Dir.pwd
40
41
 
41
42
  while current_dir != '/' do
42
- if File.exists?(File.join(current_dir, 'Gemfile')) then
43
+ if File.exist?(File.join(current_dir, 'Gemfile')) then
43
44
  return Pathname.new(current_dir)
44
45
  else
45
46
  current_dir = File.expand_path('../', current_dir)
@@ -55,20 +56,6 @@ module Doggy
55
56
  ENV['DATADOG_APP_KEY'] || secrets['datadog_app_key']
56
57
  end
57
58
 
58
- def modified(compare_to, all = false)
59
- @modified ||= begin
60
- mods = Set.new
61
- paths = repo.diff(compare_to, 'HEAD').each_delta.map { |delta| delta.new_file[:path] }
62
- paths.each do |path|
63
- parts = path.split('/')
64
- next unless parts[0] =~ /objects/
65
- next unless File.exist?(path)
66
- mods << path
67
- end
68
- mods
69
- end
70
- end
71
-
72
59
  def resolve_path(path)
73
60
  path = Pathname.new(path)
74
61
  curr_dir = Pathname.new(Dir.pwd)
@@ -86,8 +73,4 @@ module Doggy
86
73
  JSON.parse(raw)
87
74
  end
88
75
  end
89
-
90
- def repo
91
- @repo ||= Rugged::Repository.new(Doggy.object_root.parent.to_s)
92
- end
93
- end # Doggy
76
+ end
data/lib/doggy/cli.rb CHANGED
@@ -6,57 +6,59 @@ module Doggy
6
6
  class CLI < Thor
7
7
  include Thor::Actions
8
8
 
9
- desc "pull", "Pulls objects from Datadog"
10
- long_desc <<-D
11
- Pull objects from Datadog. All objects are pulled unless the type switches
12
- are used.
13
- D
14
-
15
- method_option "dashboards", type: :boolean, desc: 'Pull dashboards'
16
- method_option "monitors", type: :boolean, desc: 'Pull monitors'
17
- method_option "screens", type: :boolean, desc: 'Pull screens'
9
+ desc "pull [IDs]", "Pulls objects from Datadog"
18
10
 
19
11
  def pull(*ids)
20
12
  CLI::Pull.new(options.dup, ids).run
21
13
  end
22
14
 
23
- desc "push", "Pushes objects to Datadog"
15
+ desc "delete IDs", "Deletes objects with given IDs from both local repository and Datadog"
16
+
17
+ def delete(*ids)
18
+ CLI::Delete.new.run(ids)
19
+ end
20
+
21
+ desc "sync", "Pushes the changes to Datadog"
24
22
  long_desc <<-D
25
- Pushes objects to Datadog. Any objects that aren't skipped and don't have
26
- the marker in their title will get it as a result of a push.
23
+ Performs git diff between the HEAD and last deployed SHA to get the changes,
24
+ then accordingly either deletes or pushes objects to Datadog.
27
25
  D
28
26
 
29
- method_option "dashboards", type: :boolean, default: true, desc: 'Pull dashboards'
30
- method_option "monitors", type: :boolean, default: true, desc: 'Pull monitors'
31
- method_option "screens", type: :boolean, default: true, desc: 'Pull screens'
32
- method_option "all_objects", type: :boolean, default: false, desc: 'Push all objects even if they are not changed'
27
+ def sync
28
+ CLI::Push.new.sync_changes
29
+ end
30
+
31
+ desc "push [IDs]", "Hard pushes objects to Datadog"
32
+ long_desc <<-D
33
+ Pushes objects to Datadog. You can provide list of IDs to scope it to certain objects,
34
+ otherwise it will push all local objects to Datadog. The changes in Datadog that are not in
35
+ the repository will be overridden. This action does not delete anything.
36
+ IDs is a space separated list of item IDs.
37
+ D
33
38
 
34
39
  def push(*ids)
35
- CLI::Push.new(options.dup, ids).run
40
+ CLI::Push.new.push_all(ids)
36
41
  end
37
42
 
38
- desc "mute OBJECT_ID OBJECT_ID OBJECT_ID", "Mutes monitor on DataDog"
43
+ desc "mute IDs", "Mutes given monitors indefinitely"
39
44
  long_desc <<-D
40
- Mutes monitors on Datadog.
45
+ IDs is a space separated list of item IDs.
41
46
  D
42
47
 
43
48
  def mute(*ids)
44
49
  CLI::Mute.new(options.dup, ids).run
45
50
  end
46
51
 
47
- desc "unmute OBJECT_ID OBJECT_ID OBJECT_ID", "Unmutes monitor on DataDog"
52
+ desc "unmute IDs", "Unmutes given monitors"
48
53
  long_desc <<-D
49
- Unmutes monitors on datadog
54
+ IDs is a space separated list of item IDs.
50
55
  D
51
56
 
52
57
  def unmute(*ids)
53
58
  CLI::Unmute.new(options.dup, ids).run
54
59
  end
55
60
 
56
- desc "edit OBJECT_ID", "Edits an object"
57
- long_desc <<-D
58
- Edits an object
59
- D
61
+ desc "edit ID", "Edits given item in Datadog UI"
60
62
 
61
63
  def edit(id)
62
64
  CLI::Edit.new(options.dup, id).run
@@ -0,0 +1,18 @@
1
+ # encoding: utf-8
2
+
3
+ module Doggy
4
+ class CLI::Delete
5
+ def run(ids)
6
+ Doggy::Model.all_local_resources.each do |resource|
7
+ next unless ids.include?(resource.id.to_s)
8
+ Doggy.ui.say("Deleting #{resource.path}, with id #{resource.id}")
9
+ resp = resource.destroy
10
+ if resp['errors']
11
+ Doggy.ui.error("Could not delete. Error: #{resp['errors']}. Skipping")
12
+ else
13
+ resource.destroy_local
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -34,9 +34,7 @@ module Doggy
34
34
  private
35
35
 
36
36
  def resource_by_param
37
- resources = Doggy::Models::Dashboard.all_local
38
- resources += Doggy::Models::Monitor.all_local
39
- resources += Doggy::Models::Screen.all_local
37
+ resources = Doggy::Model.all_local_resources
40
38
  if @param =~ /^[0-9]+$/ then
41
39
  id = @param.to_i
42
40
  return resources.find { |res| res.id == id }
@@ -9,7 +9,7 @@ module Doggy
9
9
 
10
10
  def run
11
11
  monitors = @ids.map { |id| Doggy::Models::Monitor.find(id) }
12
- monitors.each(&:mute)
12
+ monitors.each { |monitor| monitor.toggle_mute!('mute') }
13
13
  end
14
14
  end
15
15
  end
@@ -1,31 +1,31 @@
1
1
  # encoding: utf-8
2
2
 
3
+ require 'parallel'
4
+
3
5
  module Doggy
4
6
  class CLI::Pull
5
- def initialize(options, ids_or_names)
6
- @options = options
7
- @ids_or_names = ids_or_names
7
+ def initialize(options, ids)
8
+ @options = options
9
+ @ids = ids
8
10
  end
9
11
 
10
12
  def run
11
- if @ids_or_names.empty?
12
- pull_resources('dashboards', Models::Dashboard) if !@options.any? || @options['dashboards']
13
- pull_resources('monitors', Models::Monitor) if !@options.any? || @options['monitors']
14
- pull_resources('screens', Models::Screen) if !@options.any? || @options['screens']
15
- return
16
- end
17
-
18
- @ids_or_names.each do |id_or_name|
19
- @local_resources = Doggy::Model.all_local_resources
20
- if id_or_name =~ /^\d+$/
21
- pull_by_id(id_or_name.to_i)
22
- else
23
- pull_by_file(id_or_name)
13
+ @local_resources = Doggy::Model.all_local_resources
14
+ if @ids.empty?
15
+ Parallel.each(@local_resources) do |local_resource|
16
+ if remote_resource = local_resource.class.find(local_resource.id)
17
+ remote_resource.path = local_resource.path
18
+ remote_resource.save_local
19
+ else
20
+ local_resource.destroy_local
21
+ end
24
22
  end
23
+ else
24
+ @ids.each { |id| pull_by_id(id.to_i) }
25
25
  end
26
26
  end
27
27
 
28
- private
28
+ private
29
29
 
30
30
  def pull_by_id(id)
31
31
  local_resources = @local_resources.find_all { |l| l.id == id }
@@ -41,9 +41,9 @@ module Doggy
41
41
 
42
42
  # Here we traverse `remote_resources` to find remote resource with matching class name and id.
43
43
  # We cannot subtract `local_resources` from `remote_resources` because those are different kind of objects.
44
- remote_resources_to_be_saved = normalized_resource_diff.map do |klass, id|
44
+ remote_resources_to_be_saved = normalized_resource_diff.map do |klass, normalized_resource_id|
45
45
  remote_resources.find do |rr|
46
- rr.class.name == klass && rr.id == id
46
+ rr.class.name == klass && rr.id == normalized_resource_id
47
47
  end
48
48
  end
49
49
 
@@ -56,29 +56,5 @@ module Doggy
56
56
  end
57
57
  end
58
58
  end
59
-
60
- def pull_by_file(file)
61
- resolved_path = Doggy.resolve_path(file)
62
- local_resource = @local_resources.find { |l| l.path == resolved_path }
63
-
64
- remote_resource = local_resource.class.find(local_resource.id)
65
- remote_resource.path = local_resource.path
66
- remote_resource.save_local
67
- end
68
-
69
- def pull_resources(name, klass)
70
- Doggy.ui.say "Pulling #{ name }..."
71
- local_resources = klass.all_local
72
- remote_resources = klass.all
73
-
74
- klass.assign_paths(remote_resources, local_resources)
75
- remote_resources.each(&:save_local)
76
-
77
- ids = local_resources.map(&:id) - remote_resources.map(&:id)
78
- local_resources.each do |local_resource|
79
- local_resource.destroy_local if ids.include?(local_resource.id)
80
- end
81
- end
82
59
  end
83
60
  end
84
-
@@ -6,41 +6,34 @@ module Doggy
6
6
  "This will override changes in Datadog if they have not been sycned to the dog repository. "\
7
7
  "Do you want to proceed?(Y/N)"
8
8
 
9
- def initialize(options, ids)
10
- @options = options
11
- @ids = ids
12
- end
13
-
14
- def run
15
- if @ids.empty?
16
- if @options['all_objects'] && !Doggy.ui.yes?(WARNING_MESSAGE)
17
- Doggy.ui.say "Operation cancelled"
18
- return
19
- end
20
- push_resources('dashboards', Models::Dashboard) if @options['dashboards']
21
- push_resources('monitors', Models::Monitor) if @options['monitors']
22
- push_resources('screens', Models::Screen) if @options['screens']
23
- else
24
- Doggy::Model.all_local_resources.each do |resource|
25
- next unless @ids.include?(resource.id.to_s)
26
- Doggy.ui.say "Pushing #{ resource.path }"
9
+ def sync_changes
10
+ changed_resources = Doggy::Model.changed_resources
11
+ Doggy.ui.say "Syncing #{changed_resources.size} objects to Datadog..."
12
+ changed_resources.each do |resource|
13
+ if resource.is_deleted
14
+ Doggy.ui.say "Deleting #{resource.path}, with id = #{resource.id}"
15
+ resp = resource.destroy
16
+ Dog.ui.error("Could not delete. Error: #{resp['errors']}. Skipping") if resp['errors']
17
+ else
18
+ Doggy.ui.say "Saving #{resource.path}, with id = #{resource.id}"
27
19
  resource.ensure_read_only!
28
20
  resource.save
29
21
  end
30
22
  end
31
-
32
23
  Doggy::Model.emit_shipit_deployment
33
24
  end
34
25
 
35
- private
36
-
37
- def push_resources(name, klass)
38
- Doggy.ui.say "Pushing #{ name }"
39
- local_resources = klass.all_local(only_changed: !@options['all_objects'])
40
- local_resources.each(&:ensure_read_only!)
41
- Doggy.ui.say "#{ local_resources.size } objects to push"
42
- local_resources.each(&:save)
26
+ def push_all(ids)
27
+ if ids.empty? && !Doggy.ui.yes?(WARNING_MESSAGE)
28
+ Doggy.ui.say('Operation cancelled')
29
+ return
30
+ end
31
+ Doggy::Model.all_local_resources.each do |resource|
32
+ next if ids && !ids.include?(resource.id.to_s)
33
+ Doggy.ui.say "Pushing #{resource.path}, with id #{resource.id}"
34
+ resource.ensure_read_only!
35
+ resource.save
36
+ end
43
37
  end
44
38
  end
45
39
  end
46
-
@@ -9,7 +9,7 @@ module Doggy
9
9
 
10
10
  def run
11
11
  monitors = @ids.map { |id| Doggy::Models::Monitor.find(id) }
12
- monitors.each(&:unmute)
12
+ monitors.each { |monitor| monitor.toggle_mute!('unmute') }
13
13
  end
14
14
  end
15
15
  end
data/lib/doggy/model.rb CHANGED
@@ -13,17 +13,14 @@ module Doggy
13
13
  # it doesn't get serialized.
14
14
  attr_accessor :path
15
15
 
16
+ # indicates whether an object locally deleted
17
+ attr_accessor :is_deleted
18
+
16
19
  # This stores whether the resource has been loaded locally or remotely.
17
20
  attr_accessor :loading_source
18
21
 
19
22
  class << self
20
- def root=(root)
21
- @root = root.to_s
22
- end
23
-
24
- def root
25
- @root || nil
26
- end
23
+ attr_accessor :root
27
24
 
28
25
  def find(id)
29
26
  attributes = request(:get, resource_url(id))
@@ -34,63 +31,41 @@ module Doggy
34
31
  resource
35
32
  end
36
33
 
37
- def assign_paths(remote_resources, local_resources)
38
- remote_resources.each do |remote|
39
- local = local_resources.find { |l| l.id == remote.id }
40
- next unless local
41
-
42
- remote.path = local.path
34
+ def all_local_resources
35
+ @all_local_resources ||= Parallel.map((Dir[Doggy.object_root.join("**/*.json")])) do |file|
36
+ raw = File.read(file, encoding: 'utf-8')
37
+ begin
38
+ attributes = JSON.parse(raw)
39
+ rescue JSON::ParserError
40
+ Doggy.ui.error "Could not parse #{ file }."
41
+ next
42
+ end
43
+ resource = infer_type(attributes).new(attributes)
44
+ resource.path = file
45
+ resource.loading_source = :local
46
+ resource
43
47
  end
44
48
  end
45
49
 
46
- def all
47
- collection = request(:get, resource_url)
48
- if collection.is_a?(Hash) && collection.keys.length == 1
49
- collection = collection.values.first
50
+ def changed_resources
51
+ repo = Rugged::Repository.new(Doggy.object_root.parent.to_s)
52
+ repo.diff(current_sha, 'HEAD').each_delta.map do |delta|
53
+ new_file_path = delta.new_file[:path]
54
+ next unless new_file_path.match(/\Aobjects\//)
55
+ is_deleted = delta.status == :deleted
56
+ oid = is_deleted ? delta.old_file[:oid] : delta.new_file[:oid]
57
+ begin
58
+ attributes = JSON.parse(repo.read(oid).data)
59
+ rescue JSON::ParserError
60
+ Doggy.ui.error("Could not parse #{ new_file_path }. Skipping...")
61
+ next
62
+ end
63
+ resource = infer_type(attributes).new(attributes)
64
+ resource.loading_source = :local
65
+ resource.path = Doggy.object_root.parent.join(new_file_path).to_s
66
+ resource.is_deleted = is_deleted
67
+ resource
50
68
  end
51
-
52
- ids = collection
53
- .map { |record| new(record) }
54
- .select { |instance| instance.managed? }
55
- .map { |instance| instance.id }
56
-
57
- Parallel.map(ids) { |id| find(id) }
58
- end
59
-
60
- def all_local_resources
61
- @resources ||= [ Models::Dashboard,
62
- Models::Monitor,
63
- Models::Screen ].flat_map(&:all_local)
64
- end
65
-
66
- def all_local(only_changed: false)
67
- @all_local ||= begin
68
- # TODO: Add serializer support here
69
- if only_changed
70
- files = Doggy.modified(Doggy::Model.current_sha).map { |i| Doggy.object_root.parent.join(i).to_s }
71
- else
72
- files = Dir[Doggy.object_root.join("**/*.json")]
73
- end
74
- resources = Parallel.map(files) do |file|
75
- raw = File.read(file, encoding: 'utf-8')
76
-
77
- begin
78
- attributes = JSON.parse(raw)
79
- rescue JSON::ParserError
80
- Doggy.ui.error "Could not parse #{ file }."
81
- next
82
- end
83
-
84
- next unless infer_type(attributes) == self
85
-
86
- resource = new(attributes)
87
- resource.path = file
88
- resource.loading_source = :local
89
- resource
90
- end
91
-
92
- resources.compact
93
- end
94
69
  end
95
70
 
96
71
  def infer_type(attributes)
@@ -177,7 +152,7 @@ module Doggy
177
152
 
178
153
  def save_local
179
154
  ensure_read_only!
180
- @path ||= Doggy.object_root.join("#{prefix}-#{id}.json")
155
+ self.path ||= Doggy.object_root.join("#{prefix}-#{id}.json")
181
156
  File.open(@path, 'w') { |f| f.write(JSON.pretty_generate(to_h)) }
182
157
  end
183
158
 
@@ -198,10 +173,10 @@ module Doggy
198
173
  attributes = request(:post, resource_url, body)
199
174
  self.id = self.class.new(attributes).id
200
175
  save_local
201
- Doggy.ui.say "Created #{ @path }"
176
+ Doggy.ui.say "Created #{ path }"
202
177
  else
203
178
  request(:put, resource_url(id), body)
204
- Doggy.ui.say "Updated #{ @path }"
179
+ Doggy.ui.say "Updated #{ path }"
205
180
  end
206
181
  end
207
182
 
@@ -5,7 +5,6 @@ module Doggy
5
5
  class Monitor < Doggy::Model
6
6
  class Options
7
7
  include Virtus.model
8
- attr_accessor :monitor
9
8
 
10
9
  attribute :silenced, Hash
11
10
  attribute :thresholds, Hash
@@ -16,16 +15,6 @@ module Doggy
16
15
  attribute :escalation_message, String
17
16
  attribute :renotify_interval, Integer
18
17
  attribute :locked, Boolean
19
-
20
- def to_h
21
- if monitor.id && monitor.loading_source == :local
22
- # Pull remote silenced state. If we don't send this value, Datadog
23
- # assumes that we want to unmute the monitor.
24
- remote_monitor = Monitor.find(monitor.id)
25
- self.silenced = remote_monitor.options.silenced if remote_monitor.options
26
- end
27
- super
28
- end
29
18
  end
30
19
 
31
20
  attribute :id, Integer
@@ -65,12 +54,6 @@ module Doggy
65
54
  end
66
55
  end
67
56
 
68
- def initialize(attributes = nil)
69
- super(attributes)
70
-
71
- options.monitor = self if options
72
- end
73
-
74
57
  def managed?
75
58
  !(name =~ Doggy::DOG_SKIP_REGEX)
76
59
  end
@@ -85,14 +68,15 @@ module Doggy
85
68
  ensure_renotify_interval_valid
86
69
  end
87
70
 
88
- def mute
89
- return unless id
90
- request(:post, "#{ resource_url(id) }/mute")
91
- end
92
-
93
- def unmute
94
- return unless id
95
- request(:post, "#{ resource_url(id) }/unmute")
71
+ def toggle_mute!(action)
72
+ return unless ['mute', 'unmute'].include?(action) && id
73
+ attributes = request(:post, "#{ resource_url(id) }/#{action}")
74
+ if message = attributes['errors']
75
+ Doggy.ui.error(message)
76
+ else
77
+ self.attributes = attributes
78
+ save_local
79
+ end
96
80
  end
97
81
 
98
82
  def human_url
data/lib/doggy/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # encoding: utf-8
2
2
 
3
3
  module Doggy
4
- VERSION = "2.0.30"
4
+ VERSION = "2.0.31"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: doggy
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.30
4
+ version: 2.0.31
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vlad Gorodetsky
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2016-11-28 00:00:00.000000000 Z
12
+ date: 2016-12-02 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: json
@@ -147,6 +147,7 @@ files:
147
147
  - exe/doggy
148
148
  - lib/doggy.rb
149
149
  - lib/doggy/cli.rb
150
+ - lib/doggy/cli/delete.rb
150
151
  - lib/doggy/cli/edit.rb
151
152
  - lib/doggy/cli/mute.rb
152
153
  - lib/doggy/cli/pull.rb