├── spec ├── factories │ └── fake_file ├── support │ ├── silent-logger.rb │ └── sinatra_helper.rb ├── spec_helper.rb └── lib │ ├── web │ ├── hooks_spec.rb │ └── tarball_spec.rb │ └── fourchette │ ├── logger_spec.rb │ ├── pull_request_spec.rb │ ├── tarball_spec.rb │ ├── pgbackups_spec.rb │ ├── fork_spec.rb │ ├── github_spec.rb │ └── heroku_spec.rb ├── Procfile ├── templates ├── Procfile ├── Rakefile ├── Gemfile ├── config.ru ├── config │ └── puma.rb └── callbacks.rb ├── config.ru ├── Gemfile ├── lib ├── fourchette │ ├── version.rb │ ├── web.rb │ ├── web │ │ ├── hooks.rb │ │ └── tarball.rb │ ├── logger.rb │ ├── callbacks.rb │ ├── rake_tasks.rb │ ├── pull_request.rb │ ├── tarball.rb │ ├── pgbackups.rb │ ├── github.rb │ ├── fork.rb │ └── heroku.rb └── fourchette.rb ├── .travis.yml ├── Guardfile ├── .gitignore ├── Rakefile ├── bin └── fourchette ├── LICENSE.txt ├── fourchette.gemspec └── README.md /spec/factories/fake_file: -------------------------------------------------------------------------------- 1 | some content... -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec rackup -s puma -p $PORT -------------------------------------------------------------------------------- /templates/Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -C config/puma.rb -------------------------------------------------------------------------------- /templates/Rakefile: -------------------------------------------------------------------------------- 1 | require 'fourchette/rake_tasks' 2 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require './lib/fourchette' 2 | 3 | run Sinatra::Application -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'puma' 6 | -------------------------------------------------------------------------------- /lib/fourchette/version.rb: -------------------------------------------------------------------------------- 1 | module Fourchette 2 | VERSION = '0.1.3' 3 | end 4 | -------------------------------------------------------------------------------- /lib/fourchette/web.rb: -------------------------------------------------------------------------------- 1 | require_relative 'web/hooks' 2 | require_relative 'web/tarball' 3 | -------------------------------------------------------------------------------- /templates/Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gem 'puma' 4 | gem 'fourchette' 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0-p353 4 | - 2.1.0 5 | - 2.1.1 6 | - 2.1.2 -------------------------------------------------------------------------------- /spec/support/silent-logger.rb: -------------------------------------------------------------------------------- 1 | Logger.class_eval do 2 | def info(*_args) 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /templates/config.ru: -------------------------------------------------------------------------------- 1 | require 'fourchette' 2 | require_relative 'callbacks' 3 | run Sinatra::Application -------------------------------------------------------------------------------- /spec/support/sinatra_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rack/test' 2 | include Rack::Test::Methods 3 | 4 | def app 5 | Sinatra::Application 6 | end 7 | -------------------------------------------------------------------------------- /lib/fourchette/web/hooks.rb: -------------------------------------------------------------------------------- 1 | post '/hooks' do 2 | params = JSON.parse(request.env['rack.input'].read) 3 | Fourchette::PullRequest.new.async.perform(params) 4 | 'Got it, thanks!' 5 | end 6 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rspec, cmd: 'bundle exec rspec' do 2 | watch(%r{^spec/.+_spec\.rb$}) 3 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } 4 | watch('spec/spec_helper.rb') { 'spec' } 5 | end 6 | -------------------------------------------------------------------------------- /lib/fourchette/logger.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | module Fourchette::Logger 4 | def logger 5 | unless @logger 6 | @logger = Logger.new(STDOUT) 7 | @logger.level = Logger::INFO 8 | end 9 | 10 | @logger 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'coveralls' 3 | require 'rack/test' 4 | Coveralls.wear! 5 | 6 | require_relative '../lib/fourchette' 7 | 8 | support_include_path = "#{Dir.pwd}/spec/support/**/*.rb" 9 | Dir[support_include_path].each { |f| require f } 10 | -------------------------------------------------------------------------------- /templates/config/puma.rb: -------------------------------------------------------------------------------- 1 | workers Integer(ENV['PUMA_WORKERS'] || 5) 2 | threads Integer(ENV['PUMA_MIN_THREADS'] || 1), Integer(ENV['PUMA_MAX_THREADS'] || 16) 3 | 4 | preload_app! 5 | 6 | rackup DefaultRackup 7 | port ENV['PORT'] || 9292 8 | environment ENV['RACK_ENV'] || 'development' 9 | -------------------------------------------------------------------------------- /lib/fourchette/callbacks.rb: -------------------------------------------------------------------------------- 1 | class Fourchette::Callbacks 2 | include Fourchette::Logger 3 | 4 | def initialize params 5 | @params = params 6 | end 7 | 8 | def before_all 9 | logger.info 'Placeholder for before steps...' 10 | end 11 | 12 | def after_all 13 | logger.info 'Placeholder for after steps...' 14 | end 15 | end -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | .ruby-version 19 | 20 | # Mac specific 21 | .DS_Store 22 | 23 | # ngrok is for having tunnels to localhost, for local dev 24 | ngrok 25 | tags 26 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'fourchette/rake_tasks' 2 | 3 | begin 4 | require 'rspec/core/rake_task' 5 | # Set default Rake task to spec 6 | RSpec::Core::RakeTask.new(:spec) 7 | task default: :spec 8 | rescue LoadError => ex 9 | # That's ok, it just means we don't have RSpec loaded 10 | end 11 | 12 | desc 'Brings up a REPL with the code loaded' 13 | task :console do 14 | require './lib/fourchette' 15 | Pry.start 16 | end 17 | -------------------------------------------------------------------------------- /lib/fourchette/web/tarball.rb: -------------------------------------------------------------------------------- 1 | get '/:github_user/:github_repo/:uuid/:expiration_timestamp' do 2 | if params['expiration_timestamp'].to_i < Time.now.to_i 3 | status 404 4 | 'Oops...' 5 | else 6 | logger.info('Serving a tarball!') 7 | filepath = Fourchette::Tarball.new.filepath( 8 | params['uuid'], 9 | params['expiration_timestamp'] 10 | ) 11 | send_file filepath, type: 'application/x-tgz' 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/lib/web/hooks_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'support/sinatra_helper' 3 | require 'sucker_punch/testing/inline' 4 | 5 | describe 'GitHub web hooks receiver' do 6 | it 'kicks an async job doing all the work' do 7 | expected_param = { 'something' => 'ok' } 8 | expect_any_instance_of(Fourchette::PullRequest) 9 | .to receive(:perform) 10 | .with(expected_param) 11 | 12 | post '/hooks', 13 | expected_param.to_json, 14 | 'CONTENT_TYPE' => 'application/json' 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /templates/callbacks.rb: -------------------------------------------------------------------------------- 1 | # This is a sample file to see how the really, really basic callback system 2 | # works. See the README for me or just dive in. 3 | class Fourchette::Callbacks 4 | include Fourchette::Logger 5 | 6 | def initialize(params) 7 | @params = params 8 | end 9 | 10 | def before_all 11 | logger.info 'Placeholder for before steps... (see callbacks.rb to override)' 12 | end 13 | 14 | def after_all 15 | logger.info 'Placeholder for after steps... (see callbacks.rb to override)' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/lib/fourchette/logger_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Fourchette::Logger do 4 | class FakeClassToTest 5 | include Fourchette::Logger 6 | end 7 | 8 | subject { FakeClassToTest.new } 9 | 10 | it { expect(subject.logger.level).to be Logger::INFO } 11 | 12 | context 'first time called' do 13 | it { expect(subject.logger).to be_a(Logger) } 14 | end 15 | 16 | context 'each time after' do 17 | it 'returns the cached version' do 18 | logger = subject.logger 19 | expect(subject.logger).to be(logger) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/fourchette/rake_tasks.rb: -------------------------------------------------------------------------------- 1 | require 'fourchette' 2 | 3 | namespace :fourchette do 4 | desc 'This enables Fourchette hook' 5 | task :enable do 6 | Fourchette::GitHub.new.enable_hook 7 | end 8 | 9 | desc 'This disables Fourchette hook' 10 | task :disable do 11 | Fourchette::GitHub.new.disable_hook 12 | end 13 | 14 | desc 'This updates the Fourchette hook with the current URL of the app' 15 | task :update do 16 | Fourchette::GitHub.new.update_hook 17 | end 18 | 19 | desc 'This deletes the Fourchette hook' 20 | task :delete do 21 | Fourchette::GitHub.new.delete_hook 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /bin/fourchette: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'thor' 4 | 5 | module Fourchette 6 | class CLI < Thor 7 | include Thor::Actions 8 | 9 | desc 'new APP_NAME', 'This will create a fourchette app for you under APP_NAME directory.' 10 | def new(name) 11 | # Create directory 12 | empty_directory(name) 13 | 14 | # Copy template files 15 | ['Gemfile', 'config.ru', 'Procfile', 'Rakefile', 'config/puma.rb', 'callbacks.rb'].each do |file_name| 16 | copy_file(file_name, "#{name}/#{file_name}") 17 | end 18 | 19 | # Run bundle install 20 | run("bundle install --gemfile #{name}/Gemfile") 21 | end 22 | end 23 | end 24 | 25 | Fourchette::CLI.source_root(File.expand_path('../../templates', __FILE__)) 26 | Fourchette::CLI.start(ARGV) 27 | -------------------------------------------------------------------------------- /lib/fourchette/pull_request.rb: -------------------------------------------------------------------------------- 1 | class Fourchette::PullRequest 2 | include SuckerPunch::Job 3 | 4 | def perform(params) 5 | return if qa_skip?(params) 6 | 7 | callbacks = Fourchette::Callbacks.new(params) 8 | fork = Fourchette::Fork.new(params) 9 | 10 | callbacks.before_all 11 | 12 | case params['action'] 13 | when 'synchronize' # new push against the PR (updating code, basically) 14 | fork.update 15 | when 'closed' 16 | fork.delete 17 | when 'reopened' 18 | fork.create 19 | when 'opened' 20 | fork.create 21 | end 22 | 23 | callbacks.after_all 24 | end 25 | 26 | private 27 | 28 | def qa_skip?(params) 29 | pr_title = params['pull_request']['title'] 30 | pr_title.downcase.include?('[qa skip]') 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/web/tarball_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'support/sinatra_helper' 3 | 4 | describe 'web tarball serving' do 5 | context 'valid and not expired URL' do 6 | it 'returns the file' do 7 | expire_in_2_secs = Time.now.to_i + 2 8 | expect_any_instance_of(Fourchette::Tarball) 9 | .to receive(:filepath) 10 | .with('1234567', expire_in_2_secs.to_s) { "#{Dir.pwd}/spec/factories/fake_file" } 11 | 12 | get "/jipiboily/fourchette/1234567/#{expire_in_2_secs}" 13 | expect(last_response.headers['Content-Type']).to eq 'application/x-tgz' 14 | expect(last_response.body).to eq 'some content...' 15 | end 16 | end 17 | 18 | context 'expired URL' do 19 | it 'does NOT returns the file if it is expired' do 20 | expired_one_sec_ago = Time.now.to_i - 1 21 | get "/jipiboily/fourchette/1234567/#{expired_one_sec_ago}" 22 | expect(last_response).not_to be_ok 23 | expect(last_response.body).not_to eq('Hello World') 24 | expect(last_response.status).to eq(404) 25 | expect(subject).not_to receive(:send_file) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/fourchette.rb: -------------------------------------------------------------------------------- 1 | require 'fourchette/version' 2 | require 'sinatra' 3 | require 'json' 4 | require 'platform-api' 5 | require 'octokit' 6 | require 'git' 7 | require 'sucker_punch' 8 | require 'rest-client' 9 | 10 | def attempt_pry 11 | require 'pry' 12 | rescue LoadError => ex 13 | puts "Oops: #{ex}" 14 | end 15 | 16 | # TODO: Extract this to development.rb and production.rb 17 | if development? 18 | require 'sinatra/reloader' 19 | attempt_pry 20 | 21 | FOURCHETTE_CONFIG = { 22 | env_name: 'fourchette-dev' 23 | } 24 | else 25 | FOURCHETTE_CONFIG = { 26 | env_name: 'fourchette' 27 | } 28 | end 29 | 30 | module Fourchette 31 | DEBUG = ENV['DEBUG'] ? true : false 32 | 33 | class DeployException < StandardError; end 34 | end 35 | 36 | require_relative 'fourchette/logger' 37 | require_relative 'fourchette/web' 38 | require_relative 'fourchette/github' 39 | require_relative 'fourchette/pull_request' 40 | require_relative 'fourchette/fork' 41 | require_relative 'fourchette/heroku' 42 | require_relative 'fourchette/pgbackups' 43 | require_relative 'fourchette/callbacks' 44 | require_relative 'fourchette/tarball' 45 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2015 Jean-Philippe Boily 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/fourchette/tarball.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | class Fourchette::Tarball 3 | include Fourchette::Logger 4 | 5 | def url(github_git_url, branch_name, github_repo) 6 | filepath = prepare_tarball(github_git_url, branch_name) 7 | tarball_to_url(filepath, github_repo) 8 | end 9 | 10 | def filepath(uuid, expiration) 11 | "tmp/#{uuid}/#{expiration}.tar.gz" 12 | end 13 | 14 | private 15 | 16 | def prepare_tarball(github_git_url, branch_name) 17 | clone_path = "tmp/#{SecureRandom.uuid}" 18 | clone(github_git_url, branch_name, clone_path) 19 | tar(clone_path) 20 | end 21 | 22 | def clone(github_git_url, branch_name, clone_path) 23 | logger.info 'Cloning repository...' 24 | repo = Git.clone(github_git_url, clone_path, recursive: true) 25 | repo.checkout(branch_name) 26 | end 27 | 28 | def tar(path) 29 | logger.info 'Preparing tarball...' 30 | filepath = "#{path}/#{expiration_timestamp}.tar.gz" 31 | system("tar -zcf #{filepath} -C #{path} .") 32 | filepath 33 | end 34 | 35 | def expiration_timestamp 36 | Time.now.to_i + 300 37 | end 38 | 39 | def tarball_to_url(filepath, github_repo) 40 | logger.info 'Tarball to URL as a service in progress...' 41 | cleaned_path = filepath.gsub('tmp/', '').gsub('.tar.gz', '') 42 | "#{ENV['FOURCHETTE_APP_URL']}/#{github_repo}/#{cleaned_path}" 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/fourchette/pgbackups.rb: -------------------------------------------------------------------------------- 1 | require 'heroku/client/pgbackups' 2 | class Fourchette::Pgbackups 3 | include Fourchette::Logger 4 | 5 | def initialize 6 | @heroku = Fourchette::Heroku.new 7 | end 8 | 9 | def copy(from, to) 10 | ensure_pgbackups_is_present(from) 11 | ensure_pgbackups_is_present(to) 12 | 13 | from_url, from_name = pg_details_for(from) 14 | to_url, to_name = pg_details_for(to) 15 | 16 | @client = Heroku::Client::Pgbackups.new pgbackup_url(from) + '/api' 17 | @client.create_transfer(from_url, from_name, to_url, to_name) 18 | end 19 | 20 | private 21 | 22 | def ensure_pgbackups_is_present(heroku_app_name) 23 | unless existing_backups?(heroku_app_name) 24 | logger.info "Adding pgbackups to #{heroku_app_name}" 25 | @heroku.client.addon.create(heroku_app_name, { plan: 'pgbackups' }) 26 | end 27 | end 28 | 29 | def existing_backups?(heroku_app_name) 30 | @heroku.client.addon.list(heroku_app_name).any? do |addon| 31 | addon['name'] == 'pgbackups' 32 | end 33 | end 34 | 35 | def pg_details_for(app_name) 36 | @heroku.config_vars(app_name).each do |key, value| 37 | if key.start_with?('HEROKU_POSTGRESQL_') && key.end_with?('_URL') 38 | return [value, key] 39 | end 40 | end 41 | end 42 | 43 | def pgbackup_url(app_name) 44 | @heroku.config_vars(app_name).each do |k, v| 45 | return v if k == 'PGBACKUPS_URL' 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/lib/fourchette/pull_request_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Fourchette::PullRequest do 4 | describe '#perform' do 5 | let(:fork) { double('fork') } 6 | subject { described_class.new } 7 | 8 | after do 9 | allow(Fourchette::Fork).to receive(:new).and_return(fork) 10 | subject.perform(params) 11 | end 12 | 13 | context 'action == synchronize' do 14 | let(:params) do 15 | { 16 | 'action' => 'synchronize', 17 | 'pull_request' => { 'title' => 'Test Me' } 18 | } 19 | end 20 | 21 | it { expect(fork).to receive(:update) } 22 | end 23 | 24 | context 'action == closed' do 25 | let(:params) do 26 | { 27 | 'action' => 'closed', 28 | 'pull_request' => { 'title' => 'Test Me' } 29 | } 30 | end 31 | 32 | it { expect(fork).to receive(:delete) } 33 | end 34 | 35 | context 'action == reopened' do 36 | let(:params) do 37 | { 38 | 'action' => 'reopened', 39 | 'pull_request' => { 'title' => 'Test Me' } 40 | } 41 | end 42 | 43 | it { expect(fork).to receive(:create) } 44 | end 45 | 46 | context 'action == opened' do 47 | let(:params) do 48 | { 49 | 'action' => 'opened', 50 | 'pull_request' => { 'title' => 'Test Me' } 51 | } 52 | end 53 | 54 | it { expect(fork).to receive(:create) } 55 | end 56 | 57 | context 'title includes [qa skip]' do 58 | let(:params) do 59 | { 60 | 'action' => 'opened', 61 | 'pull_request' => { 'title' => 'Skip Me [QA Skip]' } 62 | } 63 | end 64 | 65 | it { expect(fork).not_to receive(:create) } 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/lib/fourchette/tarball_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Fourchette::Tarball do 4 | subject { described_class.new } 5 | 6 | describe '#url' do 7 | let(:git_repo_url) { 'git://github.com/jipiboily/fourchette.git' } 8 | let(:github_repo) { 'jipiboily/fourchette' } 9 | let(:branch_name) { 'feature/something-new' } 10 | 11 | before do 12 | allow(subject).to receive(:expiration_timestamp).and_return('123') 13 | allow(subject).to receive(:clone) 14 | allow(subject).to receive(:tar).and_return('tmp/1234567/123.tar.gz') 15 | allow(subject).to receive(:system) 16 | stub_const('ENV', 'FOURCHETTE_APP_URL' => 'http://example.com') 17 | allow(SecureRandom).to receive(:uuid).and_return('1234567') 18 | end 19 | 20 | it do 21 | expect( 22 | subject.url(git_repo_url, branch_name, github_repo) 23 | ).to eq 'http://example.com/jipiboily/fourchette/1234567/123' 24 | end 25 | 26 | it 'clones the repo and checkout the branch' do 27 | allow(subject).to receive(:clone).and_call_original 28 | git_instance = double 29 | expect(Git).to receive(:clone).with( 30 | git_repo_url, 'tmp/1234567', recursive: true 31 | ).and_return(git_instance) 32 | expect(git_instance).to receive(:checkout).with(branch_name) 33 | subject.url(git_repo_url, branch_name, github_repo) 34 | end 35 | 36 | it 'creates the tarball' do 37 | allow(subject).to receive(:tar).and_call_original 38 | expect(subject).to receive(:system).with( 39 | 'tar -zcf tmp/1234567/123.tar.gz -C tmp/1234567 .' 40 | ) 41 | subject.url(git_repo_url, branch_name, github_repo) 42 | end 43 | end 44 | 45 | describe '#filepath' do 46 | it 'should return the correct filepath' do 47 | expect(subject.filepath('1234567', '123')).to eq 'tmp/1234567/123.tar.gz' 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /fourchette.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'fourchette/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'fourchette' 8 | spec.version = Fourchette::VERSION 9 | spec.authors = ['Jean-Philippe Boily'] 10 | spec.email = ['j@jipi.ca'] 11 | spec.summary = 'Your new best friend for isolated testing environments on Heroku.' 12 | spec.description = "Fourchette is your new best friend for having isolated testing environment. It will help you test your GitHub PRs against a fork of one your Heroku apps. You will have one Heroku app per PR now. Isn't that amazing? It will make testing way easier and you won't have the (maybe) broken code from other PRs on staging but only the code that requires testing." 13 | spec.homepage = 'https://github.com/jipiboily/fourchette' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_dependency 'rake' 22 | spec.add_dependency 'sinatra' 23 | spec.add_dependency 'sinatra-contrib' 24 | spec.add_dependency 'octokit' 25 | spec.add_dependency 'git' 26 | spec.add_dependency 'heroku', '~> 3.9' # Deprecated, but best/easiest solution for pgbackups... 27 | spec.add_dependency 'rest-client' # required for phbackups 28 | spec.add_dependency 'platform-api', '~> 0.2.0' 29 | spec.add_dependency 'sucker_punch' 30 | spec.add_dependency 'thor' 31 | 32 | spec.add_development_dependency 'foreman' 33 | spec.add_development_dependency 'pry-byebug' 34 | spec.add_development_dependency 'rspec', '~> 3.1.0' 35 | spec.add_development_dependency 'guard-rspec' 36 | spec.add_development_dependency 'terminal-notifier-guard' 37 | spec.add_development_dependency 'coveralls' 38 | end 39 | -------------------------------------------------------------------------------- /spec/lib/fourchette/pgbackups_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Fourchette::Pgbackups do 4 | describe '#copy' do 5 | let(:from_app_name) { 'awesome app' } 6 | let(:to_app_name) { 'awesomer app!' } 7 | let(:pg_backup) { Fourchette::Pgbackups.new } 8 | let(:heroku) { instance_double(Fourchette::Heroku) } 9 | 10 | let(:config_vars_from) do 11 | { 'HEROKU_POSTGRESQL_FROM_URL' => 'postgres://from...', 12 | 'PGBACKUPS_URL' => 'postgres://frombackup...' } 13 | end 14 | 15 | let(:config_vars_to) do 16 | { 'HEROKU_POSTGRESQL_TO_URL' => 'postgres://to...', 17 | 'PGBACKUPS_URL' => 'postgres://tobackup...' } 18 | end 19 | 20 | before do 21 | allow(Fourchette::Heroku).to receive(:new).and_return(heroku) 22 | 23 | allow(heroku) 24 | .to receive_message_chain(:client, :addon, :list) 25 | .and_return([{ 'name' => addon_name }]) 26 | 27 | allow(heroku) 28 | .to receive(:config_vars) 29 | .with(from_app_name) 30 | .and_return(config_vars_from) 31 | 32 | allow(heroku) 33 | .to receive(:config_vars) 34 | .with(to_app_name) 35 | .and_return(config_vars_to) 36 | end 37 | 38 | context 'when Pgbackups addon is enabled' do 39 | let(:addon_name) { 'pgbackups' } 40 | let(:backup) { instance_double(Heroku::Client::Pgbackups) } 41 | 42 | it 'launches a PG backup from origin app to destination one' do 43 | expect(Heroku::Client::Pgbackups) 44 | .to receive(:new) 45 | .with(config_vars_from['PGBACKUPS_URL'] + '/api') 46 | .and_return(backup) 47 | 48 | expect(backup).to receive(:create_transfer).with( 49 | config_vars_from['HEROKU_POSTGRESQL_FROM_URL'], 50 | 'HEROKU_POSTGRESQL_FROM_URL', 51 | config_vars_to['HEROKU_POSTGRESQL_TO_URL'], 52 | 'HEROKU_POSTGRESQL_TO_URL' 53 | ) 54 | 55 | pg_backup.copy(from_app_name, to_app_name) 56 | end 57 | end 58 | 59 | context 'when Pgbackups addon is not enabled' do 60 | let(:addon_name) { 'addon' } 61 | 62 | it 'enables Pgbackups addon and launches a PG backup' do 63 | expect(heroku) 64 | .to receive_message_chain(:client, :addon, :create) 65 | .with(to_app_name, { plan: 'pgbackups' }) 66 | 67 | expect(heroku) 68 | .to receive_message_chain(:client, :addon, :create) 69 | .with(from_app_name, { plan: 'pgbackups' }) 70 | 71 | expect(Heroku::Client::Pgbackups) 72 | .to receive_message_chain(:new, :create_transfer) 73 | 74 | pg_backup.copy(from_app_name, to_app_name) 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/fourchette/github.rb: -------------------------------------------------------------------------------- 1 | class Fourchette::GitHub 2 | include Fourchette::Logger 3 | 4 | def enable_hook 5 | logger.info 'Enabling the hooks for your app...' 6 | if fourchette_hook 7 | enable(fourchette_hook) 8 | else 9 | create_hook 10 | end 11 | end 12 | 13 | def disable_hook 14 | logger.info 'Disabling the hook for your app...' 15 | if fourchette_hook && fourchette_hook.active == true 16 | disable(fourchette_hook) 17 | else 18 | logger.error 'Nothing to disable, move along!' 19 | end 20 | end 21 | 22 | def update_hook 23 | logger.info 'Updating the hook for your app...' 24 | toggle_active_state_to fourchette_hook, fourchette_hook.active 25 | end 26 | 27 | def delete_hook 28 | logger.info 'Removing the hook for your app...' 29 | octokit.remove_hook(ENV['FOURCHETTE_GITHUB_PROJECT'], fourchette_hook.id) 30 | end 31 | 32 | def comment_pr(pr_number, comment) 33 | if Fourchette::DEBUG 34 | comment = <<-TXT 35 | ****** FOURCHETTE COMMENT ******\n 36 | \n#{comment}\n\n 37 | ****** END OF FOURCHETTE COMMENT ****** 38 | TXT 39 | end 40 | 41 | octokit.add_comment(ENV['FOURCHETTE_GITHUB_PROJECT'], pr_number, comment) 42 | end 43 | 44 | private 45 | 46 | def octokit 47 | @octokit_client ||= Octokit::Client.new( 48 | login: ENV['FOURCHETTE_GITHUB_USERNAME'], 49 | password: ENV['FOURCHETTE_GITHUB_PERSONAL_TOKEN'] 50 | ) 51 | end 52 | 53 | def create_hook 54 | logger.info 'Creating a new hook...' 55 | octokit.create_hook( 56 | ENV['FOURCHETTE_GITHUB_PROJECT'], 57 | 'web', 58 | hook_options, 59 | events: ['pull_request'], 60 | active: true 61 | ) 62 | end 63 | 64 | def hook_options 65 | { 66 | url: "#{ENV['FOURCHETTE_APP_URL']}/hooks", 67 | content_type: 'json', 68 | fourchette_env: FOURCHETTE_CONFIG[:env_name] 69 | } 70 | end 71 | 72 | def hooks 73 | octokit.hooks(ENV['FOURCHETTE_GITHUB_PROJECT']) 74 | end 75 | 76 | def fourchette_hook 77 | existing_hook = nil 78 | 79 | hooks.each do |hook| 80 | existing_hook = hook unless hook.config && hook.config.fourchette_env.nil? 81 | end 82 | 83 | existing_hook 84 | end 85 | 86 | def enable(hook) 87 | if hook.active 88 | logger.error 'The hook is already active!' 89 | else 90 | toggle_active_state_to hook, true 91 | end 92 | end 93 | 94 | def disable(hook) 95 | toggle_active_state_to hook, false 96 | end 97 | 98 | def toggle_active_state_to(hook, active_value) 99 | octokit.edit_hook( 100 | ENV['FOURCHETTE_GITHUB_PROJECT'], 101 | hook.id, 102 | 'web', 103 | hook_options, 104 | events: ['pull_request'], 105 | active: active_value 106 | ) 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/fourchette/fork.rb: -------------------------------------------------------------------------------- 1 | class Fourchette::Fork 2 | include Fourchette::Logger 3 | 4 | def initialize(params) 5 | @params = params 6 | @heroku = Fourchette::Heroku.new 7 | @github = Fourchette::GitHub.new 8 | end 9 | 10 | def update 11 | create_unless_exists 12 | 13 | build = @heroku.client.build.create(fork_name, tarball_options) 14 | monitor_build(build) 15 | end 16 | 17 | def monitor_build(build) 18 | logger.info 'Start of the build process on Heroku...' 19 | build_info = @heroku.client.build.info(fork_name, build['id']) 20 | # Let's just leave some time to Heroku to download the tarball and start 21 | # the process. This is some random timing that seems to make sense at first. 22 | sleep 30 23 | if build_info['status'] == 'failed' 24 | @github.comment_pr( 25 | pr_number, 'The build failed on Heroku. See the activity tab on Heroku.' 26 | ) 27 | fail Fourchette::DeployException 28 | end 29 | end 30 | 31 | def create 32 | @github.comment_pr( 33 | pr_number, 'Fourchette is initializing a new fork.') if Fourchette::DEBUG 34 | create_unless_exists 35 | update 36 | end 37 | 38 | def delete 39 | @heroku.delete(fork_name) 40 | @github.comment_pr(pr_number, 'Test app deleted!') 41 | end 42 | 43 | def fork_name 44 | # It needs to be lowercase only. 45 | "#{ENV['FOURCHETTE_HEROKU_APP_PREFIX']}-PR-#{pr_number}".downcase 46 | end 47 | 48 | def branch_name 49 | @params['pull_request']['head']['ref'] 50 | end 51 | 52 | def pr_number 53 | @params['pull_request']['number'] 54 | end 55 | 56 | def create_unless_exists 57 | unless app_exists? 58 | @heroku.fork(ENV['FOURCHETTE_HEROKU_APP_TO_FORK'], fork_name) 59 | post_fork_url 60 | end 61 | end 62 | 63 | private 64 | 65 | def app_exists? 66 | @heroku.app_exists?(fork_name) 67 | end 68 | 69 | def tarball_options 70 | { 71 | source_blob: { 72 | url: tarball_url 73 | } 74 | } 75 | end 76 | 77 | def tarball_url 78 | Fourchette::Tarball.new.url( 79 | github_git_url, 80 | git_branch_name, 81 | ENV['FOURCHETTE_GITHUB_PROJECT'] 82 | ) 83 | end 84 | 85 | # Update PR with URL. This is a method so that we can override it and just not 86 | # have that, if we don't want. Use case: we have custom domains, so we post 87 | # the URLs later on. 88 | def post_fork_url 89 | @github.comment_pr( 90 | pr_number, 91 | "Test URL: #{@heroku.client.app.info(fork_name)['web_url']}" 92 | ) 93 | end 94 | 95 | def git_branch_name 96 | "remotes/origin/#{branch_name}" 97 | end 98 | 99 | def github_git_url 100 | @params['pull_request']['head']['repo']['clone_url'] 101 | .gsub( 102 | '//github.com', 103 | "//#{ENV['FOURCHETTE_GITHUB_USERNAME']}:" \ 104 | "#{ENV['FOURCHETTE_GITHUB_PERSONAL_TOKEN']}@github.com" 105 | ) 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/lib/fourchette/fork_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Fourchette::Fork do 4 | subject { described_class.new(params) } 5 | 6 | let(:params) do 7 | { 8 | 'pull_request' => { 9 | 'number' => 1, 10 | 'head' => { 11 | 'ref' => '123456' 12 | } 13 | } 14 | } 15 | end 16 | let(:fork_name) { 'my-fork-pr-1' } 17 | 18 | before do 19 | stub_const( 20 | 'ENV', 21 | 'FOURCHETTE_HEROKU_APP_PREFIX' => 'my-fork', 22 | 'FOURCHETTE_HEROKU_APP_TO_FORK' => 'my-heroku-app-name' 23 | ) 24 | end 25 | 26 | describe '#create' do 27 | it 'calls #update and #create_unless_exists' do 28 | expect(subject).to receive(:create_unless_exists) 29 | expect(subject).to receive(:update) 30 | subject.create 31 | end 32 | end 33 | 34 | describe '#create_unless_exists' do 35 | after do 36 | subject.create_unless_exists 37 | end 38 | 39 | context 'app does NOT exists' do 40 | before do 41 | allow_any_instance_of(Fourchette::Heroku).to receive(:app_exists?).and_return(false) 42 | end 43 | 44 | it 'calls the fork creation' do 45 | allow(subject).to receive(:post_fork_url) 46 | expect_any_instance_of(Fourchette::Heroku).to receive(:fork) 47 | .with('my-heroku-app-name', fork_name) 48 | end 49 | 50 | it 'post the URL to the fork on the GitHub PR' do 51 | allow_any_instance_of(Fourchette::Heroku).to receive(:fork) 52 | allow_any_instance_of(Fourchette::Heroku).to receive_message_chain(:client, :app, :info) 53 | .and_return('web_url' => 'rainforestqa.com') 54 | expect_any_instance_of(Fourchette::GitHub).to receive(:comment_pr) 55 | .with(1, 'Test URL: rainforestqa.com') 56 | end 57 | end 58 | 59 | context 'app DOES exists' do 60 | before do 61 | allow_any_instance_of(Fourchette::Heroku).to receive(:app_exists?).and_return(true) 62 | end 63 | 64 | it 'does nothing' do 65 | expect_any_instance_of(Fourchette::GitHub).not_to receive(:comment_pr) 66 | expect_any_instance_of(Fourchette::Heroku).not_to receive(:fork) 67 | end 68 | end 69 | end 70 | 71 | describe '#delete' do 72 | it 'calls deletes the fork' do 73 | allow_any_instance_of(Fourchette::GitHub).to receive(:comment_pr) 74 | expect_any_instance_of(Fourchette::Heroku).to receive(:delete).with(fork_name) 75 | subject.delete 76 | end 77 | 78 | it 'comments on the GitHub PR' do 79 | allow_any_instance_of(Fourchette::Heroku).to receive(:delete) 80 | expect_any_instance_of(Fourchette::GitHub).to receive(:comment_pr) 81 | .with(1, 'Test app deleted!') 82 | subject.delete 83 | end 84 | end 85 | 86 | describe '#fork_name' do 87 | it { expect(subject.fork_name).to eq fork_name } 88 | end 89 | 90 | describe '#branch_name' do 91 | it { expect(subject.branch_name).to eq '123456' } 92 | end 93 | 94 | describe '#pr_number' do 95 | it { expect(subject.pr_number).to eq 1 } 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/fourchette/heroku.rb: -------------------------------------------------------------------------------- 1 | class Fourchette::Heroku 2 | include Fourchette::Logger 3 | 4 | EXCEPTIONS = [ 5 | Excon::Errors::UnprocessableEntity, 6 | Excon::Errors::ServiceUnavailable 7 | ] 8 | 9 | def app_exists?(name) 10 | client.app.list.collect do |app| 11 | app if app['name'] == name 12 | end.reject(&:nil?).any? 13 | end 14 | 15 | def fork(from, to) 16 | create_app(to) 17 | copy_config(from, to) 18 | copy_add_ons(from, to) 19 | copy_pg(from, to) 20 | copy_rack_and_rails_env_again(from, to) 21 | end 22 | 23 | def delete(app_name) 24 | logger.info "Deleting #{app_name}" 25 | client.app.delete(app_name) 26 | end 27 | 28 | def client 29 | api_key = ENV['FOURCHETTE_HEROKU_API_KEY'] 30 | @heroku_client ||= PlatformAPI.connect(api_key) 31 | end 32 | 33 | def config_vars(app_name) 34 | client.config_var.info(app_name) 35 | end 36 | 37 | def git_url(app_name) 38 | client.app.info(app_name)['git_url'] 39 | end 40 | 41 | def create_app(name) 42 | logger.info "Creating #{name}" 43 | client.app.create(name: name) 44 | end 45 | 46 | def copy_config(from, to) 47 | logger.info "Copying configs from #{from} to #{to}" 48 | from_congig_vars = config_vars(from) 49 | # WE SHOULD NOT MOVE THE HEROKU_POSTGRES_*_URL or DATABASE_URL... 50 | from_congig_vars.reject! do |k, _v| 51 | k.start_with?('HEROKU_POSTGRESQL_') && k.end_with?('_URL') 52 | end 53 | from_congig_vars.reject! { |k, _v| k == ('DATABASE_URL') } 54 | client.config_var.update(to, from_congig_vars) 55 | end 56 | 57 | def copy_add_ons(from, to) 58 | logger.info "Copying addons from #{from} to #{to}" 59 | from_addons = client.addon.list(from) 60 | from_addons.each do |addon| 61 | name = addon['plan']['name'] 62 | begin 63 | logger.info "Adding #{name} to #{to}" 64 | client.addon.create(to, plan: name) 65 | rescue *EXCEPTIONS => e 66 | logger.error "Failed to copy addon #{name}" 67 | logger.error e 68 | end 69 | end 70 | end 71 | 72 | def copy_pg(from, to) 73 | if pg_enabled?(from) 74 | logger.info "Copying Postgres's data from #{from} to #{to}" 75 | backup = Fourchette::Pgbackups.new 76 | backup.copy(from, to) 77 | else 78 | logger.info "Postgres not enabled on #{from}. Skipping data copy." 79 | end 80 | end 81 | 82 | def copy_rack_and_rails_env_again(from, to) 83 | env_to_update = get_original_env(from) 84 | client.config_var.update(to, env_to_update) unless env_to_update.empty? 85 | end 86 | 87 | def get_original_env(from) 88 | environments = {} 89 | %w(RACK_ENV RAILS_ENV).each do |var| 90 | if client.config_var.info(from)[var] 91 | environments[var] = client.config_var.info(from)[var] 92 | end 93 | end 94 | environments 95 | end 96 | 97 | def pg_enabled?(app) 98 | client.addon.list(app).any? do |addon| 99 | addon['addon_service']['name'] =~ /heroku.postgres/i 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /spec/lib/fourchette/github_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Fourchette::GitHub do 4 | subject { described_class.new } 5 | 6 | let(:fake_hooks) { [] } 7 | 8 | let(:fake_hook) do 9 | hook = double('hook') 10 | allow(hook).to receive(:config).and_return(nil) 11 | allow(hook).to receive(:id).and_return(123) 12 | hook 13 | end 14 | 15 | let(:fake_fourchette_hook) do 16 | allow(fake_hook.config).to receive(:fourchette_env).and_return('something') 17 | fake_hook 18 | end 19 | 20 | let(:fake_enabled_fourchette_hook) do 21 | allow(fake_fourchette_hook).to receive(:active).and_return(true) 22 | fake_fourchette_hook 23 | end 24 | 25 | let(:fake_disabled_fourchette_hook) do 26 | allow(fake_fourchette_hook).to receive(:active).and_return(false) 27 | fake_fourchette_hook 28 | end 29 | 30 | before do 31 | allow_message_expectations_on_nil 32 | allow(subject).to receive(:hooks).and_return(fake_hooks) 33 | allow_any_instance_of(Octokit::Client).to receive(:edit_hook) 34 | end 35 | 36 | describe '#enable_hook' do 37 | context 'when there is alerady a Fourchette hook' do 38 | 39 | context 'when the hook was enabled' do 40 | let(:fake_hooks) { [fake_enabled_fourchette_hook] } 41 | 42 | it 'does NOT enable the hook' do 43 | expect_any_instance_of(Octokit::Client).not_to receive(:edit_hook) 44 | 45 | subject.enable_hook 46 | end 47 | end 48 | 49 | context 'when the hook was disabled' do 50 | let(:fake_hooks) { [fake_disabled_fourchette_hook] } 51 | 52 | it 'enables the hook' do 53 | expect_any_instance_of(Octokit::Client).to receive(:edit_hook) 54 | 55 | subject.enable_hook 56 | end 57 | end 58 | end 59 | 60 | context 'when there is no Fourchette hook yet' do 61 | it 'adds a hook' do 62 | expect_any_instance_of(Octokit::Client).to receive(:create_hook) 63 | 64 | subject.enable_hook 65 | end 66 | end 67 | end 68 | 69 | describe '#disable_hook' do 70 | context 'where there is an active Fourchette hook' do 71 | let(:fake_hooks) { [fake_enabled_fourchette_hook] } 72 | 73 | it 'disables the hook' do 74 | expect_any_instance_of(Octokit::Client).to receive(:edit_hook) 75 | 76 | subject.disable_hook 77 | end 78 | end 79 | 80 | context 'when there is a disabled Fourchette hook' do 81 | let(:fake_hooks) { [fake_disabled_fourchette_hook] } 82 | it 'does not try to disable a hook' do 83 | expect(subject).not_to receive(:disable) 84 | subject.disable_hook 85 | end 86 | end 87 | 88 | context 'when there is no Fourchette hook' do 89 | it 'does not try to disable a hook' do 90 | expect(subject).not_to receive(:disable) 91 | subject.disable_hook 92 | end 93 | end 94 | end 95 | 96 | describe '#update_hook' do 97 | let(:fake_hooks) { [fake_enabled_fourchette_hook] } 98 | 99 | it 'calls toggle_active_state_to' do 100 | expect(subject) 101 | .to receive(:toggle_active_state_to) 102 | subject.update_hook 103 | end 104 | end 105 | 106 | describe '#delete_hook' do 107 | it 'deletes the hook on GitHub' do 108 | allow(subject).to receive(:fourchette_hook).and_return(fake_hook) 109 | expect_any_instance_of(Octokit::Client).to receive(:remove_hook) 110 | 111 | subject.delete_hook 112 | end 113 | end 114 | 115 | describe '#comment_pr' do 116 | before do 117 | stub_const('ENV', 'FOURCHETTE_GITHUB_PROJECT' => 'my-project') 118 | end 119 | 120 | it 'adds a comment' do 121 | expect_any_instance_of(Octokit::Client) 122 | .to receive(:add_comment).with('my-project', 1, 'yo!') 123 | 124 | subject.comment_pr(1, 'yo!') 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /spec/lib/fourchette/heroku_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Fourchette::Heroku do 4 | let(:heroku) { Fourchette::Heroku.new } 5 | let(:from_app_name) { 'awesome app' } 6 | let(:to_app_name) { 'awesomer app!' } 7 | let(:app_list) do 8 | [ 9 | { 'name' => 'fourchette-pr-7' }, 10 | { 'name' => 'fourchette-pr-8' } 11 | ] 12 | end 13 | 14 | before do 15 | client = double('client') 16 | client_app = double('client') 17 | allow(client_app).to receive(:list).and_return(app_list) 18 | allow(client).to receive(:app).and_return(client_app) 19 | config_var = double('config_var') 20 | allow(client).to receive(:config_var).and_return(config_var) 21 | 22 | allow(client.app).to receive(:info).and_return( 23 | 'git_url' => 'git@heroku.com/something.git' 24 | ) 25 | 26 | allow(heroku).to receive(:client).and_return(client) 27 | end 28 | 29 | describe '#app_exists?' do 30 | it { expect(heroku.app_exists?('fourchette-pr-7')).to eq true } 31 | it { expect(heroku.app_exists?('fourchette-pr-8')).to eq true } 32 | it { expect(heroku.app_exists?('fourchette-pr-333')).to eq false } 33 | end 34 | 35 | describe '#fork' do 36 | before do 37 | allow(heroku).to receive(:create_app) 38 | allow(heroku).to receive(:copy_config) 39 | allow(heroku).to receive(:copy_add_ons) 40 | allow(heroku).to receive(:copy_pg) 41 | allow(heroku).to receive(:copy_rack_and_rails_env_again) 42 | end 43 | 44 | %w( 45 | create_app copy_config copy_add_ons copy_pg copy_rack_and_rails_env_again 46 | ).each do |method_name| 47 | it "calls `#{method_name}'" do 48 | expect(heroku).to receive(method_name) 49 | heroku.fork(from_app_name, to_app_name) 50 | end 51 | end 52 | end 53 | 54 | describe '#git_url' do 55 | it 'returns the correct git URL' do 56 | expect(heroku.git_url(to_app_name)).to eq 'git@heroku.com/something.git' 57 | end 58 | end 59 | 60 | describe '#delete' do 61 | it 'calls delete on the Heroku client' do 62 | expect(heroku.client.app).to receive(:delete).with(to_app_name) 63 | heroku.delete(to_app_name) 64 | end 65 | end 66 | 67 | describe '#config_vars' do 68 | it 'calls config_var.info on the Heroku client' do 69 | expect(heroku.client.config_var).to receive(:info).with(from_app_name) 70 | heroku.config_vars(from_app_name) 71 | end 72 | end 73 | 74 | describe '#create_app' do 75 | it 'calls app.create on the Heroku client' do 76 | expect(heroku.client.app).to receive(:create).with(name: to_app_name) 77 | heroku.create_app(to_app_name) 78 | end 79 | end 80 | 81 | describe '#copy_config' do 82 | let(:vars) do 83 | { 84 | 'WHATEVER' => 'ok', 85 | 'HEROKU_POSTGRESQL_SOMETHING_URL' => 'FAIL@POSTGRES/DB', 86 | 'DATABASE_URL' => 'FAIL@POSTGRES/DB' 87 | } 88 | end 89 | let(:cleaned_vars) { { 'WHATEVER' => 'ok' } } 90 | 91 | it 'calls #config_vars' do 92 | allow(heroku.client.config_var).to receive(:update) 93 | expect(heroku).to receive(:config_vars).with(from_app_name).and_return(vars) 94 | heroku.copy_config(from_app_name, to_app_name) 95 | end 96 | 97 | it 'updates config vars without postgres URLs' do 98 | expect(heroku.client.config_var).to receive(:update) 99 | .with(to_app_name, cleaned_vars) 100 | allow(heroku).to receive(:config_vars).and_return(vars) 101 | heroku.copy_config('from', to_app_name) 102 | end 103 | end 104 | 105 | describe '#copy_add_ons' do 106 | let(:addon_list) { [{ 'plan' => { 'name' => 'redistogo' } }] } 107 | 108 | before do 109 | allow(heroku.client).to receive(:addon).and_return(double('addon')) 110 | allow(heroku.client.addon).to receive(:create) 111 | allow(heroku.client.addon).to receive(:list).and_return(addon_list) 112 | end 113 | 114 | it 'gets the addon list' do 115 | expect(heroku.client.addon).to receive(:list).with(from_app_name) 116 | .and_return(addon_list) 117 | heroku.copy_add_ons(from_app_name, to_app_name) 118 | end 119 | 120 | it 'creates addons' do 121 | expect(heroku.client.addon).to receive(:create).with( 122 | to_app_name, plan: 'redistogo' 123 | ) 124 | heroku.copy_add_ons(from_app_name, to_app_name) 125 | end 126 | end 127 | 128 | describe '#copy_pg' do 129 | 130 | before do 131 | allow(heroku.client).to receive(:addon).and_return(double('addon')) 132 | allow(heroku.client.addon).to receive(:list).and_return(addon_list) 133 | end 134 | 135 | context 'when a heroku-postgresql addon is enabled' do 136 | let(:addon_list) { [{ 'addon_service' => { 'name' => addon_name } }] } 137 | 138 | shared_examples 'app with pg' do 139 | it 'calls Fourchette::Pgbackups#copy' do 140 | expect_any_instance_of(Fourchette::Pgbackups).to receive(:copy).with( 141 | from_app_name, to_app_name 142 | ) 143 | heroku.copy_pg(from_app_name, to_app_name) 144 | end 145 | end 146 | 147 | context "when the addon name is 'Heroku Postgres'" do 148 | let(:addon_name) { 'Heroku Postgres' } 149 | 150 | it_behaves_like 'app with pg' 151 | end 152 | 153 | context "when the addon name is 'heroku-postgresql'" do 154 | let(:addon_name) { 'heroku-postgresql' } 155 | 156 | it_behaves_like 'app with pg' 157 | end 158 | end 159 | 160 | context 'when a heroku-postgresql addon is not enabled' do 161 | let(:addon_list) { [{ 'addon_service' => { 'name' => 'redistogo' } }] } 162 | 163 | it 'does not call Fourchette::Pgbackups#copy' do 164 | # Had to work around lack of support for any_instance and 165 | # should_not_receive 166 | # See https://github.com/rspec/rspec-mocks/issues/164 for more details 167 | count = 0 168 | allow_any_instance_of(Fourchette::Pgbackups).to receive(:copy) do |_from_app_name, _to_app_name| 169 | count += 1 170 | end 171 | heroku.copy_pg(from_app_name, to_app_name) 172 | expect(count).to eq(0) 173 | end 174 | end 175 | end 176 | 177 | describe '#copy_rack_and_rails_env_again' do 178 | context 'with RACK_ENV or RAILS_ENV setup' do 179 | before do 180 | allow(heroku).to receive(:get_original_env).and_return('RACK_ENV' => 'qa') 181 | end 182 | 183 | it 'updates the config vars' do 184 | expect(heroku.client.config_var).to receive(:update).with( 185 | to_app_name, 'RACK_ENV' => 'qa' 186 | ) 187 | heroku.copy_rack_and_rails_env_again(from_app_name, to_app_name) 188 | end 189 | end 190 | 191 | context 'with NO env setup' do 192 | before do 193 | allow(heroku).to receive(:get_original_env).and_return({}) 194 | end 195 | 196 | it 'does not update config vars' do 197 | expect(heroku.client.config_var).not_to receive(:update) 198 | heroku.copy_rack_and_rails_env_again(from_app_name, to_app_name) 199 | end 200 | end 201 | end 202 | 203 | describe '#get_original_env' do 204 | before do 205 | stub_cong_var = { 206 | 'RACK_ENV' => 'qa', 207 | 'RAILS_ENV' => 'staging', 208 | 'DATABASE_URL' => 'postgres://....' 209 | } 210 | allow(heroku).to receive_message_chain(:client, :config_var, :info).and_return(stub_cong_var) 211 | end 212 | 213 | it 'returns the set env vars' do 214 | return_value = heroku.get_original_env(from_app_name) 215 | expect(return_value).to eq('RACK_ENV' => 'qa', 'RAILS_ENV' => 'staging') 216 | end 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

This project is deprecated...

2 |

3 | Our friends at Heroku have used Fourchette and used it as an inspiration for their "Review Apps". We have decided to move from Fourchette ourselves to the "official" way and we strongly encourage you to do so. 4 |

5 | 6 |

7 | 8 | Fourchette 9 | 10 |
11 | Your new best friend for isolated testing environments on Heroku 12 |
13 | 14 | 15 | Coverage Status 16 | Gem Version 17 | 18 |

19 | 20 | Fourchette is your new best friend for having isolated testing environments. It will help you test your [@GitHub](https://github.com/github) PRs against a fork of one your [@Heroku](https://github.com/heroku) apps. You will have one Heroku app per PR now. Isn't that amazing? It will make testing way easier and you won't have the (maybe) broken code from other PRs on staging but only the code that requires testing. 21 | 22 | Fourchette is maintained by [@jipiboily](https://github.com/jipiboily/)! You can see the other [contributors here](https://github.com/rainforestapp/fourchette/graphs/contributors). 23 | 24 | **IMPORTANT: Please note that forking your Heroku app means it will copy the same addon plans and that you will pay for multiple apps and their addons. Watch out!** 25 | 26 | ## Table of content 27 | 1. [How does that work exactly?](#how-does-that-work-exactly) 28 | - [Installation](#installation) 29 | * [Configuration](#configuration) 30 | * [Enable your Fourchette instance](#enable-your-fourchette-instance) 31 | * [Enable, disable, update or delete the hook](#enable-disable-update-or-delete-the-hook) 32 | * [Before & after steps; aka callbacks](#before--after-steps-aka-callbacks) 33 | - [Rake tasks](#rake-tasks) 34 | - [Async processing note](#async-processing-note) 35 | - [Contribute](#contribute) 36 | - [Logging](#logging) 37 | - [Contributors](#contributors) 38 | 39 | ## How does that work exactly? 40 | 41 | 1. A PR is created against your GitHub project 42 | - Fourchette then receives an event via GitHub Hooks: 43 | - It [forks](https://devcenter.heroku.com/articles/fork-app) an environment making it available to you 44 | - Any new commit against that PR will update the code 45 | - Closing the PR will delete the forked app 46 | - Re-opening the PR will re-create a fork 47 | 48 | We use it a lot at [Rainforest QA](https://www.rainforestqa.com/). If you want to see a sample Fourchette app, here is one for you to look at: https://github.com/rainforestapp/rf-ourchette. 49 | 50 | ## Installation 51 | 52 | You have two choices here, the easy path, or the manual path. 53 | 54 | **Easy** 55 | 56 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy?template=https%3A%2F%2Fgithub.com%2Frainforestapp%2Ffourchette-app) 57 | 58 | **Manual** 59 | 60 | This will give you more flexibility to create before and after actions, though you could also do it with the easy path and cloning your repo, etc. 61 | 62 | 1. run `gem install fourchette` 63 | 2. run `fourchette new my-app-name`. You can replace "my-app-name" by whatever you want it, this is the name of the directory your Fourchette app will be created in. 64 | 3. run `cd my-app-name` (replace app name, again) 65 | 4. run `git init && git add . && git commit -m "Initial commit :tada:"` 66 | 5. push to Heroku 67 | 6. configure the right environment variables (see [#configuration](#configuration)) 68 | 7. Enable your Fourchette instance 69 | 70 | ### Configuration 71 | 72 | - `export FOURCHETTE_GITHUB_PROJECT="rainforestapp/fourchette"` 73 | - `export FOURCHETTE_GITHUB_USERNAME="rainforestapp"` 74 | - `export FOURCHETTE_GITHUB_PERSONAL_TOKEN='a token here...'` # You can create one here: https://github.com/settings/applications 75 | - `export FOURCHETTE_HEROKU_API_KEY="API key here"` 76 | - `export FOURCHETTE_HEROKU_APP_TO_FORK='the name of the app to fork from'` 77 | - `export FOURCHETTE_APP_URL="https://fourchette-app.herokuapp.com"` 78 | - `export FOURCHETTE_HEROKU_APP_PREFIX="fourchette"` # This is basically to namespace your forks. In that example, they would be named "fourchette-pr-1234" where "1234" is the PR number. Beware, the name can't be more than 30 characters total! It will be changed to be lowercase only, so you should probably just use lowercase characters anyways. 79 | 80 | **IMPORTANT**: the GitHub user needs to be an admin of the repo to be able to add, enable or disable the web hook used by Fourchette. You could create it by hand if you prefer. 81 | 82 | ### Enable your Fourchette instance 83 | 84 | run `bundle exec rake fourchette:enable` 85 | 86 | ### Enable, disable, update or delete the hook 87 | 88 | `bundle exec rake -T` will tell you the rake tasks available. There are tasks to enable, disable or delete the GitHub hook to your Fourchette instance. There is also one to update the hook. That last one is mostly for development, if your local tunnel URL changed and you want to update the hook's URL. 89 | 90 | ### Before & after steps, aka, callbacks 91 | 92 | You need to run steps before and/or after the creation of your new Heroku app? Let's say you want to run mirgations after deploying new code. There is a simple (and primitive) way of doing it. It might not be perfect but will work until there is a cleaner and more flexible way of doing so, if required. 93 | 94 | Create a file in your project to override the `Fourchette::Callbacks` class and include it after Fourchette. 95 | 96 | You just want to override the `before` or `after` methods of `Fourchette::Callbacks` (`lib/fourchette/callbacks.rb`) to suit your needs. In those methods, you have access to GitHub's hook data via the `@param` instance variable. 97 | 98 | ## Rake tasks 99 | 100 | ```bash 101 | rake fourchette:console # Brings up a REPL with the code loaded 102 | rake fourchette:delete # This deletes the Fourchette hook 103 | rake fourchette:disable # This disables Fourchette hook 104 | rake fourchette:enable # This enables Fourchette hook 105 | rake fourchette:update # This updates the Fourchette hook with the current URL of the app 106 | ``` 107 | 108 | ## QA Skip 109 | 110 | Adding `[qa skip]` to the title of your pull request will cause Fourchette to ignore the pull request. This is inspired by the `[ci skip]` directive that [various](http://docs.travis-ci.com/user/how-to-skip-a-build/) [ci tools](https://circleci.com/docs/skip-a-build) support. 111 | 112 | ## Async processing note 113 | 114 | Fourchette uses [Sucker Punch](https://github.com/brandonhilkert/sucker_punch), "a single-process Ruby asynchronous processing library". No need for redis or extra processes. It also mean you can run it for free on Heroku, if this is what you want. 115 | 116 | ## License 117 | 118 | See [here](LICENSE.txt) 119 | 120 | ## Contribute 121 | 122 | - fork & clone 123 | - `bundle install` 124 | - `foreman start` 125 | - You now have the app running on port 9292 126 | 127 | Bonus: if you need a tunnel to your local dev machine to work with GitHub hooks, you might want to look at https://ngrok.com/. 128 | 129 | ### Logging 130 | 131 | If you want the maximum output in your GitHub comments, set this environment variable: 132 | 133 | ``` 134 | export DEBUG='true' 135 | ``` 136 | 137 | # Thanks to... 138 | 139 | - [@jpsirois](https://github.com/jpsirois/) for the logo! 140 | --------------------------------------------------------------------------------