├── .rspec ├── lib ├── cleric │ ├── version.rb │ ├── repo_manager.rb │ ├── user_manager.rb │ ├── hipchat_announcer.rb │ ├── repo_auditor.rb │ ├── console_announcer.rb │ ├── cli_configuration_provider.rb │ ├── cli.rb │ └── github_agent.rb └── cleric.rb ├── .gitignore ├── bin └── cleric ├── Guardfile ├── Gemfile ├── spec ├── spec_helper.rb └── cleric │ ├── user_manager_spec.rb │ ├── repo_manager_spec.rb │ ├── hipchat_announcer_spec.rb │ ├── cli_configuration_provider_spec.rb │ ├── console_announcer_spec.rb │ ├── repo_auditor_spec.rb │ ├── cli_spec.rb │ └── github_agent_spec.rb ├── cleric.gemspec ├── LICENSE └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /lib/cleric/version.rb: -------------------------------------------------------------------------------- 1 | module Cleric 2 | VERSION = '0.3.0' 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .rvmrc 2 | Gemfile.lock 3 | tmp/ 4 | .ruby-version 5 | .ruby-gemset 6 | -------------------------------------------------------------------------------- /bin/cleric: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'rubygems' 4 | require 'bundler/setup' 5 | 6 | require 'cleric' 7 | 8 | Cleric::CLI.start(ARGV) 9 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rspec do 2 | watch(%r{^spec/.+_spec\.rb$}) 3 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 4 | watch('spec/spec_helper.rb') { 'spec' } 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :test do 6 | gem 'rspec', '~> 2.13' 7 | end 8 | 9 | group :development do 10 | gem 'guard-rspec', '~> 3.0' 11 | end 12 | -------------------------------------------------------------------------------- /lib/cleric.rb: -------------------------------------------------------------------------------- 1 | require 'ansi/code' 2 | require 'git' 3 | require 'hipchat-api' 4 | require 'octokit' 5 | 6 | require 'cleric/cli' 7 | require 'cleric/cli_configuration_provider' 8 | require 'cleric/console_announcer' 9 | require 'cleric/hipchat_announcer' 10 | require 'cleric/github_agent' 11 | require 'cleric/repo_auditor' 12 | require 'cleric/repo_manager' 13 | require 'cleric/user_manager' 14 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'cleric' 2 | 3 | RSpec.configure do |config| 4 | config.treat_symbols_as_metadata_keys_with_true_values = true 5 | config.run_all_when_everything_filtered = true 6 | config.filter_run :focus 7 | 8 | # Run specs in random order to surface order dependencies. If you find an 9 | # order dependency and want to debug it, you can fix the order by providing 10 | # the seed, which is printed after each run. 11 | # --seed 1234 12 | config.order = 'random' 13 | end 14 | -------------------------------------------------------------------------------- /cleric.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path('../lib/cleric/version', __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'cleric' 5 | s.version = Cleric::VERSION 6 | s.platform = Gem::Platform::RUBY 7 | 8 | s.authors = ['Andrew Smith'] 9 | s.email = ['asmith@mdsol.com'] 10 | s.summary = 'Administration tools for the lazy software development manager.' 11 | s.homepage = 'https://github.com/mdsol/cleric' 12 | 13 | s.files = Dir['lib/**/*'] 14 | s.test_files = Dir['spec/**/*'] 15 | s.executables = Dir['bin/*'].map {|f| File.basename(f) } 16 | s.require_paths = ['lib'] 17 | 18 | s.add_dependency 'ansi', '~> 1.4' 19 | s.add_dependency 'hipchat-api', '~> 1.0' 20 | s.add_dependency 'git', '~> 1.2' 21 | s.add_dependency 'octokit', '~> 1.24' 22 | s.add_dependency 'thor', '~> 0.18' 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Medidata Solutions Worldwide 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /lib/cleric/repo_manager.rb: -------------------------------------------------------------------------------- 1 | module Cleric 2 | 3 | # Provides services for managing repos. Coordinates activities between 4 | # different service agents. 5 | class RepoManager 6 | def initialize(repo_agent, listener) 7 | @repo_agent = repo_agent 8 | @listener = listener 9 | end 10 | 11 | def create(name, team, options) 12 | @repo_agent.create_repo(name, @listener) 13 | 14 | add_team(name, team) 15 | optionally_add_chatroom(name, options) 16 | end 17 | 18 | def update(name, options) 19 | optionally_add_team(name, options) 20 | optionally_add_chatroom(name, options) 21 | end 22 | 23 | private 24 | 25 | def add_chatroom(repo_name, chatroom) 26 | @repo_agent.add_chatroom_to_repo(repo_name, chatroom, @listener) 27 | end 28 | 29 | def add_team(repo_name, team) 30 | @repo_agent.add_repo_to_team(repo_name, team, @listener) 31 | end 32 | 33 | def optionally_add_chatroom(repo_name, options) 34 | add_chatroom(repo_name, options[:chatroom]) if options[:chatroom] 35 | end 36 | 37 | def optionally_add_team(repo_name, options) 38 | add_team(repo_name, options[:team].to_i) if options[:team] 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/cleric/user_manager.rb: -------------------------------------------------------------------------------- 1 | module Cleric 2 | 3 | # Provides services for managing users. Coordinates activities between 4 | # different service agents. 5 | class UserManager 6 | def initialize(config, listener) 7 | @config = config 8 | @listener = listener 9 | end 10 | 11 | # Remove the user from the organization. 12 | # @param email [String] The user's email. 13 | # @param org [String] The name of the organization. 14 | def remove(username, org) 15 | repo_agent.remove_user_from_org(username, org, @listener) 16 | end 17 | 18 | # Callback invoked on failure by the repo agent's #verify_user_public_email 19 | # method. 20 | def verify_user_public_email_failure 21 | throw :verification_failed 22 | end 23 | 24 | # Verify the named user has the given email and then assigns them to the 25 | # team. 26 | # @param username [String] The user's username in the repo system. 27 | # @param email [String] The user's email. 28 | # @param team [String] The numeric id of the team in the repo system. 29 | def welcome(username, email, team) 30 | # Passing self as listener on verification, so on failure our own 31 | # #verify_user_public_email_failure method is called, hence the following 32 | # catch. 33 | catch :verification_failed do 34 | repo_agent.verify_user_public_email(username, email, self) 35 | repo_agent.add_user_to_team(username, team, @listener) 36 | end 37 | end 38 | 39 | def repo_agent 40 | @repo_agent ||= @config.repo_agent 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/cleric/hipchat_announcer.rb: -------------------------------------------------------------------------------- 1 | module Cleric 2 | class HipChatAnnouncer 3 | def initialize(config, listener, user) 4 | @config = config 5 | @listener = listener 6 | @user = user 7 | end 8 | 9 | def chatroom_added_to_repo(repo, chatroom) 10 | @listener.chatroom_added_to_repo(repo, chatroom) 11 | send_message(%Q[Repo "#{repo}" notifications will be sent to chatroom "#{chatroom}"]) 12 | end 13 | 14 | def repo_added_to_team(repo, team) 15 | @listener.repo_added_to_team(repo, team) 16 | send_message(%Q[Repo "#{repo}" added to team "#{team}"]) 17 | end 18 | 19 | def repo_created(repo) 20 | @listener.repo_created(repo) 21 | send_message(%Q[Repo "#{repo}" created]) 22 | end 23 | 24 | def user_added_to_team(username, team) 25 | @listener.user_added_to_team(username, team) 26 | send_message(%Q[User "#{username}" added to team "#{team}"]) 27 | end 28 | 29 | def user_not_found(email) 30 | @listener.user_not_found(email) 31 | end 32 | 33 | def user_removed_from_org(username, email, org) 34 | @listener.user_removed_from_org(username, email, org) 35 | send_message(%Q[User "#{username}" (#{email}) removed from organization "#{org}"], :red) 36 | end 37 | 38 | private 39 | 40 | def hipchat 41 | @hipchat ||= HipChat::API.new(@config.hipchat_api_token) 42 | end 43 | 44 | def send_message(description, color = :green) 45 | hipchat.rooms_message( 46 | @config.hipchat_announcement_room_id, 'Cleric', %Q[Admin "#{@user}": #{description}], 0, color.to_s, 'text' 47 | ) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/cleric/user_manager_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Cleric 4 | describe UserManager do 5 | subject(:manager) { UserManager.new(config, listener) } 6 | let(:config) { double('Config', repo_agent: repo_agent).as_null_object } 7 | let(:listener) { double('Listener') } 8 | let(:repo_agent) { double('RepoAgent').as_null_object } 9 | 10 | describe '#remove' do 11 | after(:each) { manager.remove('user@example.com', 'an_org') } 12 | 13 | it 'tells the repo agent to remove the user from the org' do 14 | repo_agent.should_receive(:remove_user_from_org).with('user@example.com', 'an_org', listener) 15 | end 16 | end 17 | 18 | describe '#welcome' do 19 | after(:each) { manager.welcome('a_user', 'user@example.com', 'a_team') } 20 | 21 | it 'tells the repo agent to verify the user public email address' do 22 | repo_agent.should_receive(:verify_user_public_email) 23 | .with('a_user', 'user@example.com', manager) 24 | end 25 | it 'tells the repo agent to add the user to the team' do 26 | repo_agent.should_receive(:add_user_to_team).with('a_user', 'a_team', listener) 27 | end 28 | 29 | context 'when the email verification fails' do 30 | before(:each) { repo_agent.stub(:verify_user_public_email) { throw :verification_failed } } 31 | 32 | it 'does not tell the repo agent to add the user to the team' do 33 | repo_agent.should_not_receive(:add_user_to_team) 34 | end 35 | end 36 | end 37 | 38 | describe '#verify_user_public_email_failure' do 39 | it 'raises an throws a verification failure' do 40 | expect { manager.verify_user_public_email_failure }.to throw_symbol(:verification_failed) 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/cleric/repo_auditor.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | 3 | module Cleric 4 | 5 | # Provides services for auditing repositories. Coordinates activities between 6 | # local repositories and different service agents, e.g. GitHub. 7 | class RepoAuditor 8 | def initialize(config, listener) 9 | @config = config 10 | @listener = listener 11 | end 12 | 13 | # Identifies commits present in the named repo that do not have 14 | # corresponding pull requests. Assumes a working directory is currently a 15 | # local copy of the repo. 16 | def audit_repo(name) 17 | git = Git.open('.') 18 | 19 | @listener.repo_fetching_latest_changes 20 | git.fetch 21 | 22 | ranges = repo_agent.repo_pull_request_ranges(name) 23 | 24 | commits_with_pr = ranges.inject(Set.new) do |set, range| 25 | set.merge(commits_in_range(git, range)) 26 | end 27 | 28 | # Passing `nil` to `log` to ensure all commits are returned. 29 | commits_without_pr = git.log(nil) 30 | .select {|commit| commit.parents.size == 1 && !commits_with_pr.include?(commit.sha) } 31 | .map {|commit| commit.sha } 32 | 33 | if commits_without_pr.empty? 34 | @listener.repo_audit_passed(name) 35 | else 36 | @listener.commits_without_pull_requests(name, commits_without_pr) 37 | end 38 | end 39 | 40 | private 41 | 42 | def repo_agent 43 | @repo_agent ||= @config.repo_agent 44 | end 45 | 46 | def commits_in_range(git, range) 47 | begin 48 | git.log(nil).between(range[:base], range[:head]).map {|commit| commit.sha } 49 | rescue StandardError => e 50 | @listener.repo_obsolete_pull_request(range[:pr_number], range[:base], range[:head]) 51 | [] 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/cleric/console_announcer.rb: -------------------------------------------------------------------------------- 1 | module Cleric 2 | class ConsoleAnnouncer 3 | include ANSI::Code 4 | 5 | def initialize(io) 6 | @io = io 7 | end 8 | 9 | def chatroom_added_to_repo(repo, chatroom) 10 | write_success(%Q[Repo "#{repo}" notifications will be sent to chatroom "#{chatroom}"]) 11 | end 12 | 13 | def commits_without_pull_requests(repo, commits) 14 | write_failure(%Q[Repo "#{repo}" has the following commits not covered by pull requests:\n] + 15 | commits.join("\n")) 16 | end 17 | 18 | def repo_added_to_team(repo, team) 19 | write_success(%Q[Repo "#{repo}" added to team "#{team}"]) 20 | end 21 | 22 | def repo_audit_passed(repo) 23 | write_success(%Q[Repo "#{repo}" passed audit]) 24 | end 25 | 26 | def repo_created(repo) 27 | write_success(%Q[Repo "#{repo}" created]) 28 | end 29 | 30 | def repo_fetching_latest_changes 31 | write_action("Fetching latest changes for local repo") 32 | end 33 | 34 | def repo_obsolete_pull_request(pr_number, base, head) 35 | write_warning(%Q[Commits #{base}..#{head} in pull request #{pr_number} are no longer present in the repo]) 36 | end 37 | 38 | def user_added_to_team(username, team) 39 | write_success(%Q[User "#{username}" added to team "#{team}"]) 40 | end 41 | 42 | def user_not_found(email) 43 | write_failure(%Q[User "#{email}" not found]) 44 | end 45 | 46 | def user_removed_from_org(username, email, org) 47 | write_success(%Q[User "#{username}" (#{email}) removed from organization "#{org}"]) 48 | end 49 | 50 | private 51 | 52 | def write_action(message) 53 | @io.puts(message) 54 | end 55 | 56 | def write_failure(message) 57 | @io.puts(red { message }) 58 | end 59 | 60 | def write_success(message) 61 | @io.puts(green { message }) 62 | end 63 | 64 | def write_warning(message) 65 | @io.puts(yellow { message }) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/cleric/cli_configuration_provider.rb: -------------------------------------------------------------------------------- 1 | module Cleric 2 | class CLIConfigurationProvider 3 | def initialize(filename = File.expand_path('~/.clericrc')) 4 | @filename = filename 5 | end 6 | 7 | # Returns GitHub credentials. 8 | # @return [Hash] Hash of credentials, e.g. `{ login: 'me', password: 'secret'}` 9 | def github_credentials 10 | if github = config['github'] 11 | { login: github['login'], oauth_token: github['oauth_token'] } 12 | else 13 | { login: ask("GitHub login"), password: ask("GitHub password", silent: true) } 14 | end 15 | end 16 | 17 | # Saves the GitHub credentials. 18 | # @param values [Hash] Hash of credentials, e.g. `{ login: 'me', oauth_token: 'abc123'}` 19 | def github_credentials=(values) 20 | config['github'] = { 'login' => values[:login], 'oauth_token' => values[:oauth_token] } 21 | File.open(@filename, 'w') {|file| file.write(YAML::dump(config)) } 22 | end 23 | 24 | # Returns HipChat announcement room id. 25 | # @return [String] The room id, either numeric or the room name. 26 | def hipchat_announcement_room_id 27 | config['hipchat']['announcement_room_id'] 28 | end 29 | 30 | # Returns HipChat API token. 31 | # @return [String] The API token. 32 | def hipchat_api_token 33 | config['hipchat']['api_token'] 34 | end 35 | 36 | # Returns repo HipChat API token, i.e. for notifications from repos to 37 | # chatrooms. 38 | # @return [String] The API token. 39 | def hipchat_repo_api_token 40 | config['hipchat']['repo_api_token'] 41 | end 42 | 43 | # Returns a configured repo agent. 44 | # @return [GitHubAgent] A GitHub repo agent. 45 | def repo_agent 46 | @repo_agent ||= GitHubAgent.new(self) 47 | end 48 | 49 | private 50 | 51 | def ask(prompt, options={}) 52 | $stdout.print "#{prompt}: " 53 | if options[:silent] 54 | require 'io/console' 55 | $stdin.noecho {|io| io.readline} 56 | else 57 | $stdin.readline 58 | end.chomp 59 | end 60 | 61 | def config 62 | @config ||= load_config 63 | end 64 | 65 | def load_config 66 | File.exists?(@filename) ? YAML::load(File.open(@filename)) : {} 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/cleric/repo_manager_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Cleric 4 | describe RepoManager do 5 | subject(:manager) { RepoManager.new(repo_agent, listener) } 6 | let(:repo_agent) { double('RepoAgent').as_null_object } 7 | let(:listener) { double('Listener').as_null_object } 8 | 9 | describe '#create' do 10 | let(:chatroom) { 'my_room' } 11 | 12 | after(:each) { manager.create('my_org/my_repo', 123, chatroom: chatroom) } 13 | 14 | it 'tells the configured repo agent to create the repo' do 15 | repo_agent.should_receive(:create_repo).with('my_org/my_repo', listener) 16 | end 17 | it 'tells the agent to add the repo to the team' do 18 | repo_agent.should_receive(:add_repo_to_team).with('my_org/my_repo', 123, listener) 19 | end 20 | 21 | context 'when passed a chatroom' do 22 | it 'tells the agent to add chatroom notification to the repo' do 23 | repo_agent.should_receive(:add_chatroom_to_repo).with('my_org/my_repo', 'my_room', listener) 24 | end 25 | end 26 | 27 | context 'when passed a nil chatroom' do 28 | let(:chatroom) { nil } 29 | 30 | it 'does not tell the agent to add chatroom notification to the repo' do 31 | repo_agent.should_not_receive(:add_chatroom_to_repo) 32 | end 33 | end 34 | end 35 | 36 | describe '#update' do 37 | let(:options) { Hash.new } 38 | 39 | after(:each) { manager.update('my_org/my_repo', options) } 40 | 41 | context 'when passed a team' do 42 | let(:options) { { team: '123' } } 43 | 44 | it 'tells the agent to add the repo to the team' do 45 | repo_agent.should_receive(:add_repo_to_team).with('my_org/my_repo', 123, listener) 46 | end 47 | end 48 | 49 | context 'when passed a nil team' do 50 | it 'does not tell the agent to add the repo to the team' do 51 | repo_agent.should_not_receive(:add_repo_to_team) 52 | end 53 | end 54 | 55 | context 'when passed a chatroom' do 56 | let(:options) { { chatroom: 'my_room' } } 57 | 58 | it 'tells the agent to add chatroom notification to the repo' do 59 | repo_agent.should_receive(:add_chatroom_to_repo).with('my_org/my_repo', 'my_room', listener) 60 | end 61 | end 62 | 63 | context 'when passed a nil chatroom' do 64 | it 'does not tell the agent to add chatroom notification to the repo' do 65 | repo_agent.should_not_receive(:add_chatroom_to_repo) 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/cleric/cli.rb: -------------------------------------------------------------------------------- 1 | require 'thor' 2 | 3 | module Cleric 4 | module CLIDefaults 5 | def config 6 | @config ||= CLIConfigurationProvider.new 7 | end 8 | def github 9 | @github ||= GitHubAgent.new(config) 10 | end 11 | def console 12 | @console ||= ConsoleAnnouncer.new($stdout) 13 | end 14 | def hipchat 15 | @hipchat ||= HipChatAnnouncer.new(config, console, github.login) 16 | end 17 | end 18 | 19 | class Repo < Thor 20 | include CLIDefaults 21 | 22 | desc 'audit ', 'Audit the repo for stray commits' 23 | def audit(name) 24 | auditor = RepoAuditor.new(config, console) 25 | auditor.audit_repo(name) 26 | end 27 | 28 | desc 'create ', 'Create the repo and assign a team' 29 | option :team, type: :numeric, required: true, 30 | desc: 'The team\'s numerical id' 31 | option :chatroom, type: :string, 32 | desc: 'Send repo notifications to the chatroom with this name or id' 33 | def create(name) 34 | manager = RepoManager.new(github, hipchat) 35 | manager.create(name, options[:team], options) 36 | end 37 | 38 | desc 'update ', 'Update the repo ' 39 | option :chatroom, type: :string, 40 | desc: 'Send repo notifications to the chatroom with this name or id' 41 | option :team, type: :numeric, 42 | desc: 'The team\'s numerical id' 43 | def update(name) 44 | manager = RepoManager.new(github, hipchat) 45 | manager.update(name, options) 46 | end 47 | end 48 | 49 | class User < Thor 50 | include CLIDefaults 51 | 52 | desc 'remove ', 'Remove the user from all teams in the organization' 53 | def remove(email, org) 54 | manager.remove(email, org) 55 | end 56 | 57 | desc 'welcome ', 'Add the existing user to a team and chat' 58 | option :email, required: true, 59 | desc: 'The user\'s email address' 60 | option :team, type: :numeric, required: true, 61 | desc: 'The team\'s numerical id' 62 | def welcome(username) 63 | manager.welcome(username, options[:email], options[:team]) 64 | end 65 | 66 | private 67 | 68 | def manager 69 | manager = UserManager.new(config, hipchat) 70 | end 71 | end 72 | 73 | class CLI < Thor 74 | include CLIDefaults 75 | 76 | desc 'auth', 'Create auth tokens to avoid repeated password entry' 77 | def auth 78 | github.create_authorization 79 | end 80 | 81 | desc 'repo [COMMAND] ...', 'Manage repos' 82 | subcommand 'repo', Repo 83 | 84 | desc 'user [COMMAND] ...', 'Manage users' 85 | subcommand 'user', User 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/cleric/hipchat_announcer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Cleric 4 | describe HipChatAnnouncer do 5 | subject(:announcer) { HipChatAnnouncer.new(config, listener, user) } 6 | let(:config) { double('Config', hipchat_api_token: 'api_token', hipchat_announcement_room_id: 'room_id') } 7 | let(:listener) { double('Listener').as_null_object } 8 | let(:hipchat) { double('HipChat').as_null_object } 9 | let(:user) {'an_admin'} 10 | 11 | before(:each) { HipChat::API.stub(:new) { hipchat } } 12 | 13 | shared_examples :announcing_method do |method, opts| 14 | after(:each) { announcer.__send__(method, *opts[:args]) } 15 | 16 | it 'sends the message to upstream listener' do 17 | listener.should_receive(method).with(*opts[:args]) 18 | end 19 | it 'creates a HipChat client' do 20 | HipChat::API.should_receive(:new).with('api_token') 21 | end 22 | it 'sends the message to the configured HipChat room' do 23 | hipchat.should_receive(:rooms_message) 24 | .with('room_id', 'Cleric', opts[:message], 0, opts[:color] || 'green', 'text') 25 | end 26 | end 27 | 28 | describe '#chatroom_added_to_repo' do 29 | it_behaves_like :announcing_method, :chatroom_added_to_repo, 30 | args: ['a_repo', 'a_chatroom'], 31 | message: 'Admin "an_admin": Repo "a_repo" notifications will be sent to chatroom "a_chatroom"' 32 | end 33 | 34 | describe '#repo_added_to_team' do 35 | it_behaves_like :announcing_method, :repo_added_to_team, 36 | args: ['a_repo', 'a_team'], 37 | message: 'Admin "an_admin": Repo "a_repo" added to team "a_team"' 38 | end 39 | 40 | describe '#repo_created' do 41 | it_behaves_like :announcing_method, :repo_created, 42 | args: ['a_repo'], 43 | message: 'Admin "an_admin": Repo "a_repo" created' 44 | end 45 | 46 | describe '#user_added_to_team' do 47 | it_behaves_like :announcing_method, :user_added_to_team, 48 | args: ['a_user', 'a_team'], 49 | message: 'Admin "an_admin": User "a_user" added to team "a_team"' 50 | end 51 | 52 | describe '#user_not_found' do 53 | it 'sends the message to upstream listener' do 54 | listener.should_receive(:user_not_found).with('user@example.com') 55 | announcer.user_not_found('user@example.com') 56 | end 57 | end 58 | 59 | describe '#user_removed_from_org' do 60 | it_behaves_like :announcing_method, :user_removed_from_org, 61 | args: ['a_user', 'user@example.com', 'an_org'], 62 | message: 'Admin "an_admin": User "a_user" (user@example.com) removed from organization "an_org"', 63 | color: 'red' 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/cleric/cli_configuration_provider_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Cleric 4 | describe CLIConfigurationProvider do 5 | subject(:config) { CLIConfigurationProvider.new(filename) } 6 | let(:filename) { 'tmp/clericrc' } 7 | let(:saved_config) do 8 | [ 9 | 'hipchat:', 10 | ' api_token: API_TOKEN', 11 | ' announcement_room_id: ROOM_ID', 12 | ' repo_api_token: REPO_API_TOKEN' 13 | ].join("\n") 14 | end 15 | 16 | before(:each) do 17 | Dir.mkdir('tmp') unless Dir.exists?('tmp') 18 | File.open(filename, 'w') { |file| file.puts(saved_config) } 19 | end 20 | after(:each) do 21 | File.delete(filename) 22 | end 23 | 24 | describe '#github_credentials' do 25 | before(:each) do 26 | # Stubbing credential input as if the load fails and `readline` is 27 | # *not* stubbed then the test run will stall waiting for input! 28 | $stdin.stub(:readline).and_return("me\n", "secret\n") 29 | end 30 | 31 | context 'when no credentials are saved' do 32 | it 'prompts for username and password' do 33 | config.github_credentials.should == { login: 'me', password: 'secret' } 34 | end 35 | end 36 | context 'when credentials are save' do 37 | let(:saved_config) { "github:\n login: ME\n oauth_token: ABC123" } 38 | 39 | it 'loads the credentials from the user config file' do 40 | config.github_credentials.should == { login: 'ME', oauth_token: 'ABC123' } 41 | end 42 | end 43 | end 44 | 45 | describe '#github_credentials=' do 46 | it 'saves the credentials to the config file' do 47 | config.github_credentials = { login: 'me', oauth_token: 'abc123' } 48 | file = YAML::load(File.open(filename)) 49 | file['github']['login'].should == 'me' 50 | file['github']['oauth_token'].should == 'abc123' 51 | end 52 | end 53 | 54 | describe '#hipchat_announcement_room_id' do 55 | it 'loads the room id from the user config file' do 56 | config.hipchat_announcement_room_id.should == 'ROOM_ID' 57 | end 58 | end 59 | 60 | describe '#hipchat_api_token' do 61 | it 'loads the token from the user config file' do 62 | config.hipchat_api_token.should == 'API_TOKEN' 63 | end 64 | end 65 | 66 | describe '#hipchat_repo_api_token' do 67 | it 'loads the token from the user config file' do 68 | config.hipchat_repo_api_token.should == 'REPO_API_TOKEN' 69 | end 70 | end 71 | 72 | describe '#repo_agent' do 73 | # Ensure `new` returns differing instances. 74 | before(:each) { GitHubAgent.stub(:new).and_return(double('Agent'), double('Agent')) } 75 | 76 | it 'creates a configured GitHub agent' do 77 | GitHubAgent.should_receive(:new).with(config) 78 | config.repo_agent 79 | end 80 | it 'returns the same object instance each time' do 81 | config.repo_agent.should be(config.repo_agent) 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/cleric/console_announcer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Cleric 4 | describe ConsoleAnnouncer do 5 | subject(:announcer) { ConsoleAnnouncer.new(io) } 6 | let(:io) { double('IO') } 7 | 8 | shared_examples 'an announcing method' do |method, opts| 9 | after(:each) { announcer.__send__(method, *opts[:args]) } 10 | 11 | it 'sends the message to the configured IO object' do 12 | colorizer = opts[:color] || 'green' 13 | if colorizer == 'white' 14 | io.should_receive(:puts).with(opts[:message]) 15 | else 16 | io.should_receive(:puts).with(ANSI::Code.send(colorizer) { opts[:message] }) 17 | end 18 | end 19 | end 20 | 21 | describe '#chatroom_added_to_repo' do 22 | it_behaves_like 'an announcing method', :chatroom_added_to_repo, 23 | args: ['a_repo', 'a_chatroom'], 24 | message: 'Repo "a_repo" notifications will be sent to chatroom "a_chatroom"' 25 | end 26 | 27 | describe '#commits_without_pull_requests' do 28 | it_behaves_like 'an announcing method', :commits_without_pull_requests, 29 | args: ['a_repo', %w(a list of commits)], 30 | message: "Repo \"a_repo\" has the following commits not covered by pull requests:\n" + 31 | "a\nlist\nof\ncommits", 32 | color: 'red' 33 | end 34 | 35 | describe '#repo_added_to_team' do 36 | it_behaves_like 'an announcing method', :repo_added_to_team, 37 | args: ['a_repo', 'a_team'], 38 | message: 'Repo "a_repo" added to team "a_team"' 39 | end 40 | 41 | describe '#repo_audit_passed' do 42 | it_behaves_like 'an announcing method', :repo_audit_passed, 43 | args: ['a_repo'], 44 | message: 'Repo "a_repo" passed audit' 45 | end 46 | 47 | describe '#repo_created' do 48 | it_behaves_like 'an announcing method', :repo_created, 49 | args: ['a_repo'], 50 | message: 'Repo "a_repo" created' 51 | end 52 | 53 | describe '#repo_fetching_latest_changes' do 54 | it_behaves_like 'an announcing method', :repo_fetching_latest_changes, 55 | args: [], 56 | message: 'Fetching latest changes for local repo', 57 | color: 'white' 58 | end 59 | 60 | describe '#repo_obsolete_pull_request' do 61 | it_behaves_like 'an announcing method', :repo_obsolete_pull_request, 62 | args: ['123', 'abc', 'def'], 63 | message: %Q[Commits abc..def in pull request 123 are no longer present in the repo], 64 | color: 'yellow' 65 | end 66 | 67 | describe '#user_added_to_team' do 68 | it_behaves_like 'an announcing method', :user_added_to_team, 69 | args: ['a_user', 'a_team'], 70 | message: 'User "a_user" added to team "a_team"' 71 | end 72 | 73 | describe '#user_not_found' do 74 | it_behaves_like 'an announcing method', :user_not_found, 75 | args: ['user@example.com'], 76 | message: 'User "user@example.com" not found', 77 | color: 'red' 78 | end 79 | 80 | describe '#user_removed_from_org' do 81 | it_behaves_like 'an announcing method', :user_removed_from_org, 82 | args: ['a_user', 'user@example.com', 'an_org'], 83 | message: 'User "a_user" (user@example.com) removed from organization "an_org"' 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/cleric/repo_auditor_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Cleric 4 | describe RepoAuditor do 5 | subject(:auditor) { RepoAuditor.new(config, listener) } 6 | let(:config) { double('Config', repo_agent: agent).as_null_object } 7 | let(:listener) { double('Listener').as_null_object } 8 | let(:agent) { double('Agent', repo_pull_request_ranges: ranges).as_null_object } 9 | let(:ranges) { [{ base: 'b', head: 'd' }, { base: 'f', head: 'h' }] } 10 | let(:client) { double('Client', log: log).as_null_object } 11 | let(:log) { ('a'..'i').map {|sha| double('LogEntry', sha: sha, parents: ['z']) } } 12 | 13 | describe '#audit_repo' do 14 | before(:each) do 15 | Git.stub(:open) { client } 16 | log.stub(:between) do |from, to| 17 | (from..to).map {|sha| double('LogEntry', sha: sha) } 18 | end 19 | 20 | # Commits with multiple parents (i.e. merge commits) should be ignored. 21 | log.first.stub(:parents) { ['x', 'y'] } 22 | end 23 | after(:each) { auditor.audit_repo('my/repo') } 24 | 25 | it 'gets the range of commits for all pull requests in the repo via the agent' do 26 | agent.should_receive(:repo_pull_request_ranges).with('my/repo') 27 | end 28 | it 'opens a client for the local repo' do 29 | Git.should_receive(:open).with('.') 30 | end 31 | it 'notifies the listener that latest changes are being fetched' do 32 | listener.should_receive(:repo_fetching_latest_changes) 33 | end 34 | it 'fetches the latest changes for the local repo' do 35 | client.should_receive(:fetch) 36 | end 37 | it 'gets the list of commits in each range from the local repo via the client' do 38 | # Expecting three log accesses, one for each range and one for all commits. 39 | client.should_receive(:log).with(nil).at_least(3).times 40 | log.should_receive(:between).with('b', 'd') 41 | log.should_receive(:between).with('f', 'h') 42 | end 43 | 44 | context 'when there are commits outside the given ranges' do 45 | it 'notifies the listener of non-merge commits not covered by a pull request' do 46 | listener.should_receive(:commits_without_pull_requests).with('my/repo', ['e', 'i']) 47 | listener.should_not_receive(:repo_audit_passed) 48 | end 49 | end 50 | 51 | context 'when there are no commits outside the given ranges' do 52 | let(:ranges) { [{ base: 'a', head: 'i' }] } 53 | 54 | it 'notifies the listener that the audit has passed' do 55 | listener.should_receive(:repo_audit_passed).with('my/repo') 56 | listener.should_not_receive(:commits_without_pull_requests) 57 | end 58 | end 59 | 60 | context 'when a given range no longer exists in the local repo' do 61 | let(:ranges) { [{ base: 'p', head: 'q', pr_number: '123' }, { base: 'a', head: 'i' }] } 62 | 63 | before(:each) do 64 | log.stub(:between).with('p', 'q') { raise 'git log error' } 65 | end 66 | 67 | it 'notifies the listener of an obsolete range' do 68 | listener.should_receive(:repo_obsolete_pull_request).with('123', 'p', 'q') 69 | end 70 | it 'continues execution normally' do 71 | listener.should_receive(:repo_audit_passed).with('my/repo') 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/cleric/github_agent.rb: -------------------------------------------------------------------------------- 1 | module Cleric 2 | 3 | # Provides services for managing GitHub using the given configuration. 4 | # Notifies listeners of activities. 5 | class GitHubAgent 6 | def initialize(config) 7 | @config = config 8 | end 9 | 10 | # Adds chatroom notifications to a repo. 11 | # @param repo [String] Name of the repo, e.g. "my_org/my_repo". 12 | # @param chatroom [String] Name or id of the chatroom. 13 | def add_chatroom_to_repo(repo, chatroom, listener) 14 | client.create_hook( 15 | repo, 'hipchat', auth_token: @config.hipchat_repo_api_token, room: chatroom 16 | ) 17 | listener.chatroom_added_to_repo(repo, chatroom) 18 | end 19 | 20 | # Adds a GitHub repo to a team. 21 | # @param repo [String] Name of the repo, e.g. "my_org/my_repo". 22 | # @param team [String] Numeric id of the team. 23 | def add_repo_to_team(repo, team, listener) 24 | client.add_team_repository(team.to_i, repo) 25 | listener.repo_added_to_team(repo, team_name(team)) 26 | end 27 | 28 | # Adds a GitHub user to a team. 29 | # @param username [String] Username of the user to add. 30 | # @param team [String] Numeric id of the team. 31 | def add_user_to_team(username, team, listener) 32 | client.add_team_member(team.to_i, username) 33 | listener.user_added_to_team(username, team_name(team)) 34 | end 35 | 36 | # Creates a GitHub authorization and saves it to the config. 37 | def create_authorization 38 | auth = client.create_authorization(scopes: %w(repo), note: 'Cleric') 39 | @config.github_credentials = { login: credentials[:login], oauth_token: auth.token } 40 | end 41 | 42 | # Creates a GitHub repo. 43 | # @param name [String] Name of the repo, e.g. "my_org/my_repo". 44 | def create_repo(name, listener) 45 | org_name, repo_name = name.split('/') 46 | client.create_repository(repo_name, organization: org_name, private: 'true') 47 | listener.repo_created(name) 48 | end 49 | 50 | # Returns an array of hashes with `:base` and `:head` commit SHA hashes for 51 | # every merged pull request in the named repo. 52 | def repo_pull_request_ranges(repo) 53 | client.pull_requests(repo, 'closed') 54 | .reject {|request| request.merged_at.nil? } 55 | .map do |request| 56 | { 57 | base: request.base.sha, 58 | head: request.head.sha, 59 | pr_number: request.number 60 | } 61 | end 62 | end 63 | 64 | # Removes the user from the organization. 65 | # @param email [String] The email of the user. 66 | # @param org [String] The name of the organization. 67 | # @param listener [Object] The target of any callbacks. 68 | def remove_user_from_org(email, org, listener) 69 | user = client.search_users(email).first 70 | if user 71 | client.remove_organization_member(org, user.username) 72 | listener.user_removed_from_org(user.username, email, org) 73 | else 74 | listener.user_not_found(email) 75 | end 76 | end 77 | 78 | # Verifies that the user's public email matches that given. On failure, 79 | # calls the `verify_user_public_email` method on the listener. 80 | # @param username [String] The user's username. 81 | # @param email [String] The asserted email for the user. 82 | # @param listener [Object] The target of any callbacks. 83 | def verify_user_public_email(username, email, listener) 84 | user = client.user(username) 85 | listener.verify_user_public_email_failure unless user.email == email 86 | end 87 | 88 | # Returns the user login (from the GitHub client, rather than from the 89 | # stored login) 90 | def login 91 | client.user()[:login] 92 | end 93 | 94 | private 95 | 96 | def client 97 | @client ||= create_client 98 | end 99 | 100 | def create_client 101 | client = Octokit::Client.new(credentials.merge(auto_traversal: true)) 102 | end 103 | 104 | def credentials 105 | @credentials ||= @config.github_credentials 106 | end 107 | 108 | def team_name(team) 109 | client.team(team).name 110 | end 111 | 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cleric 2 | 3 | Administration tools for the lazy software development manager. 4 | 5 | Cleric automates the boring jobs a manager has to do when administrating 6 | accounts on GitHub, HipChat and other services used by teams. It provides 7 | commands such as: 8 | 9 | * Add a GitHub user to a team 10 | * Remove a GitHub user from an organization 11 | * Create a GitHub repo, assign it to a team and hook up repo notifications to 12 | HipChat *all at once* 13 | * Audit a local repo to ensure all commits are covered by pull requests 14 | 15 | For any successful action, a configured HipChat room is notified, allowing other 16 | managers to see what's going on in your organization. 17 | 18 | Cleric is intended to be extensible, so it should ultimately be possible to plug 19 | in other repo and chat services (assuming they have an adequate API) and provide 20 | other management notification mechanisms beyond HipChat. 21 | 22 | While Cleric currently provides a command line interface, the design is intended 23 | to be reusable with other interfaces, e.g. a web UI. 24 | 25 | ## Install 26 | 27 | Cleric is available as a gem through GitHub, hence you'll need to add it to the 28 | `Gemfile` of an existing project or create a stub `Gemfile` to use it in 29 | isolation. Add the following line: 30 | 31 | ```ruby 32 | gem 'cleric', git: 'git@github.com:mdsol/cleric.git' 33 | ``` 34 | 35 | Then run: 36 | 37 | ``` 38 | bundle install 39 | ``` 40 | 41 | This assumes you have Ruby (1.9+) and the bundler gem installed already! 42 | 43 | To use the Cleric from the command line, run this command in the top directory 44 | for help: 45 | 46 | ``` 47 | [bundle exec] cleric help 48 | ``` 49 | 50 | ## Configure 51 | 52 | Cleric is configured using the `.clericrc` YAML file in your home directory. 53 | 54 | ### Management notifications 55 | 56 | Management notification to a HipChat room is (currently) mandatory and requires 57 | the following settings in `.clericrc`: 58 | 59 | ```yaml 60 | hipchat: 61 | api_token: 1234567890abcdef1234567890abcd 62 | announcement_room_id: 1234 63 | ``` 64 | 65 | The `api_token` value should be an **Admin** token, to allow future commands to 66 | perform actions such as adding accounts. As the HipChat API does not provide a 67 | way to generate tokens, you will have to do this manually (once) through the 68 | "Group admin" web UI. 69 | 70 | The `announcement_room_id` is the management notification chat room's id. In 71 | HipChat's chat web UI, this can be discovered by looking at the URL in the chat 72 | history page. In future, Cleric should allow room names to be used or provide a 73 | command to look up the id. Using an id *does* have the advantage of be resilient 74 | to the chat room being renamed. 75 | 76 | ### GitHub authentication 77 | 78 | By default, Cleric will prompt for your GitHub login and password on each 79 | invocation. You can avoid this be running the following command: 80 | 81 | ``` 82 | [bundle exec] cleric auth 83 | ``` 84 | 85 | You will be prompted for your login and password one last time but then an API 86 | token will be generated and stored in `.clericrc`. 87 | 88 | Guard this token like you would a password! It allows the possessor full access 89 | to every public and private repo your account has access to! The advantage API 90 | tokens have over passwords is that they do not allow your account to be modified 91 | and can be revoked individually from GitHub's account management UI; Cleric's 92 | token will be named "Cleric (API)". 93 | 94 | If you are uncomfortable with this level of risk, you can still enter your login 95 | and password with each invocation, which is the default behavior. 96 | 97 | ### GitHub repo notifications to HipChat 98 | 99 | The `repo create` command allows a new GitHub repo to (optionally) post 100 | notifications to a HipChat room. Rather than configure GitHub with the 101 | admin-level token used by Cleric itself, a separate **Notification** token 102 | should be used and configured in `.clericrc` as follows: 103 | 104 | ```yaml 105 | hipchat: 106 | api_token: 1234567890abcdef1234567890abcd 107 | announcement_room_id: 1234 108 | repo_api_token: abcdef1234567890abcdef12345678 109 | ``` 110 | 111 | ## Contributors 112 | 113 | * [Andrew Smith](https://github.com/asmith-mdsol) 114 | * [Geoff Low](https://github.com/glow-mdsol) 115 | * [Harry Wilkinson](https://github.com/harryw) 116 | 117 | -------------------------------------------------------------------------------- /spec/cleric/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Command line' do 4 | let(:cli_command) { '' } 5 | let(:cli_help) { `./bin/cleric #{cli_command}` } 6 | 7 | context 'when invoked without arguments' do 8 | it 'shows the available commands' do 9 | cli_help.should include('cleric help [COMMAND]') 10 | end 11 | end 12 | 13 | it 'has a "auth" command' do 14 | cli_help.should include('cleric auth') 15 | end 16 | it 'has a "repo [COMMAND]" command' do 17 | cli_help.should include('cleric repo [COMMAND]') 18 | end 19 | it 'has a "user [COMMAND]" command' do 20 | cli_help.should include('cleric user [COMMAND]') 21 | end 22 | 23 | context 'under the "repo" command' do 24 | let(:cli_command) { 'repo' } 25 | it 'has an audit command' do 26 | cli_help.should include('cleric repo audit ') 27 | end 28 | it 'has a create command' do 29 | cli_help.should include('cleric repo create ') 30 | end 31 | it 'has an update command' do 32 | cli_help.should include('cleric repo update ') 33 | end 34 | end 35 | 36 | context 'under the "user" command' do 37 | let(:cli_command) { 'user' } 38 | it 'has a welcome command' do 39 | cli_help.should include('cleric user welcome ') 40 | end 41 | it 'has a remove command' do 42 | cli_help.should include('cleric user remove ') 43 | end 44 | end 45 | end 46 | 47 | module Cleric 48 | describe CLI do 49 | let(:config) { double('Config') } 50 | let(:agent) { double('GitHub').as_null_object } 51 | let(:console) { double(ConsoleAnnouncer) } 52 | let(:hipchat) { double(HipChatAnnouncer) } 53 | 54 | before(:each) do 55 | CLIConfigurationProvider.stub(:new) { config } 56 | GitHubAgent.stub(:new) { agent } 57 | ConsoleAnnouncer.stub(:new) { console } 58 | HipChatAnnouncer.stub(:new) { hipchat } 59 | end 60 | 61 | shared_examples :github_agent_user do 62 | it 'creates a configured GitHub agent' do 63 | GitHubAgent.should_receive(:new).with(config) 64 | end 65 | end 66 | 67 | shared_examples :announcers do 68 | it 'creates a console announcer' do 69 | ConsoleAnnouncer.should_receive(:new).with($stdout) 70 | end 71 | it 'creates a HipChat console decorating the console announcer' do 72 | HipChatAnnouncer.should_receive(:new).with(config, console, agent.login) 73 | end 74 | end 75 | 76 | describe '#auth' do 77 | after(:each) { subject.auth } 78 | 79 | include_examples :github_agent_user 80 | it 'tells the agent to create an authorization token' do 81 | agent.should_receive(:create_authorization) 82 | end 83 | end 84 | 85 | describe Repo do 86 | subject(:repo) { Cleric::Repo.new } 87 | 88 | describe '#create' do 89 | let(:name) { 'example_name' } 90 | let(:manager) { double(RepoManager).as_null_object } 91 | 92 | before(:each) do 93 | RepoManager.stub(:new) { manager } 94 | repo.options = { team: '1234', chatroom: 'my_room' } 95 | end 96 | 97 | after(:each) { repo.create(name) } 98 | 99 | include_examples :github_agent_user 100 | include_examples :announcers 101 | it 'creates a repo manager configured with the agent' do 102 | RepoManager.should_receive(:new).with(agent, hipchat) 103 | end 104 | it 'delegates creation to the manager' do 105 | manager.should_receive(:create).with(name, '1234', hash_including(chatroom: 'my_room')) 106 | end 107 | end 108 | 109 | describe '#audit' do 110 | let(:auditor) { double(RepoAuditor).as_null_object } 111 | 112 | before(:each) { RepoAuditor.stub(:new) { auditor } } 113 | after(:each) { repo.audit('my/repo') } 114 | 115 | it 'creates a console announcer' do 116 | ConsoleAnnouncer.should_receive(:new).with($stdout) 117 | end 118 | it 'creates a configured repo auditor' do 119 | RepoAuditor.should_receive(:new).with(config, console) 120 | end 121 | it 'delegates to the auditor' do 122 | auditor.should_receive(:audit_repo).with('my/repo') 123 | end 124 | end 125 | 126 | describe '#update' do 127 | let(:name) { 'example_name' } 128 | let(:manager) { double(RepoManager).as_null_object } 129 | let(:options) { { chatroom: 'my_room' } } 130 | 131 | before(:each) do 132 | RepoManager.stub(:new) { manager } 133 | repo.options = options 134 | end 135 | 136 | after(:each) { repo.update(name) } 137 | 138 | include_examples :github_agent_user 139 | include_examples :announcers 140 | it 'creates a repo manager configured with the agent' do 141 | RepoManager.should_receive(:new).with(agent, hipchat) 142 | end 143 | it 'delegates creation to the manager' do 144 | manager.should_receive(:update).with(name, hash_including(chatroom: 'my_room')) 145 | end 146 | 147 | context 'when given a team option' do 148 | let(:options) { { chatroom: 'my_room', team: '1234' } } 149 | 150 | it 'delegates creation to the manager' do 151 | manager.should_receive(:update).with(name, hash_including(team: '1234')) 152 | end 153 | end 154 | end 155 | end 156 | 157 | describe User do 158 | subject(:user) { Cleric::User.new } 159 | let(:manager) { double(UserManager).as_null_object } 160 | 161 | before(:each) do 162 | UserManager.stub(:new) { manager } 163 | end 164 | 165 | shared_examples :creates_user_manager do 166 | it 'creates a user manager configured with the agent' do 167 | UserManager.should_receive(:new).with(config, hipchat) 168 | end 169 | end 170 | 171 | describe '#create' do 172 | before(:each) { user.options = { team: '1234', email: 'me@example.com' } } 173 | after(:each) { user.welcome('a_username') } 174 | 175 | include_examples :announcers 176 | include_examples :creates_user_manager 177 | it 'delegates welcome to the manager' do 178 | manager.should_receive(:welcome).with('a_username', 'me@example.com', '1234') 179 | end 180 | end 181 | 182 | describe '#remove' do 183 | after(:each) { user.remove('a_user@example.com', 'an_org') } 184 | 185 | include_examples :announcers 186 | include_examples :creates_user_manager 187 | it 'delegates removal to the manager' do 188 | manager.should_receive(:remove).with('a_user@example.com', 'an_org') 189 | end 190 | end 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /spec/cleric/github_agent_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Cleric 4 | describe GitHubAgent do 5 | subject(:agent) { GitHubAgent.new(config) } 6 | let(:config) { double('ConfigProvider', github_credentials: credentials).as_null_object } 7 | let(:credentials) { { login: 'me', password: 'secret' } } 8 | let(:client) { double('GitHubClient').as_null_object } 9 | let(:listener) { double('Listener').as_null_object } 10 | 11 | before(:each) { Octokit::Client.stub(:new) { client } } 12 | 13 | shared_examples :client do 14 | it 'creates a GitHub client with the configured credentials' do 15 | Octokit::Client.should_receive(:new).with(credentials.merge(auto_traversal: true)) 16 | end 17 | end 18 | 19 | it 'uses a single GitHub client across multiple calls' do 20 | Octokit::Client.should_receive(:new).once 21 | 3.times do 22 | agent.create_repo('my_org/my_repo', listener) 23 | agent.add_repo_to_team('my_org/my_repo', '1234', listener) 24 | end 25 | end 26 | 27 | describe '#login' do 28 | it 'returns the client login id' do 29 | client.stub(:user) { {login: 'user'} } 30 | expect(agent.login).to eq('user') 31 | end 32 | end 33 | 34 | describe '#add_chatroom_to_repo' do 35 | before(:each) { config.stub(:hipchat_repo_api_token) { 'REPO_API_TOKEN' } } 36 | after(:each) { agent.add_chatroom_to_repo('my_org/my_repo', 'my_room', listener) } 37 | 38 | include_examples :client 39 | it 'adds the chatroom to the repo via the client' do 40 | client.should_receive(:create_hook) 41 | .with('my_org/my_repo', 'hipchat', room: 'my_room', auth_token: 'REPO_API_TOKEN') 42 | end 43 | it 'announces success to the listener' do 44 | listener.should_receive(:chatroom_added_to_repo).with('my_org/my_repo', 'my_room') 45 | end 46 | end 47 | 48 | describe '#add_repo_to_team' do 49 | before(:each) { client.stub(:team) { double(name: 'Fabbo Team') } } 50 | after(:each) { agent.add_repo_to_team('my_org/my_repo', '1234', listener) } 51 | 52 | include_examples :client 53 | it 'adds the repo to the team via the client' do 54 | client.should_receive(:add_team_repository).with(1234, 'my_org/my_repo') 55 | end 56 | it 'announces success to the listener' do 57 | listener.should_receive(:repo_added_to_team).with('my_org/my_repo', 'Fabbo Team') 58 | end 59 | end 60 | 61 | describe '#add_user_to_team' do 62 | let(:listener) { double('Listener').as_null_object } 63 | 64 | before(:each) { client.stub(:team) { double(name: 'Fabbo Team') } } 65 | after(:each) { agent.add_user_to_team('a_user', '1234', listener) } 66 | 67 | it 'add the user to the team via the client' do 68 | client.should_receive(:add_team_member).with(1234, 'a_user') 69 | end 70 | it 'announces success to the listener' do 71 | listener.should_receive(:user_added_to_team).with('a_user', 'Fabbo Team') 72 | end 73 | end 74 | 75 | describe '#create_authorization' do 76 | before(:each) do 77 | client.stub(:create_authorization) do 78 | double('Auth', token: 'abc123') 79 | end 80 | end 81 | after(:each) { agent.create_authorization } 82 | 83 | include_examples :client 84 | it 'creates an authorization via the client' do 85 | client.should_receive(:create_authorization) 86 | .with(hash_including(scopes: %w(repo), note: 'Cleric')) 87 | end 88 | it 'saves it to the config' do 89 | config.should_receive(:github_credentials=) 90 | .with(hash_including(login: 'me', oauth_token: 'abc123')) 91 | end 92 | end 93 | 94 | describe '#create_repo' do 95 | after(:each) { agent.create_repo('my_org/my_repo', listener) } 96 | 97 | include_examples :client 98 | it 'creates a private repo via the client' do 99 | client.should_receive(:create_repository) 100 | .with('my_repo', hash_including(organization: 'my_org', private: 'true')) 101 | end 102 | it 'announces success to the listener' do 103 | listener.should_receive(:repo_created).with('my_org/my_repo') 104 | end 105 | end 106 | 107 | describe '#repo_pull_request_ranges' do 108 | let(:pull_request) do 109 | double('PullRequest', 110 | merged_at: merged_at, 111 | base: double('Commit', sha: '123').as_null_object, 112 | head: double('Commit', sha: '456').as_null_object, 113 | number: '789' 114 | ).as_null_object 115 | end 116 | let(:merged_at) { 'some time' } 117 | 118 | before(:each) { client.stub(:pull_requests) { [pull_request] } } 119 | 120 | it 'gets all the closed pull requests' do 121 | client.should_receive(:pull_requests).with('my_org/my_repo', 'closed') 122 | agent.repo_pull_request_ranges('my_org/my_repo') 123 | end 124 | it 'returns the base and head commit SHA hashes' do 125 | returned = agent.repo_pull_request_ranges('my_org/my_repo') 126 | returned.should == [ { base: '123', head: '456', pr_number: '789' } ] 127 | end 128 | 129 | context 'when encountering a pull request that has not been merged' do 130 | let(:merged_at) { nil } 131 | 132 | it 'does not return that pull request' do 133 | returned = agent.repo_pull_request_ranges('my_org/my_repo') 134 | returned.should be_empty 135 | end 136 | end 137 | end 138 | 139 | describe '#remove_user_from_org' do 140 | let(:users) { [ double('User', username: 'a_user') ] } 141 | 142 | before(:each) { client.stub(:search_users) { users } } 143 | after(:each) { agent.remove_user_from_org('user@example.com', 'an_org', listener) } 144 | 145 | it 'finds the user by their public email via the client' do 146 | client.should_receive(:search_users).with('user@example.com') 147 | end 148 | it 'removes the user from the organization via the client' do 149 | client.should_receive(:remove_organization_member).with('an_org', 'a_user') 150 | end 151 | it 'announces success to the listener' do 152 | listener.should_receive(:user_removed_from_org) 153 | .with('a_user', 'user@example.com', 'an_org') 154 | end 155 | 156 | context 'when the user is not found' do 157 | let(:users) { [] } 158 | 159 | it 'notifies the listener of the failure' do 160 | listener.should_receive(:user_not_found).with('user@example.com') 161 | end 162 | it 'does not notify the listener of success' do 163 | listener.should_not_receive(:user_removed_from_org) 164 | end 165 | end 166 | end 167 | 168 | describe '#verify_user_public_email' do 169 | let(:email) { 'user@example.com' } 170 | 171 | before(:each) { client.stub(:user) { double('User', email: email) } } 172 | after(:each) { agent.verify_user_public_email('a_user', 'user@example.com', listener) } 173 | 174 | include_examples :client 175 | it 'finds the user via the client' do 176 | client.should_recieve(:user).with('a_user') 177 | end 178 | 179 | context 'when the user public email does not match' do 180 | let(:email) { 'other_user@example.com' } 181 | 182 | it 'calls the failure callback on the listener' do 183 | listener.should_receive(:verify_user_public_email_failure) 184 | end 185 | end 186 | 187 | context 'when the email matches' do 188 | it 'does not call the failure callback' do 189 | listener.should_not_receive(:verify_user_public_email_failure) 190 | end 191 | end 192 | end 193 | end 194 | end 195 | 196 | --------------------------------------------------------------------------------