bub_bot 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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