bub_bot 0.2.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.
@@ -0,0 +1,76 @@
1
+ require 'bub_bot/redis_connection'
2
+
3
+ class BubBot::ServerManager
4
+ ROOT_KEY = 'bub_server_list'.freeze
5
+
6
+ # Options:
7
+ # - duration (1.hour)
8
+ # - server_name (cannoli)
9
+ # - user (kevin)
10
+ #
11
+ # All options required. Do your own defaulting, you lazy bum!
12
+ def take(options)
13
+ puts 'takin'
14
+
15
+ server_name, duration, user = options.values_at(:server_name, :duration, :user)
16
+ expires_at = duration.from_now
17
+
18
+ data = {
19
+ 'user' => user,
20
+ 'expires_at' => expires_at
21
+ }
22
+ redis.hset(ROOT_KEY, server_name, data.to_json)
23
+
24
+ data.merge(server: server_name)
25
+ end
26
+
27
+ def release(server_name)
28
+ redis.hdel(ROOT_KEY, server_name)
29
+ end
30
+
31
+ def names
32
+ known_server_names
33
+ end
34
+
35
+ def list
36
+ claims = redis.hgetall(ROOT_KEY).to_h
37
+
38
+ known_server_names.each_with_object({}) do |server_name, claim_map|
39
+ claim_map[server_name] =
40
+ if claim = claims[server_name]
41
+ claim_data = JSON.parse(claim)
42
+ expires_at = DateTime.parse(claim_data['expires_at'])
43
+
44
+ # Filter out expired claims
45
+ if expires_at > Time.now
46
+ claim_data['expires_at'] = expires_at
47
+ claim_data
48
+ else
49
+ {}
50
+ end
51
+ else
52
+ {}
53
+ end
54
+ end
55
+ end
56
+
57
+ def claimed_by(username)
58
+ list
59
+ .select { |server, claim_data| claim_data['user'] == username }
60
+ .keys
61
+ end
62
+
63
+ def first_unclaimed
64
+ list.key({})
65
+ end
66
+
67
+ private
68
+
69
+ def known_server_names
70
+ BubBot.configuration.servers
71
+ end
72
+
73
+ def redis
74
+ BubBot::RedisConnection.instance
75
+ end
76
+ end
@@ -0,0 +1,15 @@
1
+ require 'singleton'
2
+
3
+ # Just a simple wrapper around a slack connection. Call .instance on this, then
4
+ # call anything you'd normally call on a Slack Client on object on this instead. Eg
5
+ # `Client.instance.chat_postMessage`.
6
+ class BubBot::Slack::Client
7
+ include Singleton
8
+ def method_missing(method, *args, &block)
9
+ client.respond_to?(method) ? client.public_send(method, *args, &block) : super
10
+ end
11
+
12
+ def client
13
+ @client ||= Slack::Web::Client.new(token: BubBot.configuration.bot_oauth_token)
14
+ end
15
+ end
@@ -0,0 +1,100 @@
1
+ require 'bub_bot/server_manager.rb'
2
+ require 'bub_bot/deploy_manager.rb'
3
+ require 'slack-ruby-client'
4
+
5
+ class BubBot::Slack::Command
6
+ def self.can_handle?(command)
7
+ aliases.include?(command)
8
+ end
9
+
10
+ def self.aliases
11
+ # Guess the command name from the class name
12
+ [self.name.demodulize.downcase]
13
+ end
14
+
15
+ def initialize(options)
16
+ @options = options
17
+ puts "initialized a command"
18
+ end
19
+
20
+ def run
21
+ name = self.class.name
22
+ raise "Your command #{name} needs to implement 'run'"
23
+ end
24
+
25
+ private
26
+
27
+ def servers
28
+ @@servers ||= BubBot::ServerManager.new
29
+ end
30
+
31
+ def deployer
32
+ @@deployer ||= BubBot::DeployManager.new
33
+ end
34
+
35
+ def client
36
+ BubBot::Slack::Client.instance
37
+ end
38
+
39
+ def source_user_id
40
+ @options['user']
41
+ end
42
+
43
+ def source_user_name
44
+ # TODO: cache these, since it's probably the same few people most of the time.
45
+ client.users_info(user: source_user_id)&.dig('user', 'name')
46
+ end
47
+
48
+ def tokens
49
+ @tokens ||= @options['text'].split(' ')
50
+ end
51
+
52
+ # Takes either a string or some options
53
+ def respond(options)
54
+ BubBot::Slack::Response.new(options, client)
55
+ end
56
+
57
+ def bot_name
58
+ BubBot.configuration.bot_name
59
+ end
60
+
61
+ # Returns an iterator over the token list that returns nil when out of tokens.
62
+ #
63
+ # Eg if the tokens are `aaa bbb ccc`:
64
+ #
65
+ # iterator = create_token_iterator
66
+ # iterator.next # aaa
67
+ # iterator.next # bbb
68
+ # iterator.next # ccc
69
+ # iterator.next # nil
70
+ #
71
+ # A good way to use this is for parsing order-agnostic commands:
72
+ #
73
+ # iterator = create_token_iterator
74
+ # while token = iterator.next
75
+ # if token == 'bake'
76
+ # recipe = iterator.next
77
+ # raise "bad recipe" unless %w(bread cookies).include?(recipe)
78
+ # bake(iterator.next)
79
+ # elsif token == 'order'
80
+ # raise 'missing type' unless food_type = iterator.next
81
+ # raise 'missing when' unless when = iterator.next
82
+ # order(food_type, when)
83
+ # end
84
+ # end
85
+ #
86
+ # Warning: don't use to_a on this iterator - it'll result in an infinite loop.
87
+ # In retrospect, this was a bad pattern. TODO: refactor this.
88
+ def create_token_iterator
89
+ unsafe_iterator = tokens.each
90
+
91
+ return Enumerator.new do |yielder|
92
+ loop do
93
+ yielder.yield unsafe_iterator.next
94
+ end
95
+ loop do
96
+ yielder.yield nil
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,37 @@
1
+ module BubBot::Slack
2
+ end
3
+
4
+ require 'bub_bot/slack/command'
5
+ Dir[File.dirname(__FILE__) + '/commands/*.rb'].each {|file| require file }
6
+
7
+ class BubBot::Slack::CommandParser
8
+ def self.get_command(string_input)
9
+ puts "Parsing #{string_input}"
10
+ #puts "options: #{command_classes}"
11
+
12
+ # Strip the bot name out
13
+ string_input.sub!(/^#{BubBot.configuration.bot_name} /, '')
14
+
15
+ command = string_input.split(' ').first
16
+
17
+ if command
18
+ command_classes.find do |command_class|
19
+ command_class.can_handle?(command)
20
+ end
21
+ else
22
+ nil
23
+ end
24
+ end
25
+
26
+ def self.command_classes
27
+
28
+ # Get all the classes under BubBot::Slack::Command::*
29
+ @_command_classes ||= BubBot::Slack::Command.constants.map do |constant|
30
+ (BubBot::Slack::Command.to_s + "::" + constant.to_s).safe_constantize
31
+ end.select do |constant|
32
+
33
+ # Get only the constants that are classes and inherit from Command
34
+ constant.is_a?(Class) && constant < BubBot::Slack::Command
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,89 @@
1
+ require 'action_view'
2
+ require 'action_view/helpers'
3
+
4
+ class BubBot::Slack::Command::Claim < BubBot::Slack::Command
5
+ include ActionView::Helpers::DateHelper
6
+
7
+ DEFAULT_DURATION = 1.hour.freeze
8
+ INCREMENTS = %w(minute hour day week month)
9
+
10
+ # bub take cannoli for 20 minutes deploy master
11
+ def self.aliases
12
+ %w(claim take gimmee canhaz)
13
+ end
14
+
15
+ def run
16
+ server = duration = deploy = nil
17
+ iterator = create_token_iterator
18
+
19
+ # Skip the command name itself
20
+ iterator.next
21
+
22
+ while token = iterator.peek
23
+ if token == 'for'
24
+ puts 'got for'
25
+ iterator.next
26
+ duration = parse_duration(iterator)
27
+ elsif token.to_i > 0
28
+ puts 'got int'
29
+ duration = parse_duration(iterator)
30
+ elsif servers.names.include?(token)
31
+ puts 'got server'
32
+ server = iterator.next
33
+ elsif token == 'deploy'
34
+ puts 'got deploy'
35
+ raise RespondableError.new('Use the new deploy command to deploy, not the take command.')
36
+ else
37
+ raise RespondableError.new("I'm not sure what '#{token}' means.")
38
+ end
39
+ end
40
+
41
+ # Default to the first unclaimed server if no server was specified. If there
42
+ # are no unclaimed servers, error.
43
+ unless server
44
+ unless server = servers.first_unclaimed
45
+ raise RespondableError.new("No available servers.")
46
+ end
47
+ end
48
+
49
+ duration ||= DEFAULT_DURATION
50
+
51
+ take_options = {
52
+ server_name: server,
53
+ duration: duration,
54
+ user: source_user_name
55
+ }.compact
56
+
57
+ result = servers.take(take_options)
58
+
59
+ time_ago = time_ago_in_words(result['expires_at'])
60
+ respond("#{source_user_name} has #{server} for the next #{time_ago}")
61
+ end
62
+
63
+ private
64
+
65
+ def parse_duration(iterator)
66
+ value = iterator.next.to_i
67
+ unless increment = iterator.next
68
+ raise RespondableError.new("Missing increment. Do you mean '#{value} hours'?")
69
+ end
70
+ unless valid_increments.include?(increment)
71
+ raise RespondableError.new("I don't know the increment '#{increment}'. Try one of these instead: #{INCREMENTS.join(', ')}")
72
+ end
73
+
74
+ # 5.minutes
75
+ value.public_send(increment)
76
+ end
77
+
78
+ def should_deploy?
79
+ false # todo: parse from command, also support buttons
80
+ end
81
+
82
+ def valid_increments
83
+ @@valid_increments ||= INCREMENTS.reduce([]) do |valid, increment|
84
+ valid << increment + 's'
85
+ valid << increment
86
+ valid
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,123 @@
1
+ class BubBot::Slack::Command::Deploy < BubBot::Slack::Command
2
+ def self.aliases
3
+ %w(deploy ship push)
4
+ end
5
+
6
+ # bub deploy cannoli core kk_foo
7
+ def run
8
+ puts 'deploying'
9
+ server = nil
10
+ deploys = {}
11
+ iterator = create_token_iterator
12
+
13
+ # Skip the command name itself
14
+ iterator.next
15
+
16
+ while token = iterator.peek
17
+ puts "token loop #{token}"
18
+ if token == 'and'
19
+ iterator.next
20
+ next
21
+ end
22
+
23
+ # TODO: strip trailing commas (need to do this in the iterator)
24
+ #token = token.sub!(/,$/, '')
25
+
26
+ if servers.names.include?(token)
27
+ puts 'servers.names'
28
+ server = iterator.next
29
+
30
+ # Handle 'kk_some_fix [to] core'
31
+ elsif branches.include?(token)
32
+ puts 'branches.include'
33
+
34
+ branch = iterator.next
35
+ target = iterator.next
36
+
37
+ # Skip connector words
38
+ target = iterator.next if %w(to on with).include?(target)
39
+
40
+ deploys[target] = branch
41
+
42
+ # Handle 'core kk_some_fix'
43
+ elsif targets.include?(token)
44
+ puts 'else targets.inclue'
45
+ target = iterator.next
46
+ branch = iterator.next
47
+ deploys[target] = branch
48
+ else
49
+ raise RespondableError.new("I didn't recognize #{token}")
50
+ end
51
+ end
52
+
53
+ # Validate all the potential deploys before we start
54
+ deploys.each do |target, branch|
55
+ puts 'deploys.each'
56
+ # TODO: infer targets, etc
57
+ unless targets.include?(target)
58
+ raise RespondableError.new("Unknown deploy target #{token}. Try one of #{targets.join(', ')}")
59
+ end
60
+ unless branches(target).include?(branch)
61
+ raise RespondableError.new("Deploy target #{target} doesn't have a branch named #{branch}. Maybe you forgot to push that branch?")
62
+ end
63
+ end
64
+
65
+ server = servers.first_unclaimed unless server
66
+
67
+ unless server
68
+ return respond('No servers available')
69
+ end
70
+
71
+ unless deploys.any?
72
+ return respond("Please specify a target and a branch, eg `#{bot_name} deploy #{server} core kk_add_lasers`");
73
+ end
74
+
75
+ # TODO:
76
+ # - default to deploying develop
77
+
78
+ claim_data = servers.list[server]
79
+
80
+ if claim_data['user'] && claim_data['user'] != source_user_name
81
+ raise RespondableError.new("Server already claimed by #{claim_data['user']}. Use the 'take' command first to override their claim.")
82
+ elsif
83
+ # Extend our current claim on this server to 1 hour from now unless we
84
+ # already have it claimed for longer
85
+ if !claim_data['expires_at'] || claim_data['expires_at'] < 1.hour.from_now
86
+ servers.take(
87
+ user: source_user_name,
88
+ duration: 1.hour,
89
+ server_name: server
90
+ )
91
+ end
92
+ end
93
+
94
+ message_segments = deploys.map do |target, branch|
95
+ "branch '#{branch}' to target '#{target}'"
96
+ end
97
+ respond("Deploying on server #{server}: #{message_segments.join('; ')}").deliver
98
+
99
+ deploys.each do |target, branch|
100
+ deployer.deploy(server, target, branch)
101
+ end
102
+
103
+ respond("Finished deploying on server #{server}: #{message_segments.join('; ')}");
104
+ end
105
+
106
+ # All known branches for the given target. Returns all branches for *all*
107
+ # targets if target is nil.
108
+ def branches(target = nil)
109
+ puts 'deploy.branches'
110
+ @_branch_cache ||= {}
111
+ if target == nil
112
+ targets.flat_map do |target|
113
+ branches(target)
114
+ end
115
+ else
116
+ @_branch_cache[target] ||= deployer.branches(target)
117
+ end
118
+ end
119
+
120
+ def targets
121
+ deployer.target_names
122
+ end
123
+ end
@@ -0,0 +1,5 @@
1
+ class BubBot::Slack::Command::Echo < BubBot::Slack::Command
2
+ def run
3
+ respond(@options[:text])
4
+ end
5
+ end
@@ -0,0 +1,26 @@
1
+ require 'action_view'
2
+ require 'action_view/helpers'
3
+
4
+ class BubBot::Slack::Command::List < BubBot::Slack::Command
5
+ include ActionView::Helpers::DateHelper
6
+
7
+ def self.aliases
8
+ %w(list status all wazup)
9
+ end
10
+
11
+ def run
12
+ list_strings = servers.list.map do |server, claim|
13
+ if claim['expires_at']
14
+ time_ago = time_ago_in_words(claim['expires_at'])
15
+ "#{server}: *#{claim['user']}'s* for the next #{time_ago}"
16
+ else
17
+ "#{server}: *free*"
18
+ end
19
+ end
20
+
21
+ respond(list_strings.join("\n"))
22
+ end
23
+
24
+ private
25
+
26
+ end
@@ -0,0 +1,26 @@
1
+ class BubBot::Slack::Command::Release < BubBot::Slack::Command
2
+ def run
3
+ puts "Running release"
4
+ servers_to_release = tokens.drop(1)
5
+ puts "servers_to_release: #{servers_to_release}"
6
+
7
+ my_servers = servers.claimed_by(source_user_name)
8
+ servers_to_release =
9
+ if servers_to_release.empty?
10
+ my_servers
11
+ else
12
+ servers_to_release & my_servers
13
+ end
14
+
15
+ if (unknown_servers = servers_to_release - servers.names).any?
16
+ raise RespondableError.new("Unknown server(s): #{unknown_servers.join(', ')}. Nothing released.")
17
+ end
18
+
19
+ servers_to_release.each do |server|
20
+ servers.release(server)
21
+ end
22
+
23
+ released = servers_to_release.any? ? servers_to_release.join(', ') : 'nothing'
24
+ respond("Released #{released}")
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ class BubBot::Slack::Response
2
+ attr_accessor :text
3
+
4
+ def initialize(options, client)
5
+ @text = options.is_a?(String) ? options : options[:text]
6
+ @client = client
7
+ end
8
+
9
+ def deliver
10
+ body = {
11
+ text: text,
12
+ username: BubBot.configuration.bot_name
13
+ }
14
+ # TODO: configure channel
15
+ @client.chat_postMessage(channel: '#' + channel, text: text, as_user: true)
16
+ end
17
+
18
+ def channel
19
+ BubBot.configuration.slack_channel
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ module BubBot
2
+ VERSION = "0.2.1"
3
+ end
@@ -0,0 +1,92 @@
1
+ require 'bub_bot/slack/command_parser'
2
+ require 'bub_bot/slack/response'
3
+ require 'bub_bot/slack/client'
4
+ require 'faraday'
5
+
6
+ class BubError < StandardError
7
+ end
8
+
9
+ # An error that should should result in a user-facing response (and not a non-200
10
+ # http response code)
11
+ class RespondableError < StandardError
12
+ end
13
+
14
+ class BubBot::WebServer
15
+ def call(env)
16
+ puts ' --- Got request ---'
17
+
18
+ # Ignore retries for now.
19
+ if env['HTTP_X_SLACK_RETRY_NUM']
20
+ puts "Ignoring retry: #{env['HTTP_X_SLACK_RETRY_NUM']}, because #{env['HTTP_X_SLACK_RETRY_REASON']}"
21
+ return [200, {}, ['ok']]
22
+ end
23
+
24
+ request = Rack::Request.new(env)
25
+
26
+ # For easily checking if the server's up
27
+ if request.path == '/' && request.get?
28
+ return [200, {}, ['ok']]
29
+
30
+ # When slack sends us a challenge request
31
+ elsif request.path == '/' && request.post?
32
+ params = parse_params(request)
33
+ return [200, {}, [params[:challenge]]] if params[:challenge]
34
+
35
+ event = params[:event]
36
+
37
+ # Skip messages from bots
38
+ return [200, {}, []] if event[:subtype] == 'bot_message'
39
+
40
+ # Make sure this is in the form of 'bub foo'
41
+ unless event[:text].starts_with?(BubBot.configuration.bot_name + ' ')
42
+ puts "skipping non-bub message"
43
+ return [200, {}, []]
44
+ end
45
+
46
+ command = BubBot::Slack::CommandParser.get_command(event[:text])
47
+
48
+ puts " --- Running command #{command}"
49
+
50
+ # Slack will retry any message that takes longer than 3 seconds to complete,
51
+ # so do all message processing in a thread.
52
+ command_thread = Thread.new do
53
+ response =
54
+ begin
55
+ if command
56
+ command.new(event).run
57
+ else
58
+ BubBot::Slack::Response.new("unknown command", slack_client)
59
+ end
60
+ rescue RespondableError => e
61
+ BubBot::Slack::Response.new(e.message, slack_client)
62
+ end
63
+
64
+ response.deliver
65
+ end
66
+ command_thread.abort_on_exception = true
67
+
68
+ return [200, {}, []]
69
+
70
+ #elsif request.path == '/heroku_hook' && request.post?
71
+ #HerokuInterface.new.handle_heroku_webhook(request.body.read)
72
+ #return [200, {}, []]
73
+ else
74
+ raise BubError, "Failed request: #{request.request_method} #{request.path}"
75
+ err 'invalid request'
76
+ end
77
+ rescue BubError => e
78
+ puts "Err: #{e.message}"
79
+ return [400, {}, [e.message]]
80
+ end
81
+
82
+ private
83
+
84
+ def parse_params(request)
85
+ JSON.parse(request.body.read)
86
+ .with_indifferent_access
87
+ end
88
+
89
+ def slack_client
90
+ BubBot::Slack::Client.instance
91
+ end
92
+ end
data/lib/bub_bot.rb ADDED
@@ -0,0 +1,54 @@
1
+ require 'rack'
2
+ require 'bub_bot/version'
3
+ require 'bub_bot/web_server'
4
+ require 'bub_bot/configuration'
5
+ require 'bub_bot/cli'
6
+ require 'pry-byebug'
7
+ require 'active_support'
8
+ require 'active_support/core_ext'
9
+
10
+ module BubBot
11
+ class << self
12
+ attr_accessor :configuration
13
+ # From a config.ru file you can do `run BubBot`. TODO: maybe not. That would
14
+ # skip the running background thread.
15
+ #
16
+ # Handle an individual web request. You shouldn't call this method directly.
17
+ # Instead, give BubBot to Rack and let it call this method.
18
+ def call(env)
19
+ (@web_server ||= WebServer.new).call(env)
20
+ end
21
+
22
+ # This method starts a listening web server. Call from the cli or wherever
23
+ # else you want to kick off a running BubBot process.
24
+ def start
25
+ puts 'Booting BubBot'
26
+ Thread.new do
27
+ loop do
28
+ #puts "Checking for servers to shutdown"
29
+ # TODO: actually do that ^
30
+ sleep 10# * 60
31
+ end
32
+ end
33
+
34
+ app = Rack::Builder.new do
35
+ # if development (TODO)
36
+ use Rack::Reloader
37
+ # end
38
+ run BubBot
39
+ end.to_app
40
+
41
+ Rack::Handler::Thin.run(app, BubBot.configuration.rack_options_hash)
42
+ end
43
+
44
+ # Used for setting config options:
45
+ # BubBot.configure do |config|
46
+ # config.bot_name 'lillian'
47
+ # config.redis_host 'localhost:6379'
48
+ # end
49
+ def configure
50
+ self.configuration ||= Configuration.new
51
+ yield configuration
52
+ end
53
+ end
54
+ end