├── .codeclimate.yml ├── .csslintrc ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── .travis.yml ├── CONTRIBUTING.md ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── Rakefile ├── app ├── actions │ ├── base_action.rb │ ├── deploy_action.rb │ ├── lock_action.rb │ ├── slash_actions.rb │ ├── unlock_action.rb │ └── unlock_all_action.rb ├── assets │ ├── config │ │ └── manifest.js │ ├── images │ │ └── .keep │ ├── javascripts │ │ └── application.js │ └── stylesheets │ │ ├── application.css │ │ └── callout.css ├── commands │ ├── base_command.rb │ ├── boom_command.rb │ ├── check_command.rb │ ├── deploy_command.rb │ ├── environments_command.rb │ ├── help_command.rb │ ├── latest_command.rb │ ├── lock_command.rb │ ├── repository_matcher.rb │ ├── slash_commands.rb │ ├── unlock_all_command.rb │ └── unlock_command.rb ├── controllers │ ├── application_controller.rb │ ├── concerns │ │ └── .keep │ ├── documentation_controller.rb │ ├── pages_controller.rb │ ├── sessions_controller.rb │ ├── slack_controller.rb │ └── teams_controller.rb ├── github │ ├── deployment_status_event.rb │ ├── github_event_handler.rb │ ├── installation_event.rb │ ├── push_event.rb │ └── status_event.rb ├── helpers │ ├── application_helper.rb │ └── repositories_helper.rb ├── mailers │ └── .keep ├── messages │ ├── action_declined_message.rb │ ├── already_locked_message.rb │ ├── auto_deployment_configured_message.rb │ ├── auto_deployment_created_message.rb │ ├── auto_deployment_failed_message.rb │ ├── auto_deployment_locked_message.rb │ ├── auto_deployment_stuck_pending_message.rb │ ├── bad_ref_message.rb │ ├── check_message.rb │ ├── deployment_created_message.rb │ ├── environment_locked_message.rb │ ├── environments_message.rb │ ├── error_message.rb │ ├── github_authenticate_message.rb │ ├── github_deployment_status_message.rb │ ├── github_no_deployment_status_message.rb │ ├── help_message.rb │ ├── latest_message.rb │ ├── lock_nag_message.rb │ ├── lock_stolen_message.rb │ ├── locked_message.rb │ ├── ping_message.rb │ ├── red_commit_message.rb │ ├── slack_message.rb │ ├── unauthorized_message.rb │ ├── unlocked_all_message.rb │ ├── unlocked_message.rb │ └── validation_error_message.rb ├── models │ ├── .keep │ ├── auto_deployment.rb │ ├── commit_status_context.rb │ ├── concerns │ │ └── .keep │ ├── connected_account.rb │ ├── deployment.rb │ ├── deployment_request.rb │ ├── deployment_response.rb │ ├── deployment_status.rb │ ├── early_access.rb │ ├── environment.rb │ ├── github_account.rb │ ├── installation.rb │ ├── last_deployment.rb │ ├── lock.rb │ ├── lock_response.rb │ ├── message_action.rb │ ├── repository.rb │ ├── slack_account.rb │ ├── slack_bot.rb │ ├── slack_team.rb │ ├── status.rb │ └── user.rb ├── validators │ └── repository_validator.rb ├── views │ ├── documentation │ │ └── index.html.erb │ ├── layouts │ │ └── application.html.erb │ ├── messages │ │ ├── _environment_locked.text.erb │ │ ├── action_declined.text.erb │ │ ├── already_locked.text.erb │ │ ├── auto_deployment_configured.text.erb │ │ ├── auto_deployment_created.text.erb │ │ ├── auto_deployment_failed.text.erb │ │ ├── auto_deployment_failed │ │ │ └── rebuild.text.erb │ │ ├── auto_deployment_locked.text.erb │ │ ├── auto_deployment_stuck_pending.text.erb │ │ ├── bad_ref.text.erb │ │ ├── check.text.erb │ │ ├── deployment_created.text.erb │ │ ├── environment_locked.text.erb │ │ ├── environments.text.erb │ │ ├── error.text.erb │ │ ├── github_deployment_status.text.erb │ │ ├── github_no_deployment_status.text.erb │ │ ├── help.text.erb │ │ ├── latest.text.erb │ │ ├── lock_nag.text.erb │ │ ├── lock_stolen.text.erb │ │ ├── locked.text.erb │ │ ├── red_commit.text.erb │ │ ├── unauthorized.text.erb │ │ ├── unlocked.text.erb │ │ ├── unlocked_all.text.erb │ │ └── validation_error.text.erb │ ├── pages │ │ └── index.html.erb │ ├── slack │ │ ├── early_access.html.erb │ │ ├── install.html.erb │ │ └── installed.html.erb │ └── teams │ │ └── index.html.erb └── workers │ ├── auto_deployment_watchdog_worker.rb │ ├── github_deployment_watchdog_worker.rb │ └── lock_nag_worker.rb ├── bin ├── bundle ├── docker-compose-setup ├── rails ├── rake ├── rspec ├── setup └── spring ├── config.ru ├── config ├── application.rb ├── boot.rb ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── initializers │ ├── assets.rb │ ├── backtrace_silencers.rb │ ├── cookies_serializer.rb │ ├── filter_parameter_logging.rb │ ├── inflections.rb │ ├── logging.rb │ ├── mime_types.rb │ ├── omniauth.rb │ ├── rollbar.rb │ ├── routing.rb │ ├── session_store.rb │ ├── slashdeploy.rb │ ├── statsd.rb │ ├── subscribers.rb │ ├── warden.rb │ └── wrap_parameters.rb ├── locales │ └── en.yml ├── puma.rb ├── routes.rb └── secrets.yml ├── db ├── migrate │ ├── 20160203003153_create_users.rb │ ├── 20160203003510_create_environments.rb │ ├── 20160203113511_create_locks.rb │ ├── 20160205012702_add_environment_ref_to_locks.rb │ ├── 20160205013625_create_repositories.rb │ ├── 20160205060045_add_user_id_to_locks.rb │ ├── 20160205063654_create_connected_accounts.rb │ ├── 20160209012632_add_in_channel_to_environments.rb │ ├── 20160209082726_add_github_secret_to_repositories.rb │ ├── 20160209085736_add_auto_deploy_branch_to_environment.rb │ ├── 20160210071446_add_aliases_to_environments.rb │ ├── 20160210094548_create_slack_teams.rb │ ├── 20160211061750_add_defaults_to_repositories_and_environments.rb │ ├── 20160211091522_add_required_contexts_to_environments.rb │ ├── 20160211161702_create_early_accesses.rb │ ├── 20160212035321_create_auto_deployments.rb │ ├── 20160609042717_create_slack_bots.rb │ ├── 20160725205816_add_slack_notifications_to_users.rb │ ├── 20160727164721_enable_slack_notifications.rb │ ├── 20160804213314_create_message_actions.rb │ ├── 20160815212720_drop_unique_environment_index.rb │ ├── 20161217031700_add_target_url_and_description_to_statuses.rb │ ├── 20170704235901_create_installations.rb │ ├── 20180308001951_add_raw_config_to_repository.rb │ └── 20181211003305_add_timestampts_to_github_accounts.rb ├── seeds.rb └── structure.sql ├── docker-compose.yml ├── lib ├── assets │ ├── .keep │ ├── javascripts │ │ └── flat-ui.js │ └── stylesheets │ │ └── flat-ui.css.scss ├── github.rb ├── github │ ├── app.rb │ ├── client │ │ ├── fake.rb │ │ └── octokit.rb │ └── errors.rb ├── hookshot.rb ├── hookshot │ └── router.rb ├── omniauth │ └── strategies │ │ ├── github.rb │ │ ├── jwt.rb │ │ └── slack.rb ├── perty.rb ├── perty │ ├── logger.rb │ └── rack.rb ├── rack │ └── statsd.rb ├── slack.rb ├── slack │ ├── attachment.rb │ ├── client │ │ ├── fake.rb │ │ └── faraday.rb │ └── message.rb ├── slash.rb ├── slash │ ├── action.rb │ ├── action_payload.rb │ ├── command.rb │ ├── command_payload.rb │ ├── handler.rb │ ├── matcher │ │ └── regexp.rb │ ├── middleware │ │ ├── logging.rb │ │ ├── normalize_text.rb │ │ └── verify.rb │ ├── rack.rb │ ├── response.rb │ ├── route.rb │ └── router.rb ├── slashdeploy.rb ├── slashdeploy │ ├── auth.rb │ ├── config.rb │ ├── errors.rb │ └── service.rb └── tasks │ ├── .keep │ ├── brakeman.rake │ ├── cd.rake │ ├── github.rake │ ├── locks.rake │ ├── rubocop.rake │ └── statuses.rake ├── log └── .keep ├── ngrok.yml ├── public ├── 404.html ├── 422.html ├── 500.html ├── favicon.ico └── robots.txt ├── spec ├── commands │ └── slash_commands_spec.rb ├── controllers │ └── sessions_controller_spec.rb ├── features │ ├── add_to_slack_spec.rb │ ├── auto_deploy_spec.rb │ ├── commands_spec.rb │ └── github_webhooks_spec.rb ├── fixtures │ ├── github │ │ ├── deployment_status_event.json │ │ ├── installation_event.json │ │ ├── push_event.json │ │ └── status_event.json │ ├── github_accounts.yml │ ├── installations.yml │ ├── repositories.yml │ ├── slack_accounts.yml │ ├── slack_teams.yml │ └── users.yml ├── github │ ├── app_spec.rb │ ├── client │ │ └── octokit_spec.rb │ └── github_event_handler_spec.rb ├── hookshot │ └── router_spec.rb ├── hookshot_spec.rb ├── models │ ├── auto_deployment_spec.rb │ ├── deployment_spec.rb │ ├── environment_spec.rb │ ├── lock_spec.rb │ ├── message_action_spec.rb │ ├── repository_spec.rb │ ├── slack_team_spec.rb │ └── user_spec.rb ├── omniauth │ └── strategies │ │ └── jwt_spec.rb ├── perty │ └── logger_spec.rb ├── rails_helper.rb ├── slack │ ├── client │ │ └── faraday_spec.rb │ └── message_spec.rb ├── slash │ ├── middleware │ │ └── verify_spec.rb │ ├── rack_spec.rb │ ├── response_spec.rb │ └── router_spec.rb ├── slashdeploy │ ├── config_spec.rb │ └── service_spec.rb ├── spec_helper.rb ├── support │ ├── features.rb │ ├── omniauth.rb │ ├── sidekiq.rb │ └── warden.rb └── validators │ └── repository_validator_spec.rb └── vendor └── assets ├── fonts ├── glyphicons │ ├── flat-ui-icons-regular.eot │ ├── flat-ui-icons-regular.svg │ ├── flat-ui-icons-regular.ttf │ ├── flat-ui-icons-regular.woff │ └── selection.json └── lato │ ├── lato-black.eot │ ├── lato-black.svg │ ├── lato-black.ttf │ ├── lato-black.woff │ ├── lato-bold.eot │ ├── lato-bold.svg │ ├── lato-bold.ttf │ ├── lato-bold.woff │ ├── lato-bolditalic.eot │ ├── lato-bolditalic.svg │ ├── lato-bolditalic.ttf │ ├── lato-bolditalic.woff │ ├── lato-italic.eot │ ├── lato-italic.svg │ ├── lato-italic.ttf │ ├── lato-italic.woff │ ├── lato-light.eot │ ├── lato-light.svg │ ├── lato-light.ttf │ ├── lato-light.woff │ ├── lato-regular.eot │ ├── lato-regular.svg │ ├── lato-regular.ttf │ └── lato-regular.woff ├── javascripts └── .keep └── stylesheets ├── .keep └── bootstrap.css /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | brakeman: 4 | enabled: true 5 | bundler-audit: 6 | enabled: true 7 | csslint: 8 | enabled: true 9 | duplication: 10 | enabled: true 11 | config: 12 | languages: 13 | - ruby 14 | - javascript 15 | - python 16 | - php 17 | eslint: 18 | enabled: true 19 | fixme: 20 | enabled: true 21 | rubocop: 22 | enabled: true 23 | ratings: 24 | paths: 25 | - Gemfile.lock 26 | - "**.erb" 27 | - "**.haml" 28 | - "**.rb" 29 | - "**.rhtml" 30 | - "**.slim" 31 | - "**.css" 32 | - "**.inc" 33 | - "**.js" 34 | - "**.jsx" 35 | - "**.module" 36 | - "**.php" 37 | - "**.py" 38 | exclude_paths: 39 | - config/ 40 | - db/ 41 | - spec/ 42 | - vendor/ 43 | - lib/assets 44 | -------------------------------------------------------------------------------- /.csslintrc: -------------------------------------------------------------------------------- 1 | --exclude-exts=.min.css 2 | --ignore=adjoining-classes,box-model,ids,order-alphabetical,unqualified-attributes 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*{.,-}min.js 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | on: 4 | - push 5 | - pull_request 6 | env: 7 | DATABASE_TEST_URL: postgres://postgres:postgres@postgres/postgres 8 | RACK_ENV: test 9 | REDIS_URL: redis://redis:6379/0 10 | jobs: 11 | test: 12 | runs-on: ubuntu-20.04 13 | container: library/ruby:2.6.7 14 | services: 15 | postgres: 16 | image: library/postgres:9.6 17 | env: 18 | POSTGRES_HOST_AUTH_METHOD: trust 19 | options: >- 20 | --health-cmd pg_isready 21 | --health-interval 10s 22 | --health-timeout 5s 23 | --health-retries 5 24 | redis: 25 | image: library/redis:latest 26 | steps: 27 | - run: apt-get update && apt-get install -y postgresql-client 28 | - uses: actions/checkout@v2 29 | - run: bundle install 30 | - run: bin/rake db:setup 31 | - run: bin/rake db:migrate 32 | - run: bin/rake 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-journal 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | !/log/.keep 17 | /tmp 18 | /spec/examples.txt 19 | .env* 20 | *.pem 21 | .byebug_history 22 | 23 | # Ignore asdf ruby version manager 24 | .tool-versions 25 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.6.7 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | cache: bundler 3 | #before_install: 4 | # - gem install bundler 5 | dist: trusty 6 | rvm: 7 | - 2.6.7 8 | before_script: 9 | - psql -c 'create database travis_ci_test;' -U postgres 10 | - ./bin/rake db:setup 11 | env: 12 | - DATABASE_URL=postgres://postgres:@localhost/travis_ci_test 13 | script: ./bin/rake 14 | notifications: 15 | email: false 16 | addons: 17 | postgresql: "9.6" # json data type was introduced in postgres 9.2. Travis default is 9.1 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.6.7 2 | 3 | RUN apt-get update && apt-get install -y \ 4 | build-essential openssl libssl-dev libpq-dev postgresql-client 5 | RUN gem install bundler 6 | 7 | WORKDIR /home/app 8 | 9 | COPY Gemfile Gemfile.lock /home/app/ 10 | RUN bundle install --jobs 4 --retry 3 11 | 12 | COPY . /home/app 13 | 14 | CMD ["bundle", "exec", "rake"] 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby '2.6.7' 4 | 5 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' 6 | gem 'rails', '~> 4.2.10' 7 | gem 'uglifier', '>= 1.3.0' 8 | gem 'jquery-rails' 9 | gem 'turbolinks' 10 | gem 'virtus' 11 | gem 'pg', '~> 0.21' 12 | gem 'postgres_ext' 13 | gem 'oj' 14 | gem 'puma' 15 | #gem 'sass' 16 | gem 'sassc-rails' 17 | gem 'therubyracer' 18 | 19 | # Not actually used, but Rails asset pipeline (sprockets) is loading it. 20 | # TODO: Try to remove with config.generators.javascript_engine = :js 21 | gem 'coffee-script' 22 | 23 | # Github API Client Library. 24 | gem 'octokit', '>= 4.16.0' 25 | 26 | # Used for interacting with Slack API. 27 | gem 'faraday', '0.9.1' 28 | gem 'faraday_middleware' 29 | 30 | # async worker. 31 | gem 'sidekiq' 32 | 33 | # Visibility 34 | gem 'rollbar', '~> 2.8.0' 35 | gem 'lograge' 36 | gem 'dogstatsd-ruby', '~> 1.5.0' 37 | 38 | # Auth 39 | gem 'jwt' 40 | gem 'warden' 41 | gem 'oauth2' 42 | gem 'omniauth' 43 | gem 'omniauth-oauth2' 44 | 45 | group :development, :test do 46 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console 47 | gem 'byebug' 48 | gem 'rspec', '~> 3.7.0' 49 | gem 'rspec-rails' 50 | gem 'rspec-activemodel-mocks' 51 | gem 'rubocop', '~> 1.16.0' 52 | 53 | gem 'pry', '0.10.3' 54 | gem 'pry-rails', '0.3.4' 55 | end 56 | 57 | group :test do 58 | gem 'webmock', require: false 59 | gem 'capybara' 60 | gem 'codeclimate-test-reporter', '0.4.8', require: nil 61 | end 62 | 63 | group :development do 64 | # Access an IRB console on exception pages or by using <%= console %> in views 65 | gem 'web-console', '~> 2.0' 66 | 67 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring 68 | gem 'spring' 69 | gem 'foreman' 70 | gem 'brakeman' 71 | gem 'dotenv-rails', '~> 2.2' 72 | gem 'byebug' 73 | end 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Remind101, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build 2 | 3 | .PHONY: build 4 | build: 5 | docker-compose build 6 | 7 | .PHONY: test 8 | test: 9 | docker-compose run --rm test rake 10 | 11 | .PHONY: shell 12 | shell: 13 | docker-compose run --rm test bash 14 | 15 | .PHONY: ngrok 16 | ngrok: 17 | ngrok start -config=./ngrok.yml default 18 | 19 | .PHONY: dev 20 | dev: 21 | docker-compose run --rm --service-ports dev 22 | 23 | .PHONY: psql 24 | psql: 25 | docker-compose run --rm test psql -h postgres -U postgres 26 | 27 | .PHONY: reset 28 | reset: 29 | docker-compose stop 30 | docker-compose rm 31 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -C config/puma.rb 2 | worker: bundle exec sidekiq -c 2 -q auto_deployment_watchdog -q github_deployment_watchdog -q lock_nag 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SlashDeploy [![Build Status](https://travis-ci.org/remind101/slashdeploy.svg?branch=master)](https://travis-ci.org/remind101/slashdeploy) [![Code Climate](https://codeclimate.com/github/remind101/slashdeploy/badges/gpa.svg)](https://codeclimate.com/github/remind101/slashdeploy) 2 | 3 | [SlashDeploy](https://slashdeploy.io) is a web app for triggering [GitHub 4 | Deployments](https://developer.github.com/v3/repos/deployments/) via a `/deploy` 5 | command in Slack. 6 | 7 | ## Installation 8 | 9 | SlashDeploy is already hosted at https://slashdeploy.io. All you have to do is 10 | add it to your Slack team: 11 | 12 | Add to Slack 15 | 16 | ## Usage 17 | 18 | Deploy a repository to the default environment (production): 19 | 20 | ```console 21 | /deploy ejholmes/acme-inc 22 | ``` 23 | 24 | Deploy a repository to a specific environment: 25 | 26 | ```console 27 | /deploy ejholmes/acme-inc to staging 28 | ``` 29 | 30 | Deploy a branch: 31 | 32 | ```console 33 | /deploy ejholmes/acme-inc@topic-branch to staging 34 | ``` 35 | 36 | And more at . 37 | 38 | ## Features 39 | 40 | * Create GitHub Deployments directly from Slack. 41 | * Receive Slack DM's whenever GitHub Deployments change status. 42 | * Trigger GitHub Deployments when a set of commit statuses pass (Continuous 43 | Delivery). 44 | * Environment locking. 45 | 46 | ## Contributing 47 | 48 | Contributions are highly welcome! If you'd like to contribute, please read 49 | [CONTRIBUTING.md](./CONTRIBUTING.md) 50 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require File.expand_path('config/application', __dir__) 7 | 8 | Rails.application.load_tasks 9 | 10 | task security: [:'brakeman:run'] 11 | 12 | if ENV['CI'] 13 | task default: [:rubocop, :spec, :security] 14 | else 15 | task default: [:rubocop, :spec] 16 | end 17 | -------------------------------------------------------------------------------- /app/actions/base_action.rb: -------------------------------------------------------------------------------- 1 | # BaseAction is a base action for other actions to inherit from. Actions 2 | # should implement the `run` method. 3 | class BaseAction 4 | attr_reader :env 5 | attr_reader :slashdeploy 6 | 7 | def self.call(env) 8 | new(env, ::SlashDeploy.service).call 9 | end 10 | 11 | def initialize(env, slashdeploy) 12 | @env = env 13 | @slashdeploy = slashdeploy 14 | end 15 | 16 | def call 17 | logger.with_module(self.class) do 18 | logger.info('running action') 19 | run 20 | end 21 | end 22 | 23 | def run 24 | fail NotImplementedError 25 | end 26 | 27 | private 28 | 29 | def account 30 | env['account'] 31 | end 32 | 33 | # The User object 34 | def user 35 | account.user 36 | end 37 | 38 | # The Slash::Action object 39 | def action 40 | env['action'] 41 | end 42 | 43 | # The MessageAction object 44 | def message_action 45 | env['message_action'] 46 | end 47 | 48 | def params 49 | env['params'] 50 | end 51 | 52 | def logger 53 | Rails.logger 54 | end 55 | 56 | delegate :transaction, to: :'ActiveRecord::Base' 57 | end 58 | -------------------------------------------------------------------------------- /app/actions/deploy_action.rb: -------------------------------------------------------------------------------- 1 | class DeployAction < BaseAction 2 | def run 3 | transaction do 4 | env.merge!('params' => message_action.action_params.to_h) 5 | if action.value == 'yes' 6 | DeployCommand.call(env) 7 | else 8 | Slash.reply ActionDeclinedMessage.build \ 9 | declined_action_text: 'deploy' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/actions/lock_action.rb: -------------------------------------------------------------------------------- 1 | # LockAction handles the response from the lock command buttons 2 | class LockAction < BaseAction 3 | def run 4 | transaction do 5 | env.merge!('params' => message_action.action_params.to_h) 6 | if action.value == 'yes' 7 | LockCommand.call(env) 8 | else 9 | Slash.reply ActionDeclinedMessage.build \ 10 | declined_action_text: 'steal lock' 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/actions/slash_actions.rb: -------------------------------------------------------------------------------- 1 | # SlashActions is a slash handler that provides SlashDeploy slack slash 2 | # actions. This class routes whitelisted action names to the appropriate 3 | # action. 4 | class SlashActions 5 | ACTIONS = [ 6 | LockAction, 7 | UnlockAction, 8 | DeployAction 9 | ] 10 | 11 | attr_reader :actions 12 | 13 | def self.build 14 | actions = ACTIONS.each_with_object({}) { |klass, h| h[klass.name] = klass } 15 | new actions 16 | end 17 | 18 | def initialize(actions) 19 | @actions = actions 20 | end 21 | 22 | def call(env) 23 | account = env['account'] 24 | user = account.user 25 | 26 | # User needs a Github account, so bubble MissingGitHubAccount if missing. 27 | user.github_account 28 | 29 | scope = { 30 | person: { id: user.id, username: user.username } 31 | } 32 | 33 | Rollbar.scoped(scope) do 34 | begin 35 | callback_id = env['action'].payload.callback_id 36 | message_action = MessageAction.find_by_callback_id(callback_id) 37 | if message_action 38 | action = actions[message_action.action] 39 | if action 40 | env['message_action'] = message_action 41 | action.call(env) 42 | else 43 | Slash.reply ErrorMessage.build 44 | end 45 | else 46 | Slash.reply ErrorMessage.build 47 | end 48 | rescue SlashDeploy::RepoUnauthorized => e 49 | Slash.reply UnauthorizedMessage.build \ 50 | repository: e.repository 51 | rescue StandardError => e 52 | Rollbar.error(e) 53 | raise e if Rails.env.test? 54 | Slash.reply ErrorMessage.build 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /app/actions/unlock_action.rb: -------------------------------------------------------------------------------- 1 | # UnlockAction handles the response from the unlock command buttons 2 | class UnlockAction < BaseAction 3 | def run 4 | transaction do 5 | env.merge!('params' => message_action.action_params.to_h) 6 | if action.value == 'yes' 7 | UnlockCommand.call(env) 8 | else 9 | Slash.reply ActionDeclinedMessage.build \ 10 | declined_action_text: 'unlock' 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/actions/unlock_all_action.rb: -------------------------------------------------------------------------------- 1 | # UnlockAllAction handles the response from the "unlock all" buttons. 2 | class UnlockAllAction < BaseAction 3 | def run 4 | transaction do 5 | env.merge!('params' => message_action.action_params.to_h) 6 | if action.value == 'yes' 7 | UnlockAllCommand.call(env) 8 | else 9 | Slash.reply ActionDeclinedMessage.build \ 10 | declined_action_text: 'unlock' 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../javascripts .js 3 | //= link_directory ../stylesheets .css 4 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/app/assets/images/.keep -------------------------------------------------------------------------------- /app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require jquery 14 | //= require jquery_ujs 15 | //= require turbolinks 16 | //= require flat-ui 17 | //= require_tree . 18 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require bootstrap 14 | *= require flat-ui 15 | *= require_tree . 16 | *= require_self 17 | */ 18 | 19 | .navbar-brand .beta { 20 | display: inline-block; 21 | position: relative; 22 | top: -18px; 23 | left: -12px; 24 | font-size: 15px; 25 | color: #1abc9c; 26 | } 27 | 28 | #intro { 29 | margin-top: 40px; 30 | margin-bottom: 40px; 31 | } 32 | -------------------------------------------------------------------------------- /app/assets/stylesheets/callout.css: -------------------------------------------------------------------------------- 1 | .bs-callout { 2 | padding: 20px; 3 | margin: 20px 0; 4 | border: 1px solid #eee; 5 | border-left-width: 5px; 6 | border-radius: 3px; 7 | } 8 | .bs-callout h4 { 9 | margin-top: 0; 10 | margin-bottom: 5px; 11 | } 12 | .bs-callout p:last-child { 13 | margin-bottom: 0; 14 | } 15 | .bs-callout code { 16 | border-radius: 3px; 17 | } 18 | .bs-callout+.bs-callout { 19 | margin-top: -5px; 20 | } 21 | .bs-callout-default { 22 | border-left-color: #777; 23 | } 24 | .bs-callout-default h4 { 25 | color: #777; 26 | } 27 | .bs-callout-primary { 28 | border-left-color: #428bca; 29 | } 30 | .bs-callout-primary h4 { 31 | color: #428bca; 32 | } 33 | .bs-callout-success { 34 | border-left-color: #5cb85c; 35 | } 36 | .bs-callout-success h4 { 37 | color: #5cb85c; 38 | } 39 | .bs-callout-danger { 40 | border-left-color: #d9534f; 41 | } 42 | .bs-callout-danger h4 { 43 | color: #d9534f; 44 | } 45 | .bs-callout-warning { 46 | border-left-color: #f0ad4e; 47 | } 48 | .bs-callout-warning h4 { 49 | color: #f0ad4e; 50 | } 51 | .bs-callout-info { 52 | border-left-color: #5bc0de; 53 | } 54 | .bs-callout-info h4 { 55 | color: #5bc0de; 56 | } 57 | -------------------------------------------------------------------------------- /app/commands/base_command.rb: -------------------------------------------------------------------------------- 1 | # BaseCommand is a base command for other commands to inherit from. Commands 2 | # should implement the `run` method. 3 | class BaseCommand 4 | attr_reader :env 5 | attr_reader :slashdeploy 6 | 7 | def self.call(env) 8 | new(env, ::SlashDeploy.service).call 9 | end 10 | 11 | def initialize(env, slashdeploy) 12 | @env = env 13 | @slashdeploy = slashdeploy 14 | end 15 | 16 | def call 17 | logger.with_module(self.class) do 18 | logger.info('running command') 19 | run 20 | end 21 | end 22 | 23 | def run 24 | fail NotImplementedError 25 | end 26 | 27 | private 28 | 29 | def account 30 | env['account'] 31 | end 32 | 33 | def user 34 | account.user 35 | end 36 | 37 | def cmd 38 | env['cmd'] 39 | end 40 | 41 | def params 42 | env['params'] 43 | end 44 | 45 | def logger 46 | Rails.logger 47 | end 48 | 49 | delegate :transaction, to: :'ActiveRecord::Base' 50 | end 51 | -------------------------------------------------------------------------------- /app/commands/boom_command.rb: -------------------------------------------------------------------------------- 1 | class BoomCommand < BaseCommand 2 | def run 3 | fail 'Boom' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/commands/check_command.rb: -------------------------------------------------------------------------------- 1 | # CheckCommand handles the `/deploy check` command. 2 | class CheckCommand < BaseCommand 3 | def run 4 | transaction do 5 | repo = Repository.with_name(params['repository']) 6 | return Slash.reply(ValidationErrorMessage.build(record: repo)) if repo.invalid? 7 | 8 | env = repo.environment(params['environment']) 9 | return Slash.reply(EnvironmentsMessage.build(repository: repo)) unless env 10 | return Slash.reply(ValidationErrorMessage.build(record: env)) if env.invalid? 11 | 12 | Slash.say CheckMessage.build \ 13 | environment: env, 14 | slack_team: account.slack_team 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/commands/environments_command.rb: -------------------------------------------------------------------------------- 1 | # EnvironmentsCommand handles the `/deploy where` subcommand. 2 | class EnvironmentsCommand < BaseCommand 3 | def run 4 | transaction do 5 | repo = Repository.with_name(params['repository']) 6 | return Slash.reply(ValidationErrorMessage.build(record: repo)) if repo.invalid? 7 | 8 | slashdeploy.authorize! user, repo 9 | 10 | Slash.say EnvironmentsMessage.build \ 11 | repository: repo 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/commands/help_command.rb: -------------------------------------------------------------------------------- 1 | # HelpCommand handles the `/deploy help` subcommand, which prints the usage 2 | # information. 3 | class HelpCommand < BaseCommand 4 | def run 5 | Slash.reply HelpMessage.build \ 6 | not_found: params['not_found'] 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/commands/latest_command.rb: -------------------------------------------------------------------------------- 1 | # LatestCommand handles the `/deploy latest` subcommand. 2 | class LatestCommand < BaseCommand 3 | def run 4 | transaction do 5 | repo = Repository.with_name(params['repository']) 6 | return Slash.reply(ValidationErrorMessage.build(record: repo)) if repo.invalid? 7 | 8 | env = repo.environment(params['environment']) 9 | last_deployment = slashdeploy.last_deployment(user, repo, env) 10 | 11 | Slash.say LatestMessage.build \ 12 | last_deployment: last_deployment.last_deployment, 13 | last_deployment_state: last_deployment.last_deployment_status.state 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/commands/lock_command.rb: -------------------------------------------------------------------------------- 1 | # LockCommand handles the `/deploy lock` command. 2 | class LockCommand < BaseCommand 3 | def run 4 | transaction do 5 | repo = Repository.with_name(params['repository']) 6 | return Slash.reply(ValidationErrorMessage.build(record: repo)) if repo.invalid? 7 | 8 | env = repo.environment(params['environment']) 9 | return Slash.reply(EnvironmentsMessage.build(repository: repo)) unless env 10 | return Slash.reply(ValidationErrorMessage.build(record: env)) if env.invalid? 11 | 12 | begin 13 | resp = slashdeploy.lock_environment(user, env, message: params['message'].try(:strip), force: params['force']) 14 | if resp 15 | Slash.say LockedMessage.build \ 16 | environment: env, 17 | stolen_lock: resp.stolen, 18 | slack_team: account.slack_team 19 | else 20 | Slash.say AlreadyLockedMessage.build \ 21 | environment: env 22 | end 23 | rescue SlashDeploy::EnvironmentLockedError => e 24 | message_action = slashdeploy.create_message_action( 25 | LockAction, 26 | force: true, 27 | repository: params['repository'], 28 | environment: params['environment'], 29 | message: params['message'] 30 | ) 31 | 32 | Slash.reply EnvironmentLockedMessage.build \ 33 | environment: env, 34 | lock: e.lock, 35 | slack_team: account.slack_team, 36 | message_action: message_action 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/commands/repository_matcher.rb: -------------------------------------------------------------------------------- 1 | class RepositoryMatcher 2 | attr_reader :matcher 3 | 4 | def initialize(matcher) 5 | @matcher = matcher 6 | end 7 | 8 | def match(env) 9 | matcher.match(env).tap do |params| 10 | break unless params 11 | 12 | if params['repository'].present? && !params['repository'].include?('/') 13 | team = env['account'].slack_team 14 | if team.github_organization.present? 15 | params['repository'] = "#{team.github_organization}/#{params['repository']}" 16 | else 17 | return nil 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/commands/slash_commands.rb: -------------------------------------------------------------------------------- 1 | # SlashCommands is a slash handler that provides the SlashDeploy slack slash 2 | # commands. This class is simply a demuxer that routes requests to the appropriate 3 | # sub command. 4 | class SlashCommands 5 | REPO = /(?\S+?)/ 6 | ENV = /(?\S+?)/ 7 | REF = /(?\S+?)/ 8 | ON_TO = /(on|to){1}/ 9 | 10 | attr_reader :router 11 | 12 | # order matters, uses first match priority. 13 | def self.route 14 | router = Slash::Router.new 15 | router.match match_regexp(/^help$/), HelpCommand 16 | router.match match_regexp(/^where #{REPO}$/), EnvironmentsCommand 17 | router.match match_regexp(/^lock #{ENV} #{ON_TO} #{REPO}(:(?.*(?!)?$/), LockCommand 18 | router.match match_regexp(/^unlock all$/), UnlockAllCommand 19 | router.match match_regexp(/^unlock #{ENV} #{ON_TO} #{REPO}$/), UnlockCommand 20 | router.match match_regexp(/^check #{ENV} #{ON_TO} #{REPO}$/), CheckCommand 21 | router.match match_regexp(/^boom$/), BoomCommand 22 | router.match match_regexp(/^#{REPO}(@#{REF})?( #{ON_TO} #{ENV})?(?!)?$/), DeployCommand 23 | router.match match_regexp(/^latest #{REPO}( #{ON_TO} #{ENV})?$/), LatestCommand 24 | 25 | router.not_found = -> (env) do 26 | env['params'] = { 'not_found' => true } 27 | HelpCommand.call(env) 28 | end 29 | 30 | router 31 | end 32 | 33 | def self.build 34 | new route 35 | end 36 | 37 | # Returns a Slash::Matcher::Regexp matcher that will also normalize the 38 | # `repository` param to include the full name of the repository, if the user 39 | # specifies the short form. 40 | def self.match_regexp(re) 41 | matcher = Slash.match_regexp(re) 42 | RepositoryMatcher.new(matcher) 43 | end 44 | 45 | def initialize(router) 46 | @router = router 47 | end 48 | 49 | def call(env) 50 | account = env['account'] 51 | user = account.user 52 | 53 | # User needs a Github account, so bubble MissingGitHubAccount if missing. 54 | user.github_account 55 | 56 | scope = { 57 | person: { id: user.id, username: user.username } 58 | } 59 | 60 | Rollbar.scoped(scope) do 61 | begin 62 | router.call(env) 63 | rescue SlashDeploy::RepoUnauthorized => e 64 | Slash.reply UnauthorizedMessage.build \ 65 | repository: e.repository 66 | rescue StandardError => e 67 | Rollbar.error(e) 68 | raise e if Rails.env.test? 69 | Slash.reply ErrorMessage.build 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /app/commands/unlock_all_command.rb: -------------------------------------------------------------------------------- 1 | # UnlockAllCommand handles the `/deploy unlock all` command. 2 | class UnlockAllCommand < BaseCommand 3 | def run 4 | transaction do 5 | locks = slashdeploy.unlock_all(user) 6 | Slash.say UnlockedAllMessage.build \ 7 | locks: locks 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/commands/unlock_command.rb: -------------------------------------------------------------------------------- 1 | # UnlockCommand handles the `/deploy unlock` command. 2 | class UnlockCommand < BaseCommand 3 | def run 4 | transaction do 5 | repo = Repository.with_name(params['repository']) 6 | return Slash.reply(ValidationErrorMessage.build(record: repo)) if repo.invalid? 7 | 8 | env = repo.environment(params['environment']) 9 | return Slash.reply(EnvironmentsMessage.build(repository: repo)) unless env 10 | return Slash.reply(ValidationErrorMessage.build(record: env)) if env.invalid? 11 | 12 | slashdeploy.unlock_environment(user, env) 13 | Slash.say UnlockedMessage.build \ 14 | environment: env 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # The primary controller that all controllers inherit from. 2 | class ApplicationController < ActionController::Base 3 | # Prevent CSRF attacks by raising an exception. 4 | # For APIs, you may want to use :null_session instead. 5 | protect_from_forgery with: :exception 6 | 7 | # By default, all actions should require authentication. Individual actions 8 | # can opt-out using `skip_before_action`. 9 | before_action :authenticate! 10 | 11 | def current_user 12 | warden.user 13 | end 14 | helper_method :current_user 15 | 16 | def signed_in? 17 | warden.authenticated? 18 | end 19 | helper_method :signed_in? 20 | 21 | def authenticate! 22 | redirect_to login_path unless signed_in? 23 | end 24 | 25 | private 26 | 27 | def warden 28 | request.env['warden'] 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /app/controllers/documentation_controller.rb: -------------------------------------------------------------------------------- 1 | class DocumentationController < ApplicationController 2 | skip_before_action :authenticate! 3 | 4 | def index 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/controllers/pages_controller.rb: -------------------------------------------------------------------------------- 1 | # Controller for static pages 2 | class PagesController < ApplicationController 3 | skip_before_action :authenticate! 4 | 5 | def index 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/controllers/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class SessionsController < ApplicationController 2 | skip_before_filter :verify_authenticity_token 3 | 4 | skip_before_action :authenticate! 5 | 6 | def failure 7 | render text: 'Authentication failed' 8 | end 9 | 10 | def destroy 11 | warden.logout 12 | redirect_to root_path 13 | end 14 | 15 | def jwt 16 | user = User.find(auth_hash[:uid]) 17 | warden.set_user user 18 | if user.github_accounts.empty? 19 | redirect_to oauth_path(:github) 20 | else 21 | redirect_to after_sign_in_path 22 | end 23 | end 24 | 25 | def create 26 | if auth_hash[:provider] == 'slack' && auth_hash[:extra][:bot_info].present? 27 | SlackBot.from_auth_hash(auth_hash) 28 | end 29 | 30 | User.transaction do 31 | account = ConnectedAccount.from_auth_hash(auth_hash) 32 | 33 | if signed_in? 34 | if account.user == current_user 35 | # User is signed in so they are trying to link an identity with their 36 | # account. But we found the identity and the user associated with it 37 | # is the current user. So the identity is already associated with 38 | # this user. So let's display an error message. 39 | redirect_to after_sign_in_path, flash: { warning: "You've already linked that account!" } 40 | else 41 | # The identity is not associated with the current_user so lets 42 | # associate the identity. 43 | account.user = current_user 44 | account.save! 45 | redirect_to after_sign_in_path, flash: { success: 'Successfully linked that account!' } 46 | end 47 | else 48 | if account.user.present? 49 | # The identity we found had a user associated with it so let's 50 | # just log them in here. 51 | sign_in_and_redirect account.user 52 | else 53 | # No user associated with the identity so sign them up. 54 | user = User.new 55 | account.user = user 56 | account.save! 57 | sign_in_and_redirect user, flash: { success: 'Thanks for signing up!' } 58 | end 59 | end 60 | end 61 | end 62 | 63 | private 64 | 65 | def auth_hash 66 | request.env['omniauth.auth'] 67 | end 68 | 69 | def after_sign_in_path 70 | request.env['omniauth.origin'] || root_url 71 | end 72 | 73 | def sign_in_and_redirect(user, *args) 74 | warden.set_user user 75 | redirect_to after_sign_in_path, *args 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /app/controllers/slack_controller.rb: -------------------------------------------------------------------------------- 1 | # SlackController handles the oauth callback for Slack. 2 | class SlackController < ApplicationController 3 | skip_before_filter :verify_authenticity_token 4 | 5 | skip_before_action :authenticate! 6 | 7 | def install 8 | redirect_to oauth_path(:slack, scope: 'bot,commands') unless beta? 9 | end 10 | 11 | def early_access 12 | EarlyAccess.create(email: params[:email]) 13 | $statsd.event 'New signup', "New signup from #{params[:email]} @slack-slashdeploy" 14 | end 15 | 16 | def installed 17 | end 18 | 19 | private 20 | 21 | def beta? 22 | Rails.configuration.x.beta 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/controllers/teams_controller.rb: -------------------------------------------------------------------------------- 1 | class TeamsController < ApplicationController 2 | def index 3 | @slack_teams = current_user.slack_teams 4 | # get a list of slack team objects who have slack_accounts with locks. 5 | # @slack_teams_with_locks = current_user.slack_teams.includes( 6 | # slack_accounts: [:locks] 7 | # ) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/github/deployment_status_event.rb: -------------------------------------------------------------------------------- 1 | # Handles the deployment_status event from github. 2 | class DeploymentStatusEvent < GitHubEventHandler 3 | def run 4 | return logger.info('no matching github user') unless user 5 | return logger.info('user does not have slack notifications enabled') unless user.slack_notifications? 6 | 7 | # log the deployment status event. 8 | state = event['deployment_status']['state'] 9 | state_desc = event['deployment_status']['description'] 10 | deployment_id = event['deployment']['id'] 11 | environment_name = event['deployment']['environment'] 12 | github_user_name = event['deployment']['creator']['login'] 13 | repo_name = event['repository']['full_name'] 14 | logger.info("Deployment: id=#{deployment_id}, state=#{state}, desc=#{state_desc}, environment=#{environment_name}, repo=#{repo_name}, gitub_user=#{github_user_name}") 15 | # end of logging the deployment status event. 16 | 17 | slashdeploy.direct_message \ 18 | user.slack_account_for_github_organization(organization), 19 | GitHubDeploymentStatusMessage, 20 | event: event 21 | end 22 | 23 | private 24 | 25 | def organization 26 | event['repository']['owner']['login'] 27 | end 28 | 29 | def user 30 | @user ||= User.find_by_github(event['deployment']['creator']['id']) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/github/github_event_handler.rb: -------------------------------------------------------------------------------- 1 | # GitHubEventHandler is a base GitHub event handler. All GitHub event handler 2 | # should inherit from this. 3 | class GitHubEventHandler 4 | attr_reader :env 5 | attr_reader :slashdeploy 6 | 7 | UnknownRepository = Class.new(SlashDeploy::Error) 8 | 9 | def self.call(env) 10 | new(env, ::SlashDeploy.service, Rails.configuration.x.integration_secret).call 11 | end 12 | 13 | def initialize(env, slashdeploy, secret = nil) 14 | @env = env 15 | @slashdeploy = slashdeploy 16 | @secret = secret 17 | end 18 | 19 | def installation? 20 | event['installation'].present? 21 | end 22 | 23 | def integration_secret? 24 | @secret.present? 25 | end 26 | 27 | def call 28 | logger.with_module('github event') do 29 | logger.with_module(self.class) do 30 | env['rack.input'] = StringIO.new env['rack.input'].read 31 | req = ::Rack::Request.new env 32 | @event = JSON.parse req.body.read 33 | req.body.rewind # rewind body so downstream can re-read. 34 | 35 | # Just a sanity check to make sure all webhooks are from an 36 | # integration. 37 | fail StandardError, 'Not an installation' unless installation? 38 | fail StandardError, 'No integration secret set' unless integration_secret? 39 | 40 | # When the webhook comes from an installation, we'll verify the 41 | # request using a global secret, then create the repository if 42 | # needed. This is done to support installing SlashDeploy 43 | # organization wide. 44 | return [403, {}, ['']] unless Hookshot.verify(req, @secret) 45 | 46 | scope = { 47 | event: @event 48 | } 49 | 50 | if @event['repository'] 51 | repo_name = @event['repository']['full_name'] 52 | logger.info("repository=#{repo_name}") 53 | @repository = Repository.with_name(repo_name) 54 | @repository.update_column(:installation_id, Installation.find(event['installation']['id']).id) 55 | scope[:repository] = @repository.name 56 | end 57 | 58 | Rollbar.scoped(scope) do 59 | run 60 | end 61 | 62 | [200, {}, ['']] 63 | end 64 | end 65 | end 66 | 67 | def run 68 | fail NotImplementedError 69 | end 70 | 71 | private 72 | 73 | def logger 74 | Rails.logger 75 | end 76 | 77 | attr_reader :repository 78 | attr_reader :event 79 | 80 | delegate :transaction, to: :'ActiveRecord::Base' 81 | end 82 | -------------------------------------------------------------------------------- /app/github/installation_event.rb: -------------------------------------------------------------------------------- 1 | # Handles the installation event from github. 2 | class InstallationEvent < GitHubEventHandler 3 | def run 4 | transaction do 5 | case action 6 | when 'created' 7 | Installation.create!(id: installation_id) 8 | when 'deleted' 9 | Installation.destroy(installation_id) 10 | else 11 | fail "Unknown action: #{action}" 12 | end 13 | end 14 | end 15 | 16 | def action 17 | event['action'] 18 | end 19 | 20 | def installation_id 21 | event['installation']['id'] 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/github/push_event.rb: -------------------------------------------------------------------------------- 1 | # Handles the push event from github. 2 | class PushEvent < GitHubEventHandler 3 | def run 4 | logger.info "ref=#{event['ref']} sha=#{sha} sender=#{event['sender']['login']}" 5 | return logger.info 'ignoring deleted branch' if deleted? 6 | return logger.info 'ignoring push from fork' if fork? 7 | 8 | transaction do 9 | if default_branch? 10 | logger.info "syncing #{SlashDeploy::CONFIG_FILE_NAME}" 11 | slashdeploy.update_repository_config(repository) 12 | end 13 | return logger.info 'not configured for automatic deployments' unless environments 14 | return logger.info 'skipping continuous delivery because commit message' if skip? 15 | environments.each do |environment| 16 | auto_deployment = slashdeploy.create_auto_deployment(environment, sha, deployer) 17 | if auto_deployment.valid? 18 | logger.info "auto_deployment=#{auto_deployment.id} ready=#{auto_deployment.ready?} deployer=#{auto_deployment.deployer.identifier}" 19 | else 20 | logger.info "Skipping auto_deployment for #{environment} + #{sha}, it already exists." 21 | end 22 | end 23 | end 24 | end 25 | 26 | private 27 | 28 | def skip? 29 | SlashDeploy::CD_SKIP.match(event['head_commit']['message']) 30 | end 31 | 32 | # Returns true if the ref that was pushed to is the default branch for the 33 | # repository. 34 | def default_branch? 35 | event['ref'] == "refs/heads/#{event['repository']['default_branch']}" 36 | end 37 | 38 | # Returns true if this push event was triggered from a fork. 39 | def fork? 40 | event['repository']['fork'] 41 | end 42 | 43 | # Returns true if the push event was from a deleted ref. 44 | def deleted? 45 | event['deleted'] 46 | end 47 | 48 | # The git commit sha of this push event. 49 | def sha 50 | event['head_commit']['id'] if event['head_commit'] 51 | end 52 | 53 | # Returns the environment that's configured to auto deploy this git ref. 54 | def environments 55 | @environments ||= repository.auto_deploy_environments_for_ref(event['ref']) 56 | end 57 | 58 | # Returns the user that should be attributed with the deployment. This will 59 | # be the user that pushed to GitHub if we know who they are in SlashDeploy. 60 | # If we don't know who they are, the deployment will be attributed to the 61 | # SlashDeploy app. 62 | def deployer 63 | @account ||= GitHubAccount.find_by(id: event['sender']['id']) 64 | @account ? @account.user : nil 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /app/github/status_event.rb: -------------------------------------------------------------------------------- 1 | # Handles the status event from github. 2 | class StatusEvent < GitHubEventHandler 3 | def run 4 | transaction do 5 | status = Status.track(event) 6 | slashdeploy.track_context_state_change status 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # Helpers included everywhere. 2 | module ApplicationHelper 3 | def feedback_email 4 | mail_to(Rails.configuration.x.feedback_email) 5 | end 6 | 7 | def add_to_slack 8 | link_to '/slack/install' do 9 | image_tag \ 10 | 'https://platform.slack-edge.com/img/add_to_slack.png', 11 | alt: 'Add to Slack', 12 | height: '40', 13 | width: '139', 14 | srcset: 'https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x' 15 | end 16 | end 17 | 18 | # Returns a short 7 character representation of a SHA1 hash. 19 | def short_sha(sha) 20 | sha[0...7] 21 | end 22 | 23 | # Returns a slack formatted link, if a url is provided. 24 | def slack_link_to(url, text) 25 | return text unless url.present? 26 | "<#{url}|#{text}>" 27 | end 28 | 29 | # Returns ref if it's a named reference (e.g. a branch/tag/etc), otherwise it 30 | # returns a short representation of the SHA1 hash. 31 | def short_ref_or_sha(ref, sha) 32 | return ref if ref != sha 33 | short_sha sha 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/helpers/repositories_helper.rb: -------------------------------------------------------------------------------- 1 | module RepositoriesHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/app/mailers/.keep -------------------------------------------------------------------------------- /app/messages/action_declined_message.rb: -------------------------------------------------------------------------------- 1 | class ActionDeclinedMessage < SlackMessage 2 | values do 3 | attribute :declined_action_text, String 4 | end 5 | 6 | def to_message 7 | Slack::Message.new text: text 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/messages/already_locked_message.rb: -------------------------------------------------------------------------------- 1 | class AlreadyLockedMessage < SlackMessage 2 | values do 3 | attribute :environment, Environment 4 | end 5 | 6 | def to_message 7 | Slack::Message.new text: text 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/messages/auto_deployment_configured_message.rb: -------------------------------------------------------------------------------- 1 | class AutoDeploymentConfiguredMessage < SlackMessage 2 | values do 3 | attribute :environment, Environment 4 | attribute :message_action, MessageAction 5 | end 6 | 7 | def to_message 8 | Slack::Message.new text: text, attachments: [ 9 | Slack::Attachment.new( 10 | title: 'Deploy anyway?', 11 | callback_id: message_action.callback_id, 12 | color: '#3AA3E3', 13 | actions: SlackMessage.confirmation_actions 14 | ) 15 | ] 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/messages/auto_deployment_created_message.rb: -------------------------------------------------------------------------------- 1 | class AutoDeploymentCreatedMessage < SlackMessage 2 | values do 3 | attribute :account, SlackAccount 4 | attribute :auto_deployment, AutoDeployment 5 | end 6 | 7 | def to_message 8 | Slack::Message.new text: text 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/messages/auto_deployment_failed_message.rb: -------------------------------------------------------------------------------- 1 | class AutoDeploymentFailedMessage < SlackMessage 2 | values do 3 | attribute :account, SlackAccount 4 | attribute :auto_deployment, AutoDeployment 5 | end 6 | 7 | def to_message 8 | attachments = auto_deployment.failing_statuses.map do |status| 9 | Slack::Attachment.new( 10 | mrkdwn_in: ['text'], 11 | title: status.context, 12 | title_link: status.target_url, 13 | text: status.description, 14 | color: '#F00' 15 | ) 16 | end 17 | # Let them know that they can fix the build and we'll still deploy. 18 | attachments << Slack::Attachment.new( 19 | mrkdwn_in: ['pretext'], 20 | pretext: render(:rebuild) 21 | ) 22 | Slack::Message.new text: text, attachments: attachments 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/messages/auto_deployment_locked_message.rb: -------------------------------------------------------------------------------- 1 | class AutoDeploymentLockedMessage < SlackMessage 2 | values do 3 | attribute :account, SlackAccount 4 | attribute :auto_deployment, Environment 5 | attribute :lock, Lock 6 | end 7 | 8 | def to_message 9 | Slack::Message.new text: text(locker: locker, environment: auto_deployment.environment) 10 | end 11 | 12 | private 13 | 14 | def locker 15 | slack_account lock.user 16 | end 17 | 18 | def slack_team 19 | account.slack_team 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/messages/auto_deployment_stuck_pending_message.rb: -------------------------------------------------------------------------------- 1 | class AutoDeploymentStuckPendingMessage < SlackMessage 2 | values do 3 | attribute :account, SlackAccount 4 | attribute :auto_deployment, AutoDeployment 5 | end 6 | 7 | def to_message 8 | Slack::Message.new text: text 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/messages/bad_ref_message.rb: -------------------------------------------------------------------------------- 1 | class BadRefMessage < SlackMessage 2 | values do 3 | attribute :repository, Repository 4 | attribute :ref, String 5 | end 6 | 7 | def to_message 8 | Slack::Message.new text: text 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/messages/check_message.rb: -------------------------------------------------------------------------------- 1 | class CheckMessage < SlackMessage 2 | values do 3 | attribute :environment, Environment 4 | end 5 | 6 | def to_message 7 | lock_status = Slack::Attachment.new( 8 | mrkdwn_in: ['text'], 9 | title: 'Lock Status', 10 | text: lock_message, 11 | color: environment.locked? ? '#F00' : '#3AA3E3' 12 | ) 13 | Slack::Message.new text: "#{environment.repository.name} (*#{environment.name}*)", attachments: [lock_status] 14 | end 15 | 16 | private 17 | 18 | def lock_message 19 | text(locker: locker, lock: lock) 20 | end 21 | 22 | def locker 23 | return unless lock 24 | slack_account lock.user 25 | end 26 | 27 | def lock 28 | environment.active_lock 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/messages/deployment_created_message.rb: -------------------------------------------------------------------------------- 1 | class DeploymentCreatedMessage < SlackMessage 2 | values do 3 | attribute :environment, Environment 4 | attribute :deployment, Deployment 5 | attribute :last_deployment, Deployment 6 | 7 | # Present if we should ask the user if they want to lock the environment. 8 | attribute :lock_action, MessageAction 9 | 10 | # Present if we should ask the user if they want to unlock the environment. 11 | attribute :unlock_action, MessageAction 12 | end 13 | 14 | def to_message 15 | attachments = [] 16 | 17 | if lock_action 18 | attachments << Slack::Attachment.new( 19 | mrkdwn_in: ['text'], 20 | title: "Lock #{environment}?", 21 | text: "The default ref for *#{environment}* is `#{environment.default_ref}`, but you deployed `#{deployment.ref}`.", 22 | callback_id: lock_action.callback_id, 23 | color: '#3AA3E3', 24 | actions: SlackMessage.confirmation_actions 25 | ) 26 | end 27 | 28 | if unlock_action 29 | attachments << Slack::Attachment.new( 30 | mrkdwn_in: ['text'], 31 | title: "Unlock #{environment}?", 32 | text: "You just deployed the default ref for *#{environment}*. Do you want to unlock it?", 33 | callback_id: unlock_action.callback_id, 34 | color: '#3AA3E3', 35 | actions: SlackMessage.confirmation_actions 36 | ) 37 | end 38 | 39 | Slack::Message.new text: text(diff_url: diff_url), attachments: attachments 40 | end 41 | 42 | private 43 | 44 | # Returns a url with a diff between the old and new deployment. 45 | def diff_url 46 | return unless last_deployment 47 | return if deployment.sha == last_deployment.sha 48 | "https://github.com/#{deployment.repository}/compare/#{last_deployment.sha[0..6]}...#{deployment.sha[0..6]}" 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /app/messages/environment_locked_message.rb: -------------------------------------------------------------------------------- 1 | class EnvironmentLockedMessage < SlackMessage 2 | values do 3 | attribute :environment, Environment 4 | attribute :lock, Lock 5 | attribute :message_action, MessageAction 6 | end 7 | 8 | def to_message 9 | Slack::Message.new text: text(locker: locker), attachments: [ 10 | Slack::Attachment.new( 11 | title: 'Steal the lock?', 12 | callback_id: message_action.callback_id, 13 | color: '#3AA3E3', 14 | actions: SlackMessage.confirmation_actions 15 | ) 16 | ] 17 | end 18 | 19 | private 20 | 21 | def locker 22 | slack_account lock.user 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/messages/environments_message.rb: -------------------------------------------------------------------------------- 1 | class EnvironmentsMessage < SlackMessage 2 | values do 3 | attribute :repository, Repository 4 | end 5 | 6 | def to_message 7 | Slack::Message.new text: text 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/messages/error_message.rb: -------------------------------------------------------------------------------- 1 | class ErrorMessage < SlackMessage 2 | def to_message 3 | Slack::Message.new text: text 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/messages/github_authenticate_message.rb: -------------------------------------------------------------------------------- 1 | class GitHubAuthenticateMessage < SlackMessage 2 | values do 3 | attribute :url, String 4 | end 5 | 6 | def to_message 7 | Slack::Message.new text: "Please reconnect your GitHub account by visiting #{url}" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/messages/github_deployment_status_message.rb: -------------------------------------------------------------------------------- 1 | class GitHubDeploymentStatusMessage < SlackMessage 2 | STATUSES = { 3 | 'pending' => ['#ff0', 'started'], 4 | 'success' => ['#0f0', 'succeeded'], 5 | 'failure' => ['#f00', 'failed'], 6 | 'error' => ['#f00', 'errored'], 7 | 'inactive' => ['#0f0', 'superseded'], 8 | }.freeze 9 | 10 | values do 11 | attribute :account, SlackAccount 12 | attribute :event, Hash 13 | end 14 | 15 | def to_message 16 | state = event['deployment_status']['state'] 17 | description = event['deployment_status']['description'] 18 | 19 | # hack: for some reason a GitHub Deployment Status in the inactive state 20 | # does not trigger a webhook. To work around this, we piggy back on the 21 | # success state and add the real state as the description. 22 | if state == "success" && description == "inactive" 23 | state = "inactive" 24 | description = "" 25 | end 26 | 27 | color, verb = STATUSES[state] 28 | Slack::Message.new attachments: [ 29 | Slack::Attachment.new( 30 | color: color, 31 | mrkdwn_in: ['text'], 32 | text: text(verb: verb), 33 | fallback: "Deployment #{verb}" 34 | ) 35 | ] 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /app/messages/github_no_deployment_status_message.rb: -------------------------------------------------------------------------------- 1 | class GithubNoDeploymentStatusMessage < SlackMessage 2 | values do 3 | attribute :account, SlackAccount 4 | attribute :github_deployment, Deployment 5 | end 6 | 7 | def to_message 8 | Slack::Message.new text: text 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/messages/help_message.rb: -------------------------------------------------------------------------------- 1 | class HelpMessage < SlackMessage 2 | USAGE = <" 28 | ) 29 | ] 30 | end 31 | end -------------------------------------------------------------------------------- /app/messages/lock_nag_message.rb: -------------------------------------------------------------------------------- 1 | class LockNagMessage < SlackMessage 2 | values do 3 | attribute :lock, Lock 4 | attribute :account, SlackAccount 5 | attribute :message_action, MessageAction 6 | end 7 | 8 | def to_message 9 | Slack::Message.new text: text(locker: locker), attachments: [ 10 | Slack::Attachment.new( 11 | title: 'Unlock?', 12 | callback_id: message_action.callback_id, 13 | color: '#3AA3E3', 14 | actions: SlackMessage.confirmation_actions 15 | ) 16 | ] 17 | end 18 | 19 | private 20 | 21 | def locker 22 | lock.slack_account 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/messages/lock_stolen_message.rb: -------------------------------------------------------------------------------- 1 | class LockStolenMessage < SlackMessage 2 | values do 3 | attribute :environment, Environment 4 | attribute :thief, SlackAccount 5 | end 6 | 7 | def to_message 8 | Slack::Message.new text: text(thief: thief) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/messages/locked_message.rb: -------------------------------------------------------------------------------- 1 | class LockedMessage < SlackMessage 2 | values do 3 | attribute :environment, Environment 4 | attribute :stolen_lock, Lock 5 | end 6 | 7 | def to_message 8 | Slack::Message.new text: text(stealer: stealer) 9 | end 10 | 11 | private 12 | 13 | def stealer 14 | slack_account(stolen_lock.user) if stolen_lock 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/messages/ping_message.rb: -------------------------------------------------------------------------------- 1 | class PingMessage < SlackMessage 2 | def to_message 3 | Slack::Message.new text: 'Ping' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/messages/red_commit_message.rb: -------------------------------------------------------------------------------- 1 | class RedCommitMessage < SlackMessage 2 | values do 3 | attribute :contexts, Array[CommitStatusContext] 4 | attribute :message_action, MessageAction 5 | end 6 | 7 | def to_message 8 | Slack::Message.new text: text, attachments: [ 9 | Slack::Attachment.new( 10 | title: 'Ignore status checks and deploy anyway?', 11 | callback_id: message_action.callback_id, 12 | color: '#3AA3E3', 13 | actions: SlackMessage.confirmation_actions 14 | ) 15 | ] 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/messages/slack_message.rb: -------------------------------------------------------------------------------- 1 | class SlackMessage 2 | include Virtus.value_object 3 | include ActionView::Helpers 4 | 5 | values do 6 | attribute :slack_team, SlackTeam 7 | end 8 | 9 | def self.build(*args) 10 | new(*args).to_message 11 | end 12 | 13 | # Can be overriden by subclasses. 14 | def to_message 15 | fail NotImplementedError 16 | end 17 | 18 | protected 19 | 20 | def text(extra_assigns = {}) 21 | render(nil, extra_assigns) 22 | end 23 | 24 | def render(file, extra_assigns = {}) 25 | prefix = self.class.to_s.gsub(/Message$/, '').underscore 26 | if file 27 | search = ["app/views/messages/#{prefix}"] 28 | else 29 | file = prefix 30 | search = ['app/views/messages'] 31 | end 32 | view = ActionView::Base.new(search, attributes.merge(extra_assigns)) 33 | view.class_eval do 34 | include ApplicationHelper 35 | end 36 | view.render(file: file).strip 37 | end 38 | 39 | def self.confirmation_actions 40 | [ 41 | Slack::Attachment::Action.new( 42 | name: 'yes', 43 | text: 'Yes', 44 | type: 'button', 45 | style: 'primary', 46 | value: 'yes'), 47 | Slack::Attachment::Action.new( 48 | name: 'no', 49 | text: 'No', 50 | type: 'button', 51 | value: 'no') 52 | ] 53 | end 54 | 55 | def slack_account(user) 56 | user.slack_accounts.find { |a| a.team_id == slack_team.id } 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /app/messages/unauthorized_message.rb: -------------------------------------------------------------------------------- 1 | class UnauthorizedMessage < SlackMessage 2 | values do 3 | attribute :repository, Repository 4 | end 5 | 6 | def to_message 7 | Slack::Message.new text: text 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/messages/unlocked_all_message.rb: -------------------------------------------------------------------------------- 1 | class UnlockedAllMessage < SlackMessage 2 | 3 | # list of Lock objects that were unlocked. 4 | values do 5 | attribute :locks, Array[Lock] 6 | end 7 | 8 | def to_message 9 | Slack::Message.new text: text 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/messages/unlocked_message.rb: -------------------------------------------------------------------------------- 1 | class UnlockedMessage < SlackMessage 2 | # the models.environment::Environment to unlock 3 | values do 4 | attribute :environment, Environment 5 | end 6 | 7 | def to_message 8 | Slack::Message.new text: text 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/messages/validation_error_message.rb: -------------------------------------------------------------------------------- 1 | class ValidationErrorMessage < SlackMessage 2 | values do 3 | attribute :record, Object 4 | end 5 | 6 | def to_message 7 | fields = record.errors.map do |attribute, error| 8 | Slack::Attachment::Field.new title: "#{record.class.name.downcase} #{attribute}", value: error 9 | end 10 | Slack::Message.new text: text, attachments: [ 11 | Slack::Attachment.new(fields: fields, color: '#f00') 12 | ] 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/app/models/.keep -------------------------------------------------------------------------------- /app/models/commit_status_context.rb: -------------------------------------------------------------------------------- 1 | # CommitStatusContext represents the red/green state of a git commit per context. 2 | # The github "status" API allows third party systems like CircleCI and Conveyor to 3 | # emit status payloads to Github to be centrally managed. 4 | class CommitStatusContext 5 | include Virtus.value_object 6 | 7 | # https://developer.github.com/v3/repos/statuses/#create-a-status 8 | SUCCESS = 'success'.freeze 9 | FAILURE = 'failure'.freeze 10 | PENDING = 'pending'.freeze 11 | ERROR = 'error'.freeze 12 | 13 | values do 14 | attribute :context, String 15 | attribute :state, String 16 | end 17 | 18 | def success? 19 | state == SUCCESS 20 | end 21 | 22 | def failure? 23 | state == FAILURE || state == ERROR 24 | end 25 | 26 | def pending? 27 | state == PENDING 28 | end 29 | 30 | def to_s 31 | "#{context} (#{state})" 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/connected_account.rb: -------------------------------------------------------------------------------- 1 | class ConnectedAccount 2 | def self.from_auth_hash(auth_hash) 3 | klass = case auth_hash[:provider] 4 | when 'github' 5 | GitHubAccount 6 | when 'slack' 7 | SlackAccount 8 | end 9 | fail "Cannot create connected account for #{auth_hash[:provider]}" unless klass 10 | fail 'Auth has does not specify a UID' unless auth_hash[:uid] 11 | account = klass.find_by(id: auth_hash[:uid]) 12 | if account 13 | # Update the account to ensure that we have the most recent username, 14 | # access token, etc. 15 | account.update_from_auth_hash(auth_hash) 16 | else 17 | klass.create_from_auth_hash(auth_hash) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/models/deployment.rb: -------------------------------------------------------------------------------- 1 | # Instances of this class represent state in Github's API. 2 | # Deployment represents a created DeploymentRequest. 3 | class Deployment 4 | include Virtus.value_object 5 | 6 | values do 7 | # The external id of the deployment. 8 | attribute :id, Integer 9 | # The external url of the deployment, needed to lookup statuses. 10 | attribute :url, String 11 | # The ref that was requested to be deployed. 12 | attribute :ref, String 13 | # The commit sha that the ref was resolved to (what actually got deployed). 14 | attribute :sha, String 15 | # The environment that was deployed to. 16 | attribute :environment, String 17 | # The name of the repository the deployment was for. 18 | attribute :repository, String 19 | end 20 | 21 | # return the Github Org for deployment. 22 | def organization 23 | matches = SlashDeploy::GITHUB_REPO_REGEX.match(repository) 24 | matches[1] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/models/deployment_request.rb: -------------------------------------------------------------------------------- 1 | # A DeploymentRequest represents the options provided when requesting a new deployment. 2 | class DeploymentRequest 3 | include Virtus.value_object 4 | 5 | values do 6 | # The repository to deploy. 7 | attribute :repository, String 8 | # The git branch, tag or commit to deploy. 9 | attribute :ref, String 10 | # The environment to deploy to. 11 | attribute :environment, String 12 | # Whether to "force" the deployment or not (i.e. Ignore commit status contexts). 13 | attribute :force, Boolean 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/models/deployment_response.rb: -------------------------------------------------------------------------------- 1 | # DeploymentResponse is returned when creating a new DeploymentRequest. 2 | class DeploymentResponse 3 | include Virtus.value_object 4 | 5 | values do 6 | # The created deployment. 7 | attribute :deployment, Deployment 8 | # The last deployment to the given environment. 9 | attribute :last_deployment, Deployment 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/models/deployment_status.rb: -------------------------------------------------------------------------------- 1 | # Instances of this class represent state in Github's API. 2 | # Querying a Github Deployment's list of statuses, returns a JSON payload. 3 | # This class represents a single status item and holds the fields we need. 4 | # References: 5 | # * https://developer.github.com/v3/repos/deployments/#list-deployment-statuses 6 | # * https://octokit.github.io/octokit.rb/Octokit/Client/Deployments.html#deployment_statuses-instance_method 7 | class DeploymentStatus 8 | include Virtus.value_object 9 | 10 | values do 11 | # The external id of the deployment_status item. 12 | attribute :id, Integer 13 | # The external url of the deployment_status item. 14 | attribute :url, String 15 | # The state of the deployment_status item. 16 | attribute :state, String 17 | # The description of the deployment_status item. 18 | attribute :description, String 19 | # The 3rd party url of the deployment_status item. 20 | attribute :target_url, String 21 | # The deployment url of the deployment_status item. 22 | attribute :deployment_url, String 23 | # The repository url of the deployment_status item. 24 | attribute :repository_url, String 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/models/early_access.rb: -------------------------------------------------------------------------------- 1 | class EarlyAccess < ActiveRecord::Base 2 | end 3 | -------------------------------------------------------------------------------- /app/models/github_account.rb: -------------------------------------------------------------------------------- 1 | # GitHubAccount represents a connected GitHub account. 2 | class GitHubAccount < ActiveRecord::Base 3 | belongs_to :user 4 | 5 | def self.attributes_from_auth_hash(auth_hash) 6 | { 7 | id: auth_hash[:uid], 8 | login: auth_hash[:info][:nickname], 9 | token: auth_hash[:credentials][:token], 10 | } 11 | end 12 | 13 | def self.create_from_auth_hash(auth_hash) 14 | create! attributes_from_auth_hash(auth_hash) 15 | end 16 | 17 | def update_from_auth_hash(auth_hash) 18 | update_attributes! self.class.attributes_from_auth_hash(auth_hash).except(:id) 19 | self 20 | end 21 | 22 | def username 23 | login 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/models/installation.rb: -------------------------------------------------------------------------------- 1 | # Represents an "installation" of a GitHub app on an org/repository. This 2 | # implements much of the same interface as User, since they sometimes are used 3 | # interchangeably (e.g. when attributing a deployment to a GitHub user that 4 | # SlashDeploy doesn't know about. 5 | # 6 | # See https://developer.github.com/apps/ 7 | class Installation < ActiveRecord::Base 8 | has_many :repositories 9 | 10 | def identifier 11 | 'SlashDeploy' 12 | end 13 | 14 | def app 15 | SlashDeploy.github_app 16 | end 17 | 18 | def github_token 19 | app.installation_token(id).token 20 | end 21 | 22 | def octokit_client 23 | Octokit::Client.new(access_token: github_token) 24 | end 25 | 26 | def slack_account_for_github_organization(organization) 27 | # In theory, we could use the Slack Bot to send a message to a channel, but 28 | # for now, we don't do anything. 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/models/last_deployment.rb: -------------------------------------------------------------------------------- 1 | # Represents the latest deployment for a repository and an environment 2 | class LastDeployment 3 | include Virtus.value_object 4 | values do 5 | # The last deployment. 6 | attribute :last_deployment, Deployment 7 | # The last deployment status 8 | attribute :last_deployment_status, DeploymentStatus 9 | end 10 | end -------------------------------------------------------------------------------- /app/models/lock.rb: -------------------------------------------------------------------------------- 1 | # Lock represents a lock obtained on an environment. 2 | class Lock < ActiveRecord::Base 3 | belongs_to :environment 4 | belongs_to :user 5 | 6 | scope :active, -> { where(active: true) } 7 | 8 | def inactive? 9 | !active 10 | end 11 | 12 | def unlock! 13 | update_attributes!(active: false) 14 | end 15 | 16 | def repository 17 | environment.repository 18 | end 19 | 20 | def slack_account 21 | environment.slack_account_for(user) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/models/lock_response.rb: -------------------------------------------------------------------------------- 1 | # LockResponse is returned from a request to lock an environment. 2 | class LockResponse 3 | include Virtus.value_object 4 | 5 | values do 6 | # The new lock 7 | attribute :lock, Lock 8 | 9 | # The previous lock, or nil if there was none. 10 | attribute :stolen, Lock 11 | end 12 | 13 | def stolen? 14 | previous_lock.present? 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/models/message_action.rb: -------------------------------------------------------------------------------- 1 | # Represents the data associated with the actions of a Slack attachment 2 | class MessageAction < ActiveRecord::Base 3 | end 4 | -------------------------------------------------------------------------------- /app/models/slack_account.rb: -------------------------------------------------------------------------------- 1 | # SlackAccount represents a connected Slack account. 2 | class SlackAccount < ActiveRecord::Base 3 | belongs_to :user 4 | belongs_to :slack_team 5 | 6 | def github_organization 7 | slack_team.github_organization 8 | end 9 | 10 | def self.attributes_from_auth_hash(auth_hash) 11 | { id: auth_hash[:uid], 12 | user_name: auth_hash[:info][:nickname] } 13 | end 14 | 15 | def self.create_from_auth_hash(auth_hash) 16 | # Extract the domain from the url 17 | domain = URI.parse(auth_hash[:extra][:raw_info][:url]).host.gsub(/\.slack.com/, "") 18 | 19 | # TODO: This should just be an upsert 20 | team = SlackTeam.find_or_initialize_by(id: auth_hash[:info][:team_id]) do |t| 21 | t.domain = domain 22 | end 23 | 24 | create! attributes_from_auth_hash(auth_hash).merge(slack_team: team) 25 | end 26 | 27 | def update_from_auth_hash(auth_hash) 28 | update_attributes! self.class.attributes_from_auth_hash(auth_hash).except(:id) 29 | self 30 | end 31 | 32 | def self.find_or_create_from_command_payload(payload) 33 | find_by_id(payload.user_id) || create_from_command_payload(payload) 34 | end 35 | 36 | def self.create_from_command_payload(payload) 37 | team = SlackTeam.find_or_initialize_by(id: payload.team_id) do |t| 38 | t.domain = payload.team_domain 39 | end 40 | 41 | create! \ 42 | id: payload.user_id, 43 | user_name: payload.user_name, 44 | slack_team: team 45 | end 46 | 47 | def username 48 | user_name 49 | end 50 | 51 | def team_id 52 | slack_team.id 53 | end 54 | 55 | def team_domain 56 | slack_team.domain 57 | end 58 | 59 | def bot_access_token 60 | slack_team.bot_access_token 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /app/models/slack_bot.rb: -------------------------------------------------------------------------------- 1 | class SlackBot < ActiveRecord::Base 2 | belongs_to :slack_team 3 | 4 | def self.from_auth_hash(auth_hash) 5 | bot_info = auth_hash.fetch(:extra).fetch(:bot_info) 6 | bot = find_by(id: bot_info.fetch(:bot_access_token)) 7 | if bot 8 | bot.update_attributes(access_token: bot_info.fetch(:bot_access_token)) 9 | else 10 | create(id: bot_info.fetch(:bot_access_token)) do |bot| 11 | bot.access_token = bot_info.fetch(:bot_access_token) 12 | bot.slack_team_id = auth_hash.fetch(:info).fetch(:team_id) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/models/slack_team.rb: -------------------------------------------------------------------------------- 1 | class SlackTeam < ActiveRecord::Base 2 | has_many :slack_accounts 3 | has_one :slack_bot 4 | 5 | def bot 6 | slack_bot || fail("Team #{id} does not have a slack bot") 7 | end 8 | 9 | def bot_access_token 10 | bot.access_token 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/models/status.rb: -------------------------------------------------------------------------------- 1 | # Status represents the red/green state of a git commit per context. 2 | class Status < ActiveRecord::Base 3 | belongs_to :auto_deployment, foreign_key: :sha, primary_key: :sha 4 | 5 | # Scopes statuses to only those that have succeeded. 6 | scope :success, -> { where(state: CommitStatusContext::SUCCESS) } 7 | 8 | # Pruneable returns all statuses that aren't part of an active auto deployment. 9 | scope :pruneable, -> { joins(:auto_deployment).merge(AutoDeployment.inactive) } 10 | 11 | # Tracks the new state of a context on a commit. 12 | # 13 | # event - a GitHub `status` event payload. 14 | # 15 | # Returns the Status object. 16 | def self.track(event) 17 | create! \ 18 | sha: event['sha'], 19 | context: event['context'], 20 | state: event['state'], 21 | description: event['description'], 22 | target_url: event['target_url'] 23 | end 24 | 25 | # Returns the most recently tracked status for the given context. If no 26 | # status has been tracked for the context yet, it will return a null status 27 | # in a `pending` state. 28 | def self.latest(context) 29 | order('id desc').find_by(context: context) || new(context: context, state: CommitStatusContext::PENDING) 30 | end 31 | 32 | # Returns a CommitStatusContext object for this Status. 33 | def commit_status_context 34 | CommitStatusContext.new(state: state, context: context) 35 | end 36 | 37 | delegate :pending?, :success?, :failure?, to: :commit_status_context 38 | end 39 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | # User represents a user of SlashDeploy. 2 | class User < ActiveRecord::Base 3 | has_many :github_accounts 4 | has_many :slack_accounts 5 | has_many :slack_teams, through: :slack_accounts 6 | has_many :auto_deployments 7 | has_many :locks 8 | 9 | # Raised if the user doesn't have a github account. 10 | MissingGitHubAccount = Class.new(StandardError) 11 | 12 | def enable_slack_notifications! 13 | update_attributes! slack_notifications: true 14 | end 15 | 16 | def identifier 17 | "#{id}:#{username}" 18 | end 19 | 20 | # Unlock all active locks. 21 | def unlock_all! 22 | locks.active.each(&:unlock!) 23 | end 24 | 25 | # username determine by the following priority: 26 | # 1. GithubAccount#username, 2. SlackAccount#username, 3. User#id 27 | def username 28 | account = github_accounts.first || slack_accounts.first 29 | account ? account.username : id 30 | end 31 | 32 | # class method to lookup a User object by Slack id. 33 | def self.find_by_slack(id) 34 | account = SlackAccount.where(id: id).first 35 | return unless account 36 | account.user 37 | end 38 | 39 | # class method to lookup a User object by Github id. 40 | def self.find_by_github(id) 41 | account = GitHubAccount.where(id: id).first 42 | return unless account 43 | account.user 44 | end 45 | 46 | def slack_account?(slack_account) 47 | slack_accounts.find do |account| 48 | account.id == slack_account.id 49 | end 50 | end 51 | 52 | def github_account?(github_account) 53 | github_accounts.find do |account| 54 | account.id == github_account.id 55 | end 56 | end 57 | 58 | def github_account 59 | github_accounts.first || fail(MissingGitHubAccount) 60 | end 61 | 62 | def github_token 63 | account = github_account 64 | return unless account 65 | account.token 66 | end 67 | 68 | def github_login 69 | account = github_account 70 | return unless account 71 | account.login 72 | end 73 | 74 | def octokit_client 75 | @client ||= Octokit::Client.new(access_token: github_token) 76 | end 77 | 78 | # Returns the SlackAccount that should be used when sending direct messages 79 | # related to the GitHub organization. 80 | # 81 | # Returns nil if no matching account is found. 82 | def slack_account_for_github_organization(organization) 83 | slack_accounts.find { |account| account.github_organization == organization } 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /app/validators/repository_validator.rb: -------------------------------------------------------------------------------- 1 | # Validates the github repo format (e.g. 'acme-inc/api') 2 | class RepositoryValidator < ActiveModel::EachValidator 3 | def self.check(repository) 4 | repository =~ /^#{SlashDeploy::GITHUB_REPO_REGEX}$/ 5 | end 6 | 7 | def validate_each(record, attribute, value) 8 | return if self.class.check(value) 9 | record.errors.add(attribute, :invalid_repository) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/views/messages/_environment_locked.text.erb: -------------------------------------------------------------------------------- 1 | *<%= @environment %>* was locked by <@<%= @locker.id %>> <%= time_ago_in_words(@lock.created_at) %> ago.<% if @lock.message %> 2 | > <%= @lock.message %><% end %> 3 | -------------------------------------------------------------------------------- /app/views/messages/action_declined.text.erb: -------------------------------------------------------------------------------- 1 | Did not <%= @declined_action_text %>. 2 | -------------------------------------------------------------------------------- /app/views/messages/already_locked.text.erb: -------------------------------------------------------------------------------- 1 | *<%= @environment %>* is already locked 2 | -------------------------------------------------------------------------------- /app/views/messages/auto_deployment_configured.text.erb: -------------------------------------------------------------------------------- 1 | <%= @environment.repository %> is configured to automatically deploy `<%= @environment.auto_deploy_ref %>` to *<%= @environment %>*. 2 | -------------------------------------------------------------------------------- /app/views/messages/auto_deployment_created.text.erb: -------------------------------------------------------------------------------- 1 | :wave: <@<%= @account.id %>>. I'll start a deployment of <%= @auto_deployment.environment.repository %>@<%= short_sha @auto_deployment.sha %> to *<%= @auto_deployment.environment %>* for you once <%= @auto_deployment.environment.required_contexts.map { |c| "*#{c}*" }.to_sentence %> are passing. 2 | -------------------------------------------------------------------------------- /app/views/messages/auto_deployment_failed.text.erb: -------------------------------------------------------------------------------- 1 | :wave: <@<%= @account.id %>>. I was going to deploy <%= @auto_deployment.environment.repository %>@<%= @auto_deployment.sha[0...7] %> to *<%= @auto_deployment.environment %>* for you, but some required commit status contexts failed. 2 | -------------------------------------------------------------------------------- /app/views/messages/auto_deployment_failed/rebuild.text.erb: -------------------------------------------------------------------------------- 1 | _I'll try deploying again when you fix the issues above._ 2 | -------------------------------------------------------------------------------- /app/views/messages/auto_deployment_locked.text.erb: -------------------------------------------------------------------------------- 1 | :wave: <@<%= @account.id %>>. I was going to deploy <%= @auto_deployment.environment.repository %>@<%= @auto_deployment.sha[0...7] %> to *<%= @auto_deployment.environment %>* for you, but it's currently locked. 2 | <%= render "environment_locked" %> 3 | -------------------------------------------------------------------------------- /app/views/messages/auto_deployment_stuck_pending.text.erb: -------------------------------------------------------------------------------- 1 | :sadparrot: <@<%= @account.id %>>, There was an issue with <%= @auto_deployment.environment.repository %>@<%= @auto_deployment.sha[0...7] %> to *<%= @auto_deployment.environment %>*. Some of the required commit status contexts appear hung: <%= @auto_deployment.pending_statuses.map { |s| "*#{s.context}*" }.to_sentence %>. For more details, please read: https://slashdeploy.io/docs#error-2 2 | -------------------------------------------------------------------------------- /app/views/messages/bad_ref.text.erb: -------------------------------------------------------------------------------- 1 | The ref `<%= @ref %>` was not found in <%= @repository %> 2 | -------------------------------------------------------------------------------- /app/views/messages/check.text.erb: -------------------------------------------------------------------------------- 1 | <% if @lock %> 2 | <%= render "environment_locked" %> 3 | <% else %> 4 | *<%= @environment %>* isn't locked. 5 | <% end %> 6 | -------------------------------------------------------------------------------- /app/views/messages/deployment_created.text.erb: -------------------------------------------------------------------------------- 1 | Created deployment request for |<%= @deployment.repository %>>@/commits/<%= @deployment.sha %>|<%= @deployment.ref %>> to *<%= @environment %>* (<% if @diff_url %><<%= @diff_url %>|diff><% else %>no change<% end %>) 2 | -------------------------------------------------------------------------------- /app/views/messages/environment_locked.text.erb: -------------------------------------------------------------------------------- 1 | <%= render "environment_locked" %> 2 | -------------------------------------------------------------------------------- /app/views/messages/environments.text.erb: -------------------------------------------------------------------------------- 1 | <% if @repository.known_environments.empty? %> 2 | I don't know about any environments for <%= @repository %>. For details about configuring environments, see . 3 | <% else %> 4 | I know about these environments for <%= @repository %>: 5 | <% @repository.known_environments.each do |environment| %> 6 | * <%= environment.name %><% if environment.is_default? %> (default)<% end %> 7 | <% end %> 8 | <% if @repository.default_environment_name == nil %>No default environment set, for details see .<% end %> 9 | <% end %> 10 | -------------------------------------------------------------------------------- /app/views/messages/error.text.erb: -------------------------------------------------------------------------------- 1 | Oops! We had a problem running your command, but we've been notified 2 | -------------------------------------------------------------------------------- /app/views/messages/github_deployment_status.text.erb: -------------------------------------------------------------------------------- 1 | Deployment <%= slack_link_to @event['deployment_status']['target_url'], "##{@event['deployment']['id']}" %> of <%= @event['repository']['full_name'] %>@/commits/<%= @event['deployment']['sha'] %>|<%= short_ref_or_sha @event['deployment']['ref'], @event['deployment']['sha'] %>> to *<%= @event['deployment']['environment'] %>* <%= @verb %><% if @event['deployment_status']['description'].present? %> (<%= @event['deployment_status']['description'] %>)<% end %> 2 | -------------------------------------------------------------------------------- /app/views/messages/github_no_deployment_status.text.erb: -------------------------------------------------------------------------------- 1 | :sadparrot: <@<%= @account.id %>>, The Github Deployment <<%= @github_deployment.id %>> of <%= @github_deployment.repository %>@<<%= @github_deployment.sha %>|<%= @github_deployment.ref %>> to *<%= @github_deployment.environment %>* did _not_ start. For more details, please read: https://slashdeploy.io/docs#error-1 2 | -------------------------------------------------------------------------------- /app/views/messages/help.text.erb: -------------------------------------------------------------------------------- 1 | <% if @not_found %>I don't know that command. Here's what I do know: 2 | <% end %><%= @usage %> 3 | -------------------------------------------------------------------------------- /app/views/messages/latest.text.erb: -------------------------------------------------------------------------------- 1 | Latest deployment for *<%= @last_deployment.repository %>* to environment *<%= @last_deployment.environment%>* 2 | -------------------------------------------------------------------------------- /app/views/messages/lock_nag.text.erb: -------------------------------------------------------------------------------- 1 | :alarm_clock: :lock: Hey <@<%= @account.id %>>, Did you forget to unlock <%= @lock.environment.repository %>@<%= @lock.environment %>? 2 | I'm not able to perform any AutoDeployments configured in `.slashdeploy.yml` until the lock is released. 3 | -------------------------------------------------------------------------------- /app/views/messages/lock_stolen.text.erb: -------------------------------------------------------------------------------- 1 | Your lock for *<%= @environment %>* on <%= @environment.repository %> was stolen by <@<%= @thief.id %>> 2 | -------------------------------------------------------------------------------- /app/views/messages/locked.text.erb: -------------------------------------------------------------------------------- 1 | Locked *<%= @environment %>* on <%= @environment.repository %><% if @stolen_lock %> (stolen from <@<%= @stealer.id %>>)<% end %> 2 | -------------------------------------------------------------------------------- /app/views/messages/red_commit.text.erb: -------------------------------------------------------------------------------- 1 | The following commit status checks are not passing: 2 | <% @contexts.each do |context| %> 3 | * *<%= context.context %>* [<%= context.state %>] 4 | <% end %> 5 | -------------------------------------------------------------------------------- /app/views/messages/unauthorized.text.erb: -------------------------------------------------------------------------------- 1 | Sorry, but it looks like you don't have access to <%= @repository %> 2 | -------------------------------------------------------------------------------- /app/views/messages/unlocked.text.erb: -------------------------------------------------------------------------------- 1 | Unlocked *<%= @environment %>* on <%= @environment.repository %> 2 | -------------------------------------------------------------------------------- /app/views/messages/unlocked_all.text.erb: -------------------------------------------------------------------------------- 1 | <% if @locks.length >= 1 %> 2 | You unlocked each of the the following: 3 | <% @locks.each do |lock| %> 4 | * *<%= lock.environment %>* on <%= lock.environment.repository %> 5 | <% end %> 6 | <% else %> 7 | You have nothing to unlock. 8 | <% end %> 9 | -------------------------------------------------------------------------------- /app/views/messages/validation_error.text.erb: -------------------------------------------------------------------------------- 1 | Oops! We had a problem running that command for you. 2 | -------------------------------------------------------------------------------- /app/views/pages/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

A /deploy command for your Slack team

3 |

<%= add_to_slack %>

4 |
5 |

SlashDeploy is an Open Source Rails project maintained by Remind. You should join us in development!

6 |
7 | -------------------------------------------------------------------------------- /app/views/slack/early_access.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

Thanks! We'll let you know when it's ready.

3 |
4 | -------------------------------------------------------------------------------- /app/views/slack/install.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

We're still in beta. Enter your email below to get early access.

3 |
4 |
5 |
6 | <%= form_tag early_access_path, method: 'post' do |f| %> 7 |
8 |
9 | 10 | 11 | 12 | 13 |
14 |
15 | <% end %> 16 |
17 |
18 | -------------------------------------------------------------------------------- /app/views/slack/installed.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Success!

4 |

SlashDeploy has been installed in your Slack team. Type /deploy in Slack to get started.

5 |
6 |
7 | -------------------------------------------------------------------------------- /app/workers/auto_deployment_watchdog_worker.rb: -------------------------------------------------------------------------------- 1 | class AutoDeploymentWatchdogWorker 2 | include Sidekiq::Worker 3 | sidekiq_options queue: 'auto_deployment_watchdog' 4 | 5 | # default time to wait until our worker wakes up. 6 | DEFAULT_DELAY = 1.hour 7 | 8 | # create a class method to hardcode how we want to schedule this worker. 9 | def self.schedule(auto_deployment_id) 10 | self.perform_in(DEFAULT_DELAY, auto_deployment_id) 11 | end 12 | 13 | def perform(auto_deployment_id) 14 | auto_deployment = AutoDeployment.find(auto_deployment_id) 15 | # an inactive auto_deployment was either deployed, or superceded 16 | # by another auto_deployment. Either way we exit without notifying. 17 | return logger.debug "auto_deployment id #{auto_deployment.id} is inactive which means it was already deployed or superceded." if auto_deployment.inactive? 18 | if auto_deployment.pending? 19 | logger.info "There was an issue with auto_deployment id #{auto_deployment.id}. Seems to be hung on #{auto_deployment.pending_statuses}" 20 | SlashDeploy.service.direct_message( 21 | auto_deployment.slack_account, 22 | AutoDeploymentStuckPendingMessage, 23 | auto_deployment: auto_deployment, 24 | account: auto_deployment.slack_account 25 | ) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/workers/github_deployment_watchdog_worker.rb: -------------------------------------------------------------------------------- 1 | class GithubDeploymentWatchdogWorker 2 | include Sidekiq::Worker 3 | sidekiq_options queue: 'deployment_watchdog' 4 | 5 | # default time to wait until our worker wakes up. 6 | DEFAULT_DELAY = 30.seconds 7 | 8 | # create a class method to hardcode how we want to schedule this worker. 9 | # user_id: the database id of the slashdeploy User object. 10 | # github_repo: the repo of the Github Deployment. 11 | # github_deployment_id: the id of the external Github Deployment. 12 | def self.schedule(user_id, github_repo, github_deployment_id) 13 | self.perform_in(DEFAULT_DELAY, user_id, github_repo, github_deployment_id) 14 | end 15 | 16 | # notify the user if there was an issue otherwise do nothing. 17 | # user_id: the database id of the slashdeploy User object. 18 | # github_repo: the repo of the Github Deployment. 19 | # github_deployment_id: the id of the external Github Deployment. 20 | def perform(user_id, github_repo, github_deployment_id) 21 | # get the slashdeploy User object. 22 | user = User.find(user_id) 23 | 24 | # get the Github Deployment by id. 25 | github_deployment = SlashDeploy.service.github.get_deployment(user, github_repo, github_deployment_id) 26 | 27 | # get the latest Github Deployment Status. 28 | deployment_status = SlashDeploy.service.github.last_deployment_status(user, github_deployment.url) 29 | 30 | # if the lastest deployment_status is success exit happily without notifying user. 31 | return logger.debug "The Github Deployment #{github_deployment.id} has at least one status, nothing to do." if deployment_status 32 | 33 | logger.info "The Github Deployment #{github_deployment.id} of #{github_deployment.repository} @ #{github_deployment.sha} or #{github_deployment.ref} to *#{github_deployment.environment}* did _not_ start. For more details, please read: https://slashdeploy.io/docs#error-1" 34 | 35 | # fetch the user's slack_account related to this deployments Github Org. 36 | slack_account = user.slack_account_for_github_organization(github_deployment.organization) 37 | 38 | SlashDeploy.service.direct_message( 39 | slack_account, 40 | GithubNoDeploymentStatusMessage, 41 | github_deployment: github_deployment, 42 | account: slack_account 43 | ) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /app/workers/lock_nag_worker.rb: -------------------------------------------------------------------------------- 1 | class LockNagWorker 2 | include Sidekiq::Worker 3 | sidekiq_options queue: 'lock_nag' 4 | 5 | # default time to wait until our worker wakes up. 6 | DEFAULT_DELAY = 3.hours 7 | 8 | # create a class method to hardcode how we want to schedule this worker. 9 | def self.schedule(lock_id) 10 | self.perform_in(DEFAULT_DELAY, lock_id) 11 | end 12 | 13 | def perform(lock_id) 14 | lock = Lock.find(lock_id) 15 | 16 | # an inactive lock does not need to be processed. 17 | return logger.debug "lock id (#{lock.id}) is already inactive, skipping." if lock.inactive? 18 | 19 | logger.info "Nagging user about lock id (#{lock.id}) and rescheduling another lock nag for the future." 20 | 21 | # reschedule another nag. 22 | self.class.schedule(lock.id) 23 | 24 | # create a message_action to let the user click a button to unlock 25 | # the environment from the nag message itself. 26 | message_action = SlashDeploy.service.create_message_action( 27 | UnlockAction, 28 | repository: lock.repository.name, 29 | environment: lock.environment.name, 30 | ) 31 | 32 | # send the user a nagging direct message with a button for redemption. 33 | SlashDeploy.service.direct_message( 34 | lock.slack_account, 35 | LockNagMessage, 36 | lock: lock, 37 | account: lock.slack_account, 38 | message_action: message_action, 39 | ) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /bin/docker-compose-setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cmd="$@" 6 | 7 | # Install gems 8 | bundle check || bundle install 9 | 10 | # Wait for postgres 11 | until psql -h postgres -U postgres -c '\l' > /dev/null; do 12 | >&2 echo "Postgres is unavailable - sleeping" 13 | sleep 1 14 | done 15 | 16 | # Setup/Migrate database 17 | if [[ `psql -h postgres -U postgres postgres -tAc "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'schema_migrations');"` = 'f' ]]; then 18 | >&2 echo "Postgres schema not defined, running rake db:setup" 19 | bundle exec rake db:setup 20 | fi 21 | 22 | if [[ `psql -h postgres -U postgres postgres -tAc "SELECT version FROM schema_migrations order by version desc limit 1;"` -ne `ls -1 db/migrate/ | cut -d _ -f 1 | tail -1` ]]; then 23 | >&2 echo "Postgres schema not up to date, running rake db:migrate" 24 | bundle exec rake db:migrate 25 | fi 26 | 27 | echo "Running $cmd" 28 | echo 29 | exec $cmd 30 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | APP_PATH = File.expand_path('../../config/application', __FILE__) 8 | require_relative '../config/boot' 9 | require 'rails/commands' 10 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | require_relative '../config/boot' 8 | require 'rake' 9 | Rake.application.run 10 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # This file was generated by Bundler. 4 | # 5 | # The application 'rspec' is installed as part of a gem, and 6 | # this file is here to facilitate running it. 7 | # 8 | 9 | require 'pathname' 10 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', 11 | Pathname.new(__FILE__).realpath) 12 | 13 | require 'rubygems' 14 | require 'bundler/setup' 15 | 16 | load Gem.bin_path('rspec-core', 'rspec') 17 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | 4 | # path to your application root. 5 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 6 | 7 | Dir.chdir APP_ROOT do 8 | # This script is a starting point to setup your application. 9 | # Add necessary setup steps to this file: 10 | 11 | puts '== Installing dependencies ==' 12 | system 'gem install bundler --conservative' 13 | system 'bundle check || bundle install' 14 | 15 | # puts "\n== Copying sample files ==" 16 | # unless File.exist?("config/database.yml") 17 | # system "cp config/database.yml.sample config/database.yml" 18 | # end 19 | 20 | puts "\n== Preparing database ==" 21 | system 'bin/rake db:setup' 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system 'rm -f log/*' 25 | system 'rm -rf tmp/cache' 26 | 27 | puts "\n== Restarting application server ==" 28 | system 'touch tmp/restart.txt' 29 | end 30 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This file loads spring without using Bundler, in order to be fast. 4 | # It gets overwritten when you run the `spring binstub` command. 5 | 6 | unless defined?(Spring) 7 | require 'rubygems' 8 | require 'bundler' 9 | 10 | if (match = Bundler.default_lockfile.read.match(/^GEM$.*?^ (?: )*spring \((.*?)\)$.*?^$/m)) 11 | Gem.paths = { 'GEM_PATH' => [Bundler.bundle_path.to_s, *Gem.path].uniq } 12 | gem 'spring', match[1] 13 | require 'spring/binstub' 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require ::File.expand_path('config/environment', __dir__) 6 | 7 | run Rails.application 8 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | production: 2 | url: <%= ENV['DATABASE_URL'] %> 3 | development: 4 | url: <%= ENV['DATABASE_DEV_URL'] || "postgres://localhost:5432/slashdeploy_development" %> 5 | test: 6 | url: <%= ENV['DATABASE_TEST_URL'] || "postgres://localhost:5432/slashdeploy_test" %> -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('../application', __FILE__) 3 | require 'slashdeploy' 4 | 5 | # Initialize the Rails application. 6 | Rails.application.initialize! 7 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports and disable caching. 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send. 17 | config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger. 20 | config.active_support.deprecation = :log 21 | 22 | # Raise an error on page load if there are pending migrations. 23 | config.active_record.migration_error = :page_load 24 | 25 | # Debug mode disables concatenation and preprocessing of assets. 26 | # This option may cause significant delays in view rendering with a large 27 | # number of complex assets. 28 | config.assets.debug = true 29 | 30 | # Asset digests allow you to set far-future HTTP expiration dates on all assets, 31 | # yet still be able to expire them through the digest params. 32 | config.assets.digest = true 33 | 34 | # Adds additional error checking when serving assets at runtime. 35 | # Checks for improperly declared sprockets dependencies. 36 | # Raises helpful error messages. 37 | config.assets.raise_runtime_errors = true 38 | 39 | # Raises error for missing translations 40 | # config.action_view.raise_on_missing_translations = true 41 | end 42 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | config.x.integration_secret = 'secret' 3 | 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # The test environment is used exclusively to run your application's 7 | # test suite. You never need to work with it otherwise. Remember that 8 | # your test database is "scratch space" for the test suite and is wiped 9 | # and recreated between test runs. Don't rely on the data there! 10 | config.cache_classes = true 11 | 12 | # Do not eager load code on boot. This avoids loading your whole application 13 | # just for the purpose of running a single test. If you are using a tool that 14 | # preloads Rails for running tests, you may have to set it to true. 15 | config.eager_load = false 16 | 17 | # Configure static file server for tests with Cache-Control for performance. 18 | config.serve_static_files = true 19 | config.static_cache_control = 'public, max-age=3600' 20 | 21 | # Show full error reports and disable caching. 22 | config.consider_all_requests_local = true 23 | config.action_controller.perform_caching = false 24 | 25 | # Raise exceptions instead of rendering exception templates. 26 | config.action_dispatch.show_exceptions = false 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | # Tell Action Mailer not to deliver emails to the real world. 32 | # The :test delivery method accumulates sent emails in the 33 | # ActionMailer::Base.deliveries array. 34 | config.action_mailer.delivery_method = :test 35 | 36 | # Randomize the order test cases are executed. 37 | config.active_support.test_order = :random 38 | 39 | # Print deprecation notices to the stderr. 40 | config.active_support.deprecation = :stderr 41 | 42 | # Raises error for missing translations 43 | # config.action_view.raise_on_missing_translations = true 44 | 45 | config.x.slack.verification_token = 'slacktoken' 46 | 47 | config.x.state_key = 'supersecret' 48 | end 49 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '2.0' 5 | Rails.application.config.assets.paths << Rails.root.join('app', 'assets', 'fonts') 6 | Rails.application.config.assets.paths << Rails.root.join('vendor', 'assets', 'fonts') 7 | 8 | # rballestrini commented this out because we were getting the following error: 9 | # 10 | # ActionView::Template::Error: 11 | # undefined method `start_with?' for /\.(?:svg|eot|woff|ttf)\z/:Regexp 12 | # 13 | #Rails.application.config.assets.precompile << /\.(?:svg|eot|woff|ttf)\z/ 14 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.action_dispatch.cookies_serializer = :json 4 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | 18 | ActiveSupport::Inflector.inflections(:en) do |inflect| 19 | inflect.acronym 'GitHub' 20 | end 21 | -------------------------------------------------------------------------------- /config/initializers/logging.rb: -------------------------------------------------------------------------------- 1 | STDOUT.sync = true 2 | 3 | logger = Logger.new(Rails.env.test? ? nil : STDOUT) 4 | Rails.logger = Perty::Logger.new(logger) 5 | 6 | Rails.configuration.lograge.logger = Rails.logger 7 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /config/initializers/omniauth.rb: -------------------------------------------------------------------------------- 1 | require 'omniauth/strategies/github' 2 | require 'omniauth/strategies/slack' 3 | require 'omniauth/strategies/jwt' 4 | -------------------------------------------------------------------------------- /config/initializers/routing.rb: -------------------------------------------------------------------------------- 1 | if url = ENV['URL'] 2 | uri = URI.parse(url) 3 | 4 | Rails.application.routes.default_url_options[:host] = uri.host 5 | Rails.application.routes.default_url_options[:protocol] = uri.scheme 6 | OmniAuth.config.full_host = uri.to_s 7 | else 8 | Rails.application.routes.default_url_options[:host] = 'localhost:5000' 9 | end 10 | -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_slashdeploy_session' 4 | -------------------------------------------------------------------------------- /config/initializers/slashdeploy.rb: -------------------------------------------------------------------------------- 1 | SlashDeploy.service.github = GitHub::Client.new(Rails.configuration.x.github_client) 2 | SlashDeploy.service.slack = Slack::Client.new(Rails.configuration.x.slack_client) 3 | -------------------------------------------------------------------------------- /config/initializers/statsd.rb: -------------------------------------------------------------------------------- 1 | require 'statsd' 2 | 3 | # Create a stats interface 4 | $statsd = Statsd.new('localhost', 8125) 5 | $statsd.tags << "app:#{ENV['HEROKU_APP_NAME']}" 6 | $statsd.tags << "dyno:#{ENV['DYNO']}" 7 | $statsd.tags << "release:#{ENV['HEROKU_RELEASE_VERSION']}" 8 | -------------------------------------------------------------------------------- /config/initializers/subscribers.rb: -------------------------------------------------------------------------------- 1 | ActiveSupport::Notifications.subscribe /process_action.action_controller/ do |*args| 2 | event = ActiveSupport::Notifications::Event.new(*args) 3 | controller = "controller:#{event.payload[:controller]}" 4 | action = "action:#{event.payload[:action]}" 5 | format = "format:#{event.payload[:format] || 'all'}" 6 | format = "format:all" if format == "format:*/*" 7 | status = event.payload[:status] 8 | tags = [controller, action, format] 9 | ActiveSupport::Notifications.instrument :performance, action: :timing, tags: tags, measurement: 'request.total_duration', value: event.duration 10 | ActiveSupport::Notifications.instrument :performance, action: :timing, tags: tags, measurement: 'database.query.time', value: event.payload[:db_runtime] 11 | ActiveSupport::Notifications.instrument :performance, action: :timing, tags: tags, measurement: 'web.view.time', value: event.payload[:view_runtime] 12 | ActiveSupport::Notifications.instrument :performance, tags: tags, measurement: "request.status.#{status}" 13 | end 14 | 15 | ActiveSupport::Notifications.subscribe /performance/ do |name, start, finish, id, payload| 16 | action = payload[:action] || :increment 17 | measurement = payload[:measurement] 18 | value = payload[:value] 19 | tags = payload[:tags] 20 | key_name = "#{name}.#{measurement}" 21 | if action == :increment 22 | $statsd.increment key_name, tags: tags 23 | else 24 | $statsd.histogram key_name, value, tags: tags 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /config/initializers/warden.rb: -------------------------------------------------------------------------------- 1 | Warden::Manager.serialize_into_session do |user| 2 | user.id 3 | end 4 | 5 | Warden::Manager.serialize_from_session do |id| 6 | User.find(id) 7 | end 8 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters) 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | activerecord: 24 | errors: 25 | models: 26 | repository: 27 | attributes: 28 | name: 29 | invalid_repository: "not a valid GitHub repository" 30 | environment: 31 | attributes: 32 | aliases: 33 | matches_existing_environment: "includes the name of an existing environment for this repository" 34 | name: 35 | matches_existing_environment: "includes the name of an existing environment for this repository" 36 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | workers Integer(ENV['WEB_CONCURRENCY'] || 2) 2 | threads_count = Integer(ENV['MAX_THREADS'] || 5) 3 | threads threads_count, threads_count 4 | 5 | preload_app! 6 | 7 | rackup DefaultRackup 8 | port ENV['PORT'] || 3000 9 | environment ENV['RACK_ENV'] || 'development' 10 | 11 | on_worker_boot do 12 | # Worker specific setup for Rails 4.1+ 13 | # See: https://devcenter.heroku.com/articles/deploying-rails-applications-with-the-puma-web-server#on-worker-boot 14 | ActiveRecord::Base.establish_connection 15 | end 16 | -------------------------------------------------------------------------------- /config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 9a38c6475b988f62978a13197914baf77b177e8c1e924dc79f93cba971ab274be2f4af1d4e259d65b2f3bbc6bee4b5cd516b905d40735a074f3428fd57ae7ad9 15 | 16 | test: 17 | secret_key_base: bf32c7233051de890c1621454c0138506bcf6a98b0636d6bc6af916085494ff72ae11e8aaaab24eb2a9ef5518b4562721ed7438add874d672b17de4064bb5e00 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /db/migrate/20160203003153_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration 2 | def change 3 | create_table :users, id: false do |t| 4 | t.string :id, null: false 5 | t.string :github_token, null: false 6 | 7 | t.timestamps null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20160203003510_create_environments.rb: -------------------------------------------------------------------------------- 1 | class CreateEnvironments < ActiveRecord::Migration 2 | def change 3 | create_table :environments do |t| 4 | t.string :repository 5 | t.string :name 6 | 7 | t.timestamps null: false 8 | end 9 | 10 | add_index :environments, [:repository, :name], unique: true 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20160203113511_create_locks.rb: -------------------------------------------------------------------------------- 1 | class CreateLocks < ActiveRecord::Migration 2 | def change 3 | create_table :locks do |t| 4 | t.string :message 5 | t.boolean :active, default: false, null: false 6 | 7 | t.timestamps null: false 8 | end 9 | 10 | add_reference :locks, :environment, index: true 11 | add_index :locks, :environment_id, unique: true, name: 'locked_environment', where: 'active' 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20160205012702_add_environment_ref_to_locks.rb: -------------------------------------------------------------------------------- 1 | class AddEnvironmentRefToLocks < ActiveRecord::Migration 2 | def change 3 | change_column :locks, :environment_id, :integer, null: false 4 | add_foreign_key :locks, :environments 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20160205013625_create_repositories.rb: -------------------------------------------------------------------------------- 1 | class CreateRepositories < ActiveRecord::Migration 2 | def change 3 | create_table :repositories do |t| 4 | t.string :name, unique: true, null: false 5 | 6 | t.timestamps null: false 7 | end 8 | 9 | add_column :environments, :repository_id, :integer 10 | 11 | execute <<-SQL 12 | INSERT INTO repositories (name, created_at, updated_at) SELECT repository, now(), now() FROM environments GROUP BY repository; 13 | SQL 14 | execute <<-SQL 15 | UPDATE environments AS e SET repository_id = r.id FROM repositories AS r WHERE r.name = e.repository; 16 | SQL 17 | 18 | change_column :environments, :repository_id, :integer, null: false 19 | add_foreign_key :environments, :repositories 20 | remove_column :environments, :repository 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/20160205060045_add_user_id_to_locks.rb: -------------------------------------------------------------------------------- 1 | class AddUserIdToLocks < ActiveRecord::Migration 2 | def change 3 | execute 'DELETE FROM locks WHERE 1 = 1' 4 | 5 | add_index :users, :id, unique: true 6 | 7 | change_table :locks do |t| 8 | t.string :user_id, null: false, unique: true 9 | end 10 | 11 | add_foreign_key :locks, :users 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20160205063654_create_connected_accounts.rb: -------------------------------------------------------------------------------- 1 | class CreateConnectedAccounts < ActiveRecord::Migration 2 | def up 3 | execute 'DELETE FROM locks WHERE 1 = 1' 4 | 5 | remove_foreign_key :locks, :users 6 | drop_table :users 7 | create_table :users do |t| 8 | t.timestamps null: false 9 | end 10 | remove_column :locks, :user_id 11 | add_column :locks, :user_id, :integer, null: false 12 | add_foreign_key :locks, :users 13 | 14 | create_table :github_accounts, id: false do |t| 15 | t.integer :user_id 16 | t.integer :id, null: false, primary_key: true 17 | t.string :login, null: false 18 | t.string :token, null: false 19 | end 20 | 21 | add_index :github_accounts, :id, unique: true 22 | add_foreign_key :github_accounts, :users 23 | 24 | create_table :slack_accounts, id: false do |t| 25 | t.integer :user_id 26 | t.string :id, null: false, primary_key: true 27 | t.string :user_name, null: false 28 | t.string :team_id, null: false 29 | t.string :team_domain, null: false 30 | end 31 | 32 | add_index :slack_accounts, :id, unique: true 33 | add_foreign_key :slack_accounts, :users 34 | end 35 | 36 | def down 37 | drop_table :github_accounts 38 | drop_table :slack_accounts 39 | 40 | remove_foreign_key :locks, :users 41 | drop_table :users 42 | create_table :users, id: false do |t| 43 | t.string :id, null: false 44 | t.string :github_token, null: false 45 | 46 | t.timestamps null: false 47 | end 48 | add_index :users, :id, unique: true 49 | remove_column :locks, :user_id 50 | add_column :locks, :user_id, :string, null: false 51 | add_foreign_key :locks, :users 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /db/migrate/20160209012632_add_in_channel_to_environments.rb: -------------------------------------------------------------------------------- 1 | class AddInChannelToEnvironments < ActiveRecord::Migration 2 | def up 3 | add_column :environments, :in_channel, :boolean, null: false, default: false 4 | execute <<-SQL 5 | UPDATE environments SET in_channel = 't' WHERE name = 'production' 6 | SQL 7 | end 8 | 9 | def down 10 | remove_column :environments, :in_channel 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20160209082726_add_github_secret_to_repositories.rb: -------------------------------------------------------------------------------- 1 | class AddGitHubSecretToRepositories < ActiveRecord::Migration 2 | def up 3 | add_column :repositories, :github_secret, :string 4 | Repository.reset_column_information 5 | Repository.find_each do |repository| 6 | repository.update_attributes!(github_secret: SecureRandom.hex) 7 | end 8 | change_column :repositories, :github_secret, :string, null: false 9 | end 10 | 11 | def down 12 | remove_column :repositories, :github_secret 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20160209085736_add_auto_deploy_branch_to_environment.rb: -------------------------------------------------------------------------------- 1 | class AddAutoDeployBranchToEnvironment < ActiveRecord::Migration 2 | def change 3 | add_column :environments, :auto_deploy_ref, :string 4 | add_column :environments, :auto_deploy_user_id, :integer 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20160210071446_add_aliases_to_environments.rb: -------------------------------------------------------------------------------- 1 | class AddAliasesToEnvironments < ActiveRecord::Migration 2 | def change 3 | add_column :environments, :aliases, :text, array: true, default: [] 4 | add_index :environments, [:repository_id, :name], unique: true 5 | execute <<-SQL 6 | UPDATE environments SET aliases = (CASE name 7 | WHEN 'production' THEN '{"prod"}'::text[] 8 | WHEN 'staging' THEN '{"stage"}'::text[] 9 | END); 10 | SQL 11 | end 12 | 13 | def down 14 | drop_column :environments, :aliases 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20160210094548_create_slack_teams.rb: -------------------------------------------------------------------------------- 1 | class CreateSlackTeams < ActiveRecord::Migration 2 | def up 3 | create_table :slack_teams, id: false do |t| 4 | t.string :id, null: false, primary_key: true 5 | t.string :domain, null: false 6 | t.string :github_organization 7 | 8 | t.timestamps null: false 9 | end 10 | 11 | execute <<-SQL 12 | INSERT INTO slack_teams (id, domain, created_at, updated_at) SELECT team_id, team_domain, now(), now() FROM slack_accounts GROUP BY team_id, team_domain 13 | SQL 14 | 15 | rename_column :slack_accounts, :team_id, :slack_team_id 16 | add_index :slack_teams, :id, unique: true 17 | add_foreign_key :slack_accounts, :slack_teams 18 | remove_column :slack_accounts, :team_domain 19 | end 20 | 21 | def down 22 | add_column :slack_accounts, :team_domain, :string 23 | 24 | execute <<-SQL 25 | UPDATE slack_accounts sa SET team_domain = st.domain FROM slack_teams st WHERE sa.slack_team_id = st.id 26 | SQL 27 | 28 | change_column :slack_accounts, :team_domain, :string, null: false 29 | 30 | remove_foreign_key :slack_accounts, :slack_teams 31 | drop_table :slack_teams 32 | rename_column :slack_accounts, :slack_team_id, :team_id 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /db/migrate/20160211061750_add_defaults_to_repositories_and_environments.rb: -------------------------------------------------------------------------------- 1 | class AddDefaultsToRepositoriesAndEnvironments < ActiveRecord::Migration 2 | def change 3 | add_column :repositories, :default_environment, :string 4 | add_column :environments, :default_ref, :string 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20160211091522_add_required_contexts_to_environments.rb: -------------------------------------------------------------------------------- 1 | class AddRequiredContextsToEnvironments < ActiveRecord::Migration 2 | def change 3 | add_column :environments, :required_contexts, :string, array: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20160211161702_create_early_accesses.rb: -------------------------------------------------------------------------------- 1 | class CreateEarlyAccesses < ActiveRecord::Migration 2 | def change 3 | create_table :early_accesses, id: false do |t| 4 | t.string :email, null: false 5 | 6 | t.timestamps null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20160212035321_create_auto_deployments.rb: -------------------------------------------------------------------------------- 1 | class CreateAutoDeployments < ActiveRecord::Migration 2 | def change 3 | create_table :auto_deployments do |t| 4 | t.references :user, index: true, foreign_key: true, null: false 5 | t.references :environment, index: true, foreign_key: true, null: false 6 | t.string :sha, null: false, index: true 7 | t.boolean :active, null: false, default: true 8 | 9 | t.timestamps null: false 10 | end 11 | 12 | add_index :auto_deployments, [:environment_id, :sha], unique: true 13 | 14 | # Ensure that we never have more than 1 active auto deployment per environment. 15 | add_index :auto_deployments, [:environment_id], name: 'unique_auto_deployment_per_environment', unique: true, where: 'active' 16 | 17 | create_table :statuses do |t| 18 | t.string :sha, index: true, null: false 19 | t.string :context, null: false 20 | t.string :state, null: false 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /db/migrate/20160609042717_create_slack_bots.rb: -------------------------------------------------------------------------------- 1 | class CreateSlackBots < ActiveRecord::Migration 2 | def change 3 | create_table :slack_bots, id: false do |t| 4 | t.string :id, null: false, primary_key: true 5 | t.string :slack_team_id, null: false 6 | t.string :access_token, null: false 7 | 8 | t.timestamps null: false 9 | end 10 | 11 | add_index :slack_bots, :slack_team_id, unique: true 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20160725205816_add_slack_notifications_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddSlackNotificationsToUsers < ActiveRecord::Migration 2 | def change 3 | add_column :users, :slack_notifications, :boolean 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20160727164721_enable_slack_notifications.rb: -------------------------------------------------------------------------------- 1 | class EnableSlackNotifications < ActiveRecord::Migration 2 | def change 3 | change_column_default(:users, :slack_notifications, true) 4 | 5 | execute <<-SQL 6 | UPDATE users SET slack_notifications = 't' 7 | SQL 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20160804213314_create_message_actions.rb: -------------------------------------------------------------------------------- 1 | class CreateMessageActions < ActiveRecord::Migration 2 | def change 3 | create_table :message_actions, id: false do |t| 4 | t.uuid :callback_id, null: false, primary_key: true 5 | t.json :action_params 6 | t.string :action, null: false 7 | 8 | t.timestamps null: false 9 | end 10 | 11 | add_index :message_actions, :callback_id, unique: true 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20160815212720_drop_unique_environment_index.rb: -------------------------------------------------------------------------------- 1 | class DropUniqueEnvironmentIndex < ActiveRecord::Migration 2 | def up 3 | execute 'DROP INDEX unique_auto_deployment_per_environment' 4 | end 5 | 6 | def down 7 | add_index :auto_deployments, [:environment_id], name: 'unique_auto_deployment_per_environment', unique: true, where: 'active' 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20161217031700_add_target_url_and_description_to_statuses.rb: -------------------------------------------------------------------------------- 1 | class AddTargetUrlAndDescriptionToStatuses < ActiveRecord::Migration 2 | def change 3 | add_column :statuses, :target_url, :text 4 | add_column :statuses, :description, :text 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20170704235901_create_installations.rb: -------------------------------------------------------------------------------- 1 | class CreateInstallations < ActiveRecord::Migration 2 | def up 3 | create_table :installations, id: false do |t| 4 | t.integer :id, null: false, primary_key: true 5 | 6 | t.timestamps null: false 7 | end 8 | 9 | add_column :repositories, :installation_id, :integer 10 | change_column :auto_deployments, :user_id, :integer, null: true 11 | 12 | add_index :installations, :id, unique: true 13 | end 14 | 15 | def down 16 | drop_table :installations 17 | remove_column :repositories, :installation_id 18 | change_column :auto_deployments, :user_id, :integer, null: false 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /db/migrate/20180308001951_add_raw_config_to_repository.rb: -------------------------------------------------------------------------------- 1 | class AddRawConfigToRepository < ActiveRecord::Migration 2 | def change 3 | add_column :repositories, :raw_config, :text 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20181211003305_add_timestampts_to_github_accounts.rb: -------------------------------------------------------------------------------- 1 | class AddTimestamptsToGitHubAccounts < ActiveRecord::Migration 2 | def change 3 | add_timestamps :github_accounts, null: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) 7 | # Mayor.create(name: 'Emanuel', city: cities.first) 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "3" 3 | services: 4 | test: &test 5 | build: . 6 | volumes: 7 | - .:/home/app 8 | depends_on: 9 | - postgres 10 | - redis 11 | environment: 12 | RACK_ENV: test 13 | DATABASE_TEST_URL: postgres://postgres:postgres@postgres/postgres 14 | REDIS_URL: redis://redis:6379/0 15 | entrypoint: ./bin/docker-compose-setup 16 | command: ./bin/rake 17 | dev: 18 | <<: *test 19 | ports: 20 | - "3000:3000" 21 | command: foreman start -p 3000 22 | postgres: 23 | image: postgres:9.6 24 | environment: 25 | POSTGRES_HOST_AUTH_METHOD: trust 26 | redis: 27 | image: redis:latest 28 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/lib/assets/.keep -------------------------------------------------------------------------------- /lib/github.rb: -------------------------------------------------------------------------------- 1 | require 'github/errors' 2 | 3 | module GitHub 4 | module Client 5 | autoload :Octokit, 'github/client/octokit' 6 | autoload :Fake, 'github/client/fake' 7 | 8 | def self.new(kind) 9 | case kind.try(:to_sym) 10 | when :github 11 | Octokit.new 12 | else 13 | Fake.new 14 | end 15 | end 16 | 17 | def create_deployment(_user, _req) 18 | fail NotImplementedError 19 | end 20 | 21 | def last_deployment(_user, _repository, _environment) 22 | fail NotImplementedError 23 | end 24 | 25 | def access?(_user, _repository) 26 | fail NotImplementedError 27 | end 28 | 29 | def contents(_repository, _path) 30 | fail NotImplementedError 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/github/errors.rb: -------------------------------------------------------------------------------- 1 | module GitHub 2 | Error = Class.new(StandardError) 3 | 4 | # RedCommitError is an error that's returned when the commit someone is 5 | # trying to deploy is not green. 6 | class RedCommitError < Error 7 | attr_reader :contexts 8 | 9 | def initialize(contexts = []) 10 | @contexts = contexts 11 | end 12 | 13 | # Returns the contexts that are in a failing, or pending state. 14 | def bad_contexts 15 | contexts.select { |context| !context.success? } 16 | end 17 | end 18 | 19 | class BadRefError < Error 20 | attr_reader :ref 21 | 22 | def initialize(ref) 23 | @ref = ref 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/hookshot.rb: -------------------------------------------------------------------------------- 1 | # Hookshot provides helpers for handling GitHub webhooks. 2 | module Hookshot 3 | HEADER_GITHUB_EVENT = 'HTTP_X_GITHUB_EVENT'.freeze 4 | HEADER_HUB_SIGNATURE = 'HTTP_X_HUB_SIGNATURE'.freeze 5 | 6 | autoload :Router, 'hookshot/router' 7 | 8 | # Signature calculates the SHA1 HMAC signature of the request body. 9 | def self.signature(body, secret) 10 | OpenSSL::HMAC.hexdigest('sha1', secret, body) 11 | end 12 | 13 | # Verifies that the request body matches the secret. 14 | def self.verify(request, secret) 15 | body = request.body.read 16 | sig = "sha1=#{signature(body, secret)}" 17 | ActiveSupport::SecurityUtils.secure_compare(sig, request.env[HEADER_HUB_SIGNATURE]) 18 | end 19 | 20 | # Returns a rails router compatible constraint matcher that matches the 21 | # X-GitHub-Event header to ensure it's presence. 22 | def self.constraint 23 | -> (request) { request.env[Hookshot::HEADER_GITHUB_EVENT] } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/hookshot/router.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | 3 | module Hookshot 4 | # Router is a Rack application that demuxes GitHub webhooks to other Rack 5 | # applications. 6 | # 7 | # Example 8 | # 9 | # router = Hookshot::Router.new 10 | # router.handle :push, PushHandler.new 11 | # router.handle :deployment, DeploymentHandler.new 12 | class Router 13 | # Rack app that gets called when a handler is not found. 14 | attr_accessor :not_found 15 | 16 | def self.build(&block) 17 | new.tap do |router| 18 | router.instance_eval(&block) 19 | end 20 | end 21 | 22 | def apps 23 | @apps ||= {} 24 | end 25 | 26 | def handle(event, app) 27 | apps[event.to_sym] = app 28 | end 29 | 30 | def call(env) 31 | app = apps[env[HEADER_GITHUB_EVENT].to_sym] 32 | return not_found.call(env) unless app 33 | app.call(env) 34 | end 35 | 36 | def not_found 37 | @not_found ||= -> (_env) { [204, {}, ['']] } 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/omniauth/strategies/github.rb: -------------------------------------------------------------------------------- 1 | require 'omniauth-oauth2' 2 | 3 | module OmniAuth 4 | module Strategies 5 | class GitHub < OmniAuth::Strategies::OAuth2 6 | option \ 7 | :client_options, 8 | site: 'https://api.github.com', 9 | authorize_url: 'https://github.com/login/oauth/authorize', 10 | token_url: 'https://github.com/login/oauth/access_token' 11 | 12 | def request_phase 13 | super 14 | end 15 | 16 | def authorize_params 17 | super.tap do |params| 18 | %w(scope client_options).each do |v| 19 | params[v.to_sym] = request.params[v] if request.params[v] 20 | end 21 | end 22 | end 23 | 24 | uid { raw_info['id'].to_s } 25 | 26 | info do 27 | { 28 | 'nickname' => raw_info['login'], 29 | 'email' => email, 30 | 'name' => raw_info['name'], 31 | 'image' => raw_info['avatar_url'], 32 | 'urls' => { 33 | 'GitHub' => raw_info['html_url'], 34 | 'Blog' => raw_info['blog'] 35 | } 36 | } 37 | end 38 | 39 | extra do 40 | { raw_info: raw_info, all_emails: emails } 41 | end 42 | 43 | def raw_info 44 | access_token.options[:mode] = :header 45 | @raw_info ||= access_token.get('user').parsed 46 | end 47 | 48 | def email 49 | (email_access_allowed?) ? primary_email : raw_info['email'] 50 | end 51 | 52 | def primary_email 53 | primary = emails.find { |i| i['primary'] && i['verified'] } 54 | primary && primary['email'] || nil 55 | end 56 | 57 | # The new /user/emails API - http://developer.github.com/v3/users/emails/#future-response 58 | def emails 59 | return [] unless email_access_allowed? 60 | access_token.options[:mode] = :header 61 | @emails ||= access_token.get('user/emails', headers: { 'Accept' => 'application/vnd.github.v3' }).parsed 62 | end 63 | 64 | def email_access_allowed? 65 | return false unless options['scope'] 66 | email_scopes = ['user', 'user:email'] 67 | scopes = options['scope'].split(',') 68 | (scopes & email_scopes).any? 69 | end 70 | 71 | def callback_url 72 | full_host + script_name + callback_path 73 | end 74 | end 75 | end 76 | end 77 | 78 | OmniAuth.config.add_camelization 'github', 'GitHub' 79 | -------------------------------------------------------------------------------- /lib/omniauth/strategies/jwt.rb: -------------------------------------------------------------------------------- 1 | require 'omniauth' 2 | require 'jwt' 3 | 4 | module OmniAuth 5 | module Strategies 6 | class JWT 7 | include OmniAuth::Strategy 8 | 9 | args [:secret] 10 | 11 | option :secret, nil 12 | option :algorithm, 'HS256' 13 | option :name, 'jwt' 14 | 15 | uid { decoded['id'] } 16 | 17 | extra do 18 | { raw_info: decoded } 19 | end 20 | 21 | def callback_phase 22 | super 23 | rescue ::JWT::DecodeError => e 24 | fail!(:invalid_credentials, e) 25 | end 26 | 27 | private 28 | 29 | def decoded 30 | @decoded ||= ::JWT.decode(request.params['jwt'], options.secret, options.algorithm).first 31 | end 32 | end 33 | 34 | # So it gets registered properly with omniauth. 35 | class Jwt < JWT; end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/perty.rb: -------------------------------------------------------------------------------- 1 | require 'perty/logger' 2 | require 'perty/rack' 3 | -------------------------------------------------------------------------------- /lib/perty/logger.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/module/delegation' 2 | require 'active_support/core_ext/object/blank' 3 | require 'logger' 4 | require 'active_support/logger' 5 | 6 | module Perty 7 | # BetterTaggedLogging is based on ActiveSupport::TaggedLogging to make log 8 | # messages pretty and useful. 9 | # 10 | # See also https://goo.gl/907QWS 11 | module Logger 12 | module Formatter # :nodoc: 13 | # This method is invoked when a log event occurs. 14 | def call(severity, timestamp, progname, msg) 15 | parts = [] 16 | modules.each do |mod| 17 | parts << ["[#{mod}]"] 18 | end 19 | parts << ["request_id=#{context[:request_id]}"] if context[:request_id] 20 | parts << msg 21 | super(severity, timestamp, progname, parts.join(' ')) 22 | end 23 | 24 | def with_module(mod) 25 | modules << mod 26 | yield self 27 | ensure 28 | modules.pop 29 | end 30 | 31 | def with_request_id(request_id) 32 | context[:request_id] = request_id 33 | yield self 34 | ensure 35 | context[:request_id] = nil 36 | end 37 | 38 | def modules 39 | context[:modules] ||= [] 40 | end 41 | 42 | def context 43 | Thread.current[:perty] ||= {} 44 | end 45 | end 46 | 47 | def self.new(logger) 48 | # Ensure we set a default formatter so we aren't extending nil! 49 | logger.formatter ||= ActiveSupport::Logger::SimpleFormatter.new 50 | logger.formatter.extend Formatter 51 | logger.extend(self) 52 | end 53 | 54 | delegate :with_request_id, :with_module, to: :formatter 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/perty/rack.rb: -------------------------------------------------------------------------------- 1 | require 'rails' 2 | 3 | module Perty 4 | # Rack middleware the logs the request with a perty logger. 5 | class Rack < ::Rails::Rack::Logger 6 | attr_reader :logger 7 | 8 | def initialize(app, logger = Rails.logger) 9 | @app = app 10 | @logger = logger 11 | end 12 | 13 | def call(env) 14 | request = ActionDispatch::Request.new(env) 15 | 16 | @logger.with_request_id(env['action_dispatch.request_id']) do 17 | call_app(request, env) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rack/statsd.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class StatsD 3 | METRIC_RACK_REQUEST = 'rack.request'.freeze 4 | 5 | attr_reader :app, :statsd 6 | 7 | def initialize(app, statsd = $statsd) 8 | @app = app 9 | @statsd = statsd 10 | end 11 | 12 | def call(env) 13 | statsd.time METRIC_RACK_REQUEST do 14 | app.call(env) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/slack.rb: -------------------------------------------------------------------------------- 1 | module Slack 2 | autoload :Message, 'slack/message' 3 | autoload :Attachment, 'slack/attachment' 4 | 5 | module Client 6 | autoload :Faraday, 'slack/client/faraday' 7 | autoload :Fake, 'slack/client/fake' 8 | 9 | # A base error for errors coming from slack clients. 10 | Error = Class.new(StandardError) 11 | 12 | def self.new(kind) 13 | case kind.try(:to_sym) 14 | when :slack 15 | Faraday.build 16 | else 17 | Fake.new 18 | end 19 | end 20 | 21 | def direct_message(_slack_account, _message) 22 | fail NotImplementedError 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/slack/attachment.rb: -------------------------------------------------------------------------------- 1 | module Slack 2 | # See https://api.slack.com/docs/attachments 3 | class Attachment 4 | include Virtus.model 5 | 6 | class Field 7 | include Virtus.model 8 | 9 | values do 10 | attribute :title, String 11 | attribute :value, String 12 | attribute :short, Boolean 13 | end 14 | end 15 | 16 | class Action 17 | include Virtus.model 18 | 19 | class Confirmation 20 | include Virtus.model 21 | 22 | values do 23 | attribute :title, String 24 | attribute :text, String 25 | attribute :ok_text, String 26 | attribute :dismiss_text, String 27 | end 28 | 29 | def as_json(options = {}) 30 | super(options).reject { |_k, v| v.nil? } 31 | end 32 | end 33 | 34 | values do 35 | attribute :name, String 36 | attribute :text, String 37 | attribute :style, String 38 | attribute :type, String 39 | attribute :value, String 40 | attribute :confirm, Confirmation 41 | end 42 | 43 | def as_json(options = {}) 44 | super(options).reject { |_k, v| v.nil? } 45 | end 46 | end 47 | 48 | values do 49 | attribute :mrkdwn_in, Array[String] 50 | attribute :text, String 51 | attribute :fallback, String 52 | attribute :callback_id, String 53 | attribute :color, String 54 | attribute :pretext, String 55 | attribute :author_name, String 56 | attribute :author_link, String 57 | attribute :author_icon, String 58 | attribute :title, String 59 | attribute :title_link, String 60 | attribute :fields, Array[Field] 61 | attribute :actions, Array[Action] 62 | attribute :image_url, String 63 | attribute :thumb_url, String 64 | attribute :footer, String 65 | attribute :footer_icon, String 66 | attribute :ts, Integer 67 | end 68 | 69 | def as_json(options = {}) 70 | super(options).reject { |_k, v| v.nil? } 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/slack/client/fake.rb: -------------------------------------------------------------------------------- 1 | module Slack 2 | module Client 3 | class Fake 4 | def direct_message(_slack_account, _message) 5 | end 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/slack/client/faraday.rb: -------------------------------------------------------------------------------- 1 | module Slack 2 | module Client 3 | class Faraday 4 | attr_reader :connection 5 | 6 | def self.build_faraday_connection(url: 'https://slack.com', adapter: ::Faraday.default_adapter) 7 | ::Faraday.new(url: url) do |faraday| 8 | faraday.request :url_encoded 9 | faraday.use CheckError 10 | faraday.response :json 11 | faraday.adapter adapter 12 | end 13 | end 14 | 15 | def self.build(*args) 16 | new(build_faraday_connection(*args)) 17 | end 18 | 19 | def initialize(connection) 20 | @connection = connection 21 | end 22 | 23 | def direct_message(slack_account, message) 24 | params = message.to_h 25 | 26 | params.delete(:attachments) unless params[:attachments].present? 27 | # From https://api.slack.com/methods/chat.postMessage 28 | # 29 | # > The optional attachments argument should contain a JSON-encoded array of attachments. 30 | params[:attachments] = params[:attachments].to_json if params[:attachments] 31 | 32 | connection.post '/api/chat.postMessage', params.merge( 33 | token: slack_account.bot_access_token, 34 | channel: slack_account.id 35 | ) 36 | end 37 | 38 | # Middleware that checks the response from the API, and raises an error 39 | # if the `ok` attribute is false. 40 | # 41 | # See https://api.slack.com/web 42 | class CheckError 43 | attr_reader :app 44 | 45 | def initialize(app) 46 | @app = app 47 | end 48 | 49 | def call(request_env) 50 | @app.call(request_env).on_complete do |response_env| 51 | body = response_env[:body] 52 | fail ::Slack::Client::Error, body['error'] unless body['ok'] 53 | end 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/slack/message.rb: -------------------------------------------------------------------------------- 1 | module Slack 2 | # Message is a ruby representation of a Slack message. 3 | # 4 | # See https://api.slack.com/docs/formatting/builder 5 | class Message 6 | include Virtus.model 7 | 8 | values do 9 | attribute :text, String 10 | attribute :attachments, Array[Attachment] 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/slash.rb: -------------------------------------------------------------------------------- 1 | require 'virtus' 2 | require 'active_support' 3 | require 'active_support/core_ext' 4 | 5 | # Slash is a Ruby library for handling slack slash commands. 6 | module Slash 7 | autoload :Handler, 'slash/handler' 8 | autoload :Request, 'slash/request' 9 | autoload :Action, 'slash/action' 10 | autoload :ActionPayload, 'slash/action_payload' 11 | autoload :Response, 'slash/response' 12 | autoload :Command, 'slash/command' 13 | autoload :Rack, 'slash/rack' 14 | autoload :Router, 'slash/router' 15 | autoload :Route, 'slash/route' 16 | 17 | module Matcher 18 | autoload :Regexp, 'slash/matcher/regexp' 19 | end 20 | 21 | # Middleware for wrapping handlers 22 | module Middleware 23 | autoload :Verify, 'slash/middleware/verify' 24 | autoload :NormalizeText, 'slash/middleware/normalize_text' 25 | autoload :Logging, 'slash/middleware/logging' 26 | end 27 | 28 | # Errors from the Slash library. 29 | Error = Class.new(StandardError) 30 | 31 | UnverifiedError = Class.new(Error) 32 | 33 | def self.say(message) 34 | Response.new in_channel: true, message: message 35 | end 36 | 37 | def self.reply(message) 38 | Response.new in_channel: false, message: message 39 | end 40 | 41 | def self.match_regexp(re) 42 | Matcher::Regexp.new(re) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/slash/action.rb: -------------------------------------------------------------------------------- 1 | module Slash 2 | class Action 3 | attr_accessor :payload 4 | 5 | def self.from_params(params = {}) 6 | new Slash::ActionPayload.new(params) 7 | end 8 | 9 | def initialize(payload = Slash::ActionPayload.new) 10 | @payload = payload 11 | end 12 | 13 | def empty? 14 | payload.attributes.all? { |_k, v| v.nil? } 15 | end 16 | 17 | def value 18 | payload.actions.first.value 19 | end 20 | 21 | def user_id 22 | payload.user ? payload.user.id : nil 23 | end 24 | 25 | def user_name 26 | payload.user ? payload.user.name : nil 27 | end 28 | 29 | def team_id 30 | payload.team ? payload.team.id : nil 31 | end 32 | 33 | def team_domain 34 | payload.team ? payload.team.domain : nil 35 | end 36 | 37 | def channel_id 38 | payload.channel ? payload.channel.id : nil 39 | end 40 | 41 | def channel_name 42 | payload.channel ? payload.channel.name : nil 43 | end 44 | 45 | delegate :token, to: :payload 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/slash/action_payload.rb: -------------------------------------------------------------------------------- 1 | module Slash 2 | # Action represents an incoming slack action 3 | class ActionPayload 4 | include Virtus.model 5 | 6 | class Action 7 | include Virtus.model 8 | 9 | values do 10 | attribute :name, String 11 | attribute :value, String 12 | end 13 | end 14 | 15 | class Team 16 | include Virtus.model 17 | 18 | values do 19 | attribute :id, String 20 | attribute :domain, String 21 | end 22 | end 23 | 24 | class Channel 25 | include Virtus.model 26 | 27 | values do 28 | attribute :id, String 29 | attribute :name, String 30 | end 31 | end 32 | 33 | class User 34 | include Virtus.model 35 | 36 | values do 37 | attribute :id, String 38 | attribute :name, String 39 | end 40 | end 41 | 42 | values do 43 | attribute :actions, Array[Action] 44 | attribute :callback_id, String 45 | attribute :team, Team 46 | attribute :channel, Channel 47 | attribute :user, User 48 | attribute :action_ts, String 49 | attribute :message_ts, String 50 | attribute :attachment_id, String 51 | attribute :token, String 52 | attribute :original_message, String 53 | attribute :response_url, String 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/slash/command.rb: -------------------------------------------------------------------------------- 1 | module Slash 2 | # Command represents an incoming slash command. 3 | class Command 4 | attr_accessor :payload 5 | 6 | def self.from_params(params = {}) 7 | new Slash::CommandPayload.new(params) 8 | end 9 | 10 | def initialize(payload = Slash::CommandPayload.new) 11 | @payload = payload 12 | end 13 | 14 | def ===(other) 15 | payload == other.payload 16 | end 17 | 18 | def respond(response) 19 | uri = URI.parse(payload.response_url) 20 | http = Net::HTTP.new(uri.host, uri.port) 21 | http.use_ssl = true if uri.scheme == 'https' 22 | http.post(uri.path, response.to_json, 'Content-Type' => 'application/json') 23 | nil 24 | end 25 | 26 | delegate :token, :user_id, :user_name, :team_id, :team_domain, to: :payload 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/slash/command_payload.rb: -------------------------------------------------------------------------------- 1 | module Slash 2 | # CommandPayload represents an incoming slash command request. 3 | class CommandPayload 4 | include Virtus.model 5 | 6 | values do 7 | attribute :token, String 8 | attribute :team_id, String 9 | attribute :team_domain, String 10 | attribute :channel_id, String 11 | attribute :channel_name, String 12 | attribute :user_id, String 13 | attribute :user_name, String 14 | attribute :command, String 15 | attribute :text, String 16 | attribute :response_url, String 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/slash/handler.rb: -------------------------------------------------------------------------------- 1 | module Slash 2 | # This defines the interface of the Slash::Handler, which is very similar to 3 | # the interface of Rack. 4 | class Handler 5 | # A handler is simply an object that responds to `call` and takes a hash as 6 | # the first argument. Slash's contract is that there will always be a 7 | # Slash::Command object in the `cmd` key of this hash. 8 | # 9 | # You can use this hash object to add your own arbitrary data in 10 | # middleware/decorators. 11 | def call(_env) 12 | fail NotImplementedError 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/slash/matcher/regexp.rb: -------------------------------------------------------------------------------- 1 | module Slash 2 | module Matcher 3 | # Regexp is a matcher that matches a regular expression, then returns the 4 | # named capture groups as params. 5 | class Regexp 6 | attr_reader :re 7 | 8 | def initialize(re) 9 | @re = re 10 | end 11 | 12 | def match(env) 13 | return unless re =~ env['cmd'].payload.text 14 | matches = ::Regexp.last_match 15 | Hash[matches.names.zip(matches.captures)] 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/slash/middleware/logging.rb: -------------------------------------------------------------------------------- 1 | module Slash 2 | module Middleware 3 | # Logs the command. 4 | class Logging 5 | def initialize(handler, logger = Rails.logger) 6 | @handler = handler 7 | @logger = logger 8 | end 9 | 10 | def call(env) 11 | case env['type'] 12 | when 'cmd' 13 | payload = env['cmd'].payload 14 | @logger.with_module('slack command') do 15 | @logger.info "command=#{payload.command} text=#{payload.text} user_name=#{payload.user_name} user_id=#{payload.user_id} team_domain=#{payload.team_domain} team_id=#{payload.team_id} channel_id=#{payload.channel_id} channel_name=#{payload.channel_name}" 16 | @handler.call(env) 17 | end 18 | when 'action' 19 | payload = env['action'].payload 20 | @logger.with_module('slack action') do 21 | @logger.info "callback_id=#{payload.callback_id} action_ts=#{payload.action_ts} message_ts=#{payload.message_ts} attachment_id=#{payload.attachment_id} response_url=#{payload.response_url} user_name=#{payload.user.name} user_id=#{payload.user.id} team_domain=#{payload.team.domain} team_id=#{payload.team.id} channel_id=#{payload.channel.id} channel_name=#{payload.channel.name}" 22 | @handler.call(env) 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/slash/middleware/normalize_text.rb: -------------------------------------------------------------------------------- 1 | module Slash 2 | module Middleware 3 | class NormalizeText 4 | attr_reader :handler 5 | 6 | def initialize(handler) 7 | @handler = handler 8 | end 9 | 10 | def call(env) 11 | cmd = env['cmd'] 12 | cmd.payload.text = cmd.payload.text.squeeze(' ').strip 13 | @handler.call(env) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/slash/middleware/verify.rb: -------------------------------------------------------------------------------- 1 | module Slash 2 | module Middleware 3 | # Provides a slash handler middleware that verifies the request was from 4 | # Slack. 5 | class Verify 6 | attr_reader :handler 7 | attr_reader :token 8 | 9 | def initialize(handler, token) 10 | @handler = handler 11 | @token = token 12 | end 13 | 14 | # Wraps handler in middleware that verifies the token. 15 | def call(env) 16 | case env['type'] 17 | when 'cmd' 18 | request_token = env['cmd'].token 19 | when 'action' 20 | request_token = env['action'].token 21 | end 22 | if ActiveSupport::SecurityUtils.secure_compare(request_token, token) 23 | handler.call(env) 24 | else 25 | fail UnverifiedError 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/slash/rack.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | 3 | module Slash 4 | # Rack provides a Rack compatible application to serve a slash handler. 5 | class Rack 6 | attr_reader :handler 7 | 8 | def initialize(handler) 9 | @handler = handler 10 | end 11 | 12 | # Call parses the slack slash command and calls the handler. 13 | def call(env) 14 | req = ::Rack::Request.new(env) 15 | params = {} 16 | 17 | payload = req.POST['payload'] 18 | if payload 19 | params['type'] = 'action' 20 | params['action'] = Slash::Action.from_params JSON.parse(payload) 21 | else 22 | params['type'] = 'cmd' 23 | params['cmd'] = Slash::Command.from_params req.POST 24 | end 25 | 26 | begin 27 | response = handler.call(params) 28 | if response 29 | [200, { 'Content-Type' => 'application/json' }, [response.to_json]] 30 | else 31 | [200, {}, ['']] 32 | end 33 | rescue UnverifiedError 34 | [403, {}, ['']] 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/slash/response.rb: -------------------------------------------------------------------------------- 1 | module Slash 2 | # Response represents the object we send to slack when we want to respond to 3 | # a users slash command request. 4 | class Response 5 | include Virtus.value_object 6 | 7 | values do 8 | attribute :in_channel, Boolean 9 | attribute :message, Slack::Message 10 | end 11 | 12 | def text 13 | message.text 14 | end 15 | 16 | def to_json 17 | h = message.to_h 18 | h['response_type'] = 'in_channel' if in_channel 19 | h.to_json 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/slash/route.rb: -------------------------------------------------------------------------------- 1 | module Slash 2 | class Route 3 | attr_reader :matcher, :handler 4 | 5 | def initialize(matcher, handler) 6 | @matcher = matcher 7 | @handler = handler 8 | end 9 | 10 | def match(env) 11 | matcher.match(env) 12 | end 13 | 14 | def call(env) 15 | handler.call(env) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/slash/router.rb: -------------------------------------------------------------------------------- 1 | module Slash 2 | # Router is a slash handler router. 3 | class Router 4 | attr_reader :routes 5 | 6 | # This is a Slash::Handler that will be called if there is no matching 7 | # route. 8 | attr_accessor :not_found 9 | 10 | def initialize 11 | @routes = [] 12 | end 13 | 14 | # Match adds a new handler that will be called when the slash command 15 | # matches the given matcher. 16 | def match(matcher, handler) 17 | routes << Route.new(matcher, handler) 18 | end 19 | 20 | # Returns the route that matches the request. Returns nil if there is no 21 | # matching route. 22 | def route(env) 23 | routes.each do |route| 24 | return route if route.match(env) 25 | end 26 | 27 | nil 28 | end 29 | 30 | # Finds the first handler that matches the slash command, and calls it with 31 | # the parameters returned from the matcher. 32 | def call(env) 33 | route = self.route(env) 34 | if route 35 | env['params'] = route.match(env) || {} 36 | route.call(env) 37 | else 38 | env['params'] = {} 39 | not_found.call(env) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/slashdeploy.rb: -------------------------------------------------------------------------------- 1 | require 'slash' 2 | require 'slack' 3 | require 'hookshot' 4 | require 'github' 5 | require 'slack' 6 | require 'perty' 7 | 8 | require 'slashdeploy/errors' 9 | 10 | # SlashDeployer is the core API of the SlashDeploy service. 11 | module SlashDeploy 12 | # Matches a GitHub repo 13 | # http://rubular.com/r/Ecpz7KLRyE 14 | GITHUB_REPO_REGEX = %r{([\w\-]+)\/([\w\-\.]+)} 15 | 16 | # SlashDeploy should _only_ need read access to this file path. Ensure this 17 | # value matches SlashDeploy's Github integration permission configuration 18 | # labeled "Single File". 19 | # 20 | # See https://github.com/organizations//settings/apps/slashdeploy/permissions 21 | CONFIG_FILE_NAME = ".slashdeploy.yml" 22 | 23 | # If this is included in the commit message, CD will be skipped. 24 | CD_SKIP = /\[cd skip\]/ 25 | 26 | autoload :Service, 'slashdeploy/service' 27 | autoload :Auth, 'slashdeploy/auth' 28 | autoload :Config, 'slashdeploy/config' 29 | 30 | def self.github_app 31 | @github_app ||= GitHub::App.build(Rails.configuration.x.github_app_id, Rails.configuration.x.github_app_private_pem) 32 | end 33 | 34 | # Returns a Rack app for handling the slack slash commands. 35 | def self.commands_handler 36 | handler = SlashCommands.build 37 | 38 | # Log the request 39 | handler = Slash::Middleware::Logging.new(handler) 40 | 41 | # Ensure that users are authorized 42 | handler = Auth.new(handler, Rails.configuration.x.state_key) 43 | 44 | # Strip extra whitespace from the text. 45 | handler = Slash::Middleware::NormalizeText.new(handler) 46 | 47 | # Verify that the slash command came from slack. 48 | Slash::Middleware::Verify.new(handler, Rails.configuration.x.slack.verification_token) 49 | end 50 | 51 | def self.slack_commands 52 | # Adapt it to rack. 53 | Slash::Rack.new(commands_handler) 54 | end 55 | 56 | def self.actions_handler 57 | handler = SlashActions.build 58 | 59 | # Log the request 60 | handler = Slash::Middleware::Logging.new(handler) 61 | 62 | # Ensure that users are authorized 63 | handler = Auth.new(handler, Rails.configuration.x.state_key) 64 | 65 | # Verify that the slash command came from slack. 66 | Slash::Middleware::Verify.new(handler, Rails.configuration.x.slack.verification_token) 67 | end 68 | 69 | def self.slack_actions 70 | Slash::Rack.new(actions_handler) 71 | end 72 | 73 | class << self 74 | attr_accessor :state 75 | 76 | def service 77 | @service ||= Service.new 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/slashdeploy/auth.rb: -------------------------------------------------------------------------------- 1 | module SlashDeploy 2 | # Auth is a slash handler middeware that authenticates the Slack user with GitHub. 3 | class Auth 4 | attr_reader :handler 5 | 6 | EXPIRATION = 1.minute 7 | 8 | def initialize(handler, secret) 9 | @handler = handler 10 | @secret = secret 11 | end 12 | 13 | def call(env) 14 | case env['type'] 15 | when 'cmd' 16 | auth_data = env['cmd'] 17 | when 'action' 18 | auth_data = env['action'] 19 | end 20 | 21 | # Attempt to find the user by their slack user id. This is sufficient 22 | # to authenticate the user, because we're trusting that the request is 23 | # coming from Slack. 24 | account = SlackAccount.find_or_create_from_command_payload(auth_data) 25 | unless account.user 26 | account.user = User.new 27 | account.save! 28 | end 29 | 30 | env['account'] = account 31 | 32 | begin 33 | handler.call(env) 34 | rescue User::MissingGitHubAccount 35 | # If we don't know this slack user, we'll ask them to authenticate 36 | # with GitHub. We encode and sign the Slack user id within the state 37 | # param so we know what slack user they are when the hit the GitHub 38 | # callback. 39 | claims = { 40 | id: account.user.id, 41 | exp: EXPIRATION.from_now.to_i, 42 | iat: Time.now.to_i 43 | } 44 | jwt = JWT.encode(claims, @secret) 45 | url = Rails.application.routes.url_helpers.jwt_auth_url(jwt: jwt) 46 | Slash.reply(Slack::Message.new(text: "I don't know who you are on GitHub yet. Please <#{url}|authenticate> then try again.")) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/slashdeploy/config.rb: -------------------------------------------------------------------------------- 1 | module SlashDeploy 2 | # Config is a Ruby representation of the .slashdeploy.yml configuration file. 3 | # 4 | # Example 5 | # 6 | # --- 7 | # environments: 8 | # production: 9 | # aliases: 10 | # - prod 11 | # continuous_delivery: 12 | # ref: refs/heads/master 13 | # required_contexts: 14 | # - ci/circleci 15 | # staging: 16 | # aliases: 17 | # - stage 18 | class Config 19 | include Virtus.model 20 | 21 | class ContinuousDelivery 22 | include Virtus.model 23 | 24 | attribute :ref, String 25 | attribute :required_contexts, Array[String] 26 | end 27 | 28 | class Environment 29 | include Virtus.model 30 | 31 | attribute :aliases, Array[String] 32 | attribute :continuous_delivery, ContinuousDelivery 33 | end 34 | 35 | attribute :environments, Hash[String => Environment] 36 | 37 | # optional default_environment name. 38 | attribute :default_environment, String, :default => nil 39 | 40 | # Public: Loads the raw yaml and initializes a new Config object from it. 41 | # 42 | # yaml - raw YAML formatted string 43 | # 44 | # Returns Config. 45 | def self.from_yaml(yaml) 46 | # disable arbirtary class deserialization but allow aliases 47 | new Psych.safe_load(yaml, [], [], true) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/slashdeploy/errors.rb: -------------------------------------------------------------------------------- 1 | module SlashDeploy 2 | Error = Class.new(StandardError) 3 | 4 | EnvironmentAutoDeploys = Class.new(Error) 5 | 6 | NoConfig = Class.new(Error) 7 | 8 | # Raised when a user doesn't have access to the given repo. 9 | class RepoUnauthorized < Error 10 | attr_reader :repository 11 | 12 | def initialize(repo) 13 | @repository = repo 14 | end 15 | end 16 | 17 | # Raised when an action cannot be performed on the environment because it's locked. 18 | class EnvironmentLockedError < Error 19 | attr_reader :lock 20 | 21 | def initialize(lock) 22 | @lock = lock 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/lib/tasks/.keep -------------------------------------------------------------------------------- /lib/tasks/brakeman.rake: -------------------------------------------------------------------------------- 1 | namespace :brakeman do 2 | desc 'Run Brakeman' 3 | task :run do |t| 4 | require 'brakeman' 5 | Brakeman.run app_path: '.', print_report: true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/tasks/cd.rake: -------------------------------------------------------------------------------- 1 | namespace :cd do 2 | task :enable, [:repo] => :environment do |t, args| 3 | repo = Repository.with_name(args[:repo]) 4 | prod = repo.environment('production') 5 | prod.required_contexts = ['ci/circleci', 'container/docker'] 6 | prod.configure_auto_deploy('refs/heads/master') 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/tasks/github.rake: -------------------------------------------------------------------------------- 1 | namespace :github do 2 | task :request_reauthenticate, [:user_id] => :environment do |t, args| 3 | users = User.joins(:github_accounts).where(github_accounts: { updated_at: nil }) 4 | if user_id = args[:user_id] 5 | users = [User.find(user_id)] 6 | end 7 | 8 | users.each do |user| 9 | user.slack_accounts.each do |account| 10 | puts "Requesting user #{user.id} to re-authenticate" 11 | SlashDeploy.service.direct_message \ 12 | account, \ 13 | GitHubAuthenticateMessage, \ 14 | url: Rails.application.routes.url_helpers.oauth_url(provider: 'github') 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/tasks/locks.rake: -------------------------------------------------------------------------------- 1 | namespace :locks do 2 | task :active => :environment do 3 | locks = Lock.active 4 | locks.each do |lock| 5 | puts "#{lock.user.username}\t#{lock.repository}\t#{lock.environment}" 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/tasks/rubocop.rake: -------------------------------------------------------------------------------- 1 | begin 2 | require 'rubocop/rake_task' 3 | 4 | desc 'Run RuboCop on the lib directory' 5 | RuboCop::RakeTask.new 6 | rescue LoadError 7 | # Meh 8 | end 9 | -------------------------------------------------------------------------------- /lib/tasks/statuses.rake: -------------------------------------------------------------------------------- 1 | namespace :statuses do 2 | task :prune, [:limit] => :environment do |t, args| 3 | args.with_defaults(limit: 100) 4 | limit = args[:limit].to_i 5 | 6 | statuses = Status.pruneable.order(:id).limit(limit) 7 | print "Pruning #{statuses.count} statuses. Continue? (y/n) " 8 | if STDIN.gets.strip == "y" 9 | statuses.destroy_all 10 | else 11 | puts "Aborting" 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/log/.keep -------------------------------------------------------------------------------- /ngrok.yml: -------------------------------------------------------------------------------- 1 | --- 2 | update: false 3 | tunnels: 4 | default: 5 | proto: http 6 | addr: 3000 7 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /spec/features/add_to_slack_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.feature 'Add to Slack' do 4 | fixtures :all 5 | 6 | after do 7 | OmniAuth.config.mock_auth[:slack] = nil 8 | end 9 | 10 | scenario 'clicking the Add to Slack button' do 11 | OmniAuth.config.mock_auth[:slack] = OmniAuth::AuthHash.new( 12 | provider: 'slack', 13 | uid: '123545', 14 | info: { 15 | nickname: 'joe', 16 | team_id: 'XXXXXXXXXX', 17 | team_domain: 'acme' 18 | }, 19 | credentials: { 20 | token: 'xoxt-23984754863-2348975623103' 21 | }, 22 | extra: { 23 | raw_info: { 24 | url: "https://some-org_name.slack.com" 25 | }, 26 | bot_info: { 27 | bot_user_id: 'UTTTTTTTTTTR', 28 | bot_access_token: 'xoxb-XXXXXXXXXXXX-TTTTTTTTTTTTTT' 29 | } 30 | } 31 | ) 32 | 33 | expect do 34 | visit '/auth/slack/callback' 35 | end.to change { SlackBot.count }.by(1) 36 | # expect(page).to have_content 'Success!' 37 | end 38 | 39 | scenario 'clicking the Add to Slack button a second time' do 40 | OmniAuth.config.mock_auth[:slack] = OmniAuth::AuthHash.new( 41 | provider: 'slack', 42 | uid: '123545', 43 | info: { 44 | nickname: 'joe', 45 | team_id: 'XXXXXXXXXX', 46 | team_domain: 'acme' 47 | }, 48 | credentials: { 49 | token: 'xoxt-23984754863-2348975623103' 50 | }, 51 | extra: { 52 | raw_info: { 53 | url: "https://some-org_name.slack.com" 54 | }, 55 | bot_info: { 56 | bot_user_id: 'UTTTTTTTTTTR', 57 | bot_access_token: 'xoxb-XXXXXXXXXXXX-TTTTTTTTTTTTTT' 58 | } 59 | } 60 | ) 61 | 62 | expect do 63 | visit '/auth/slack/callback' 64 | end.to change { SlackBot.count }.by(1) 65 | 66 | expect do 67 | visit '/auth/slack/callback' 68 | end.to change { SlackBot.count }.by(0) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/fixtures/github/installation_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "created", 3 | "installation": { 4 | "id": 1234, 5 | "account": { 6 | "login": "octocat", 7 | "id": 1, 8 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 9 | "gravatar_id": "", 10 | "url": "https://api.github.com/users/octocat", 11 | "html_url": "https://github.com/octocat", 12 | "followers_url": "https://api.github.com/users/octocat/followers", 13 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 14 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 15 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 16 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 17 | "organizations_url": "https://api.github.com/users/octocat/orgs", 18 | "repos_url": "https://api.github.com/users/octocat/repos", 19 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 20 | "received_events_url": "https://api.github.com/users/octocat/received_events", 21 | "type": "User", 22 | "site_admin": false 23 | }, 24 | "repository_selection": "selected", 25 | "access_tokens_url": "https://api.github.com/installations/2/access_tokens", 26 | "repositories_url": "https://api.github.com/installation/repositories" 27 | }, 28 | "sender": { 29 | "login": "octocat", 30 | "id": 1, 31 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 32 | "gravatar_id": "", 33 | "url": "https://api.github.com/users/octocat", 34 | "html_url": "https://github.com/octocat", 35 | "followers_url": "https://api.github.com/users/octocat/followers", 36 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 37 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 38 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 39 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 40 | "organizations_url": "https://api.github.com/users/octocat/orgs", 41 | "repos_url": "https://api.github.com/users/octocat/repos", 42 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 43 | "received_events_url": "https://api.github.com/users/octocat/received_events", 44 | "type": "User", 45 | "site_admin": false 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /spec/fixtures/github_accounts.yml: -------------------------------------------------------------------------------- 1 | david: 2 | user: david 3 | id: 36412047 4 | token: "05e567b87752821edcb615552f8917c7" 5 | login: david 6 | steve: 7 | user: steve 8 | id: 28462522 9 | token: "ff878051fd34eb72a002190a9111508c" 10 | login: steve 11 | bob: 12 | user: bob 13 | id: 19744705 14 | token: "3970a23cd8975706ce2efa4260b2f655" 15 | login: bob 16 | -------------------------------------------------------------------------------- /spec/fixtures/installations.yml: -------------------------------------------------------------------------------- 1 | baxterthehacker: 2 | id: 1234 3 | -------------------------------------------------------------------------------- /spec/fixtures/repositories.yml: -------------------------------------------------------------------------------- 1 | 'baxterthehacker/public-repo': 2 | name: 'baxterthehacker/public-repo' 3 | github_secret: secret 4 | installation_id: 1234 5 | raw_config: "---\n" 6 | 'acme-inc/api': 7 | name: 'acme-inc/api' 8 | github_secret: secret 9 | installation_id: 1234 10 | raw_config: | 11 | environments: 12 | production: {} 13 | staging: 14 | aliases: 15 | - stage 16 | cd/no_contexts: 17 | continuous_delivery: 18 | ref: refs/heads/master 19 | -------------------------------------------------------------------------------- /spec/fixtures/slack_accounts.yml: -------------------------------------------------------------------------------- 1 | david: 2 | user: david 3 | id: "U012AB1AB" 4 | user_name: david 5 | slack_team: acme 6 | david_baxterthehacker: 7 | user: david 8 | id: "U012AB1AC" 9 | user_name: david 10 | slack_team: baxterthehacker 11 | steve: 12 | user: steve 13 | id: "U123BC2BD" 14 | user_name: steve 15 | slack_team: acme 16 | bob: 17 | user: bob 18 | id: "U987AD3CD" 19 | user_name: bob 20 | slack_team: skynet 21 | -------------------------------------------------------------------------------- /spec/fixtures/slack_teams.yml: -------------------------------------------------------------------------------- 1 | acme: 2 | domain: "acme" 3 | github_organization: "acme-inc" 4 | baxterthehacker: 5 | domain: "baxterthehacker" 6 | github_organization: "baxterthehacker" 7 | skynet: 8 | domain: "skynet" 9 | -------------------------------------------------------------------------------- /spec/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | david: {} 2 | steve: {} 3 | bob: {} # nobody likes bob 4 | -------------------------------------------------------------------------------- /spec/github/github_event_handler_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe GitHubEventHandler do 4 | fixtures :installations 5 | 6 | let(:handler) do 7 | Class.new(GitHubEventHandler) do 8 | def run 9 | end 10 | end 11 | end 12 | 13 | describe '#call from installation' do 14 | context 'when the repo is not found' do 15 | # This could happen if the integration is installed organization wide. 16 | it 'verifies the signature and creates the repository' do 17 | req = Rack::MockRequest.new(handler) 18 | expect do 19 | resp = req.post \ 20 | '/', 21 | input: { 22 | repository: { 23 | full_name: 'acme-inc/frontend' 24 | }, 25 | installation: { 26 | id: 1234 27 | } 28 | }.to_json, 29 | 'CONTENT_TYPE' => 'application/json', 30 | 'HTTP_X_HUB_SIGNATURE' => 'sha1=cf3087c974f5bee8c93210f61b0186e0722c605d' 31 | expect(resp.status).to eq 200 32 | end.to change { Repository.count } 33 | end 34 | end 35 | 36 | context 'when the signature does not match' do 37 | it 'returns a 403' do 38 | Repository.create!(name: 'acme-inc/api', github_secret: 'secret') 39 | req = Rack::MockRequest.new(handler) 40 | resp = req.post \ 41 | '/', 42 | input: { 43 | repository: { 44 | full_name: 'acme-inc/api' 45 | }, 46 | installation: { 47 | id: 1234 48 | } 49 | }.to_json, 50 | 'CONTENT_TYPE' => 'application/json', 51 | 'HTTP_X_HUB_SIGNATURE' => 'sha1=abcd' 52 | expect(resp.status).to eq 403 53 | end 54 | end 55 | 56 | context 'when the signature matches' do 57 | it 'returns a 200 and calls the handler' do 58 | Repository.create!(name: 'acme-inc/api', github_secret: 'secret') 59 | req = Rack::MockRequest.new(handler) 60 | resp = req.post \ 61 | '/', 62 | input: { 63 | repository: { 64 | full_name: 'acme-inc/api' 65 | }, 66 | installation: { 67 | id: 1234 68 | } 69 | }.to_json, 70 | 'CONTENT_TYPE' => 'application/json', 71 | 'HTTP_X_HUB_SIGNATURE' => 'sha1=1290d145b7ac29e87238b4b129bc10076e22387f' 72 | expect(resp.status).to eq 200 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/hookshot/router_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Hookshot::Router do 4 | describe '#call' do 5 | it 'routes to the correct apps' do 6 | router = Hookshot::Router.new 7 | router.handle :push, -> (_env) { [200, {}, ['push event']] } 8 | router.handle :deployment, -> (_env) { [200, {}, ['deployment event']] } 9 | 10 | status, _headers, body = router.call(env_for_event('push')) 11 | expect(status).to eq 200 12 | expect(body).to eq ['push event'] 13 | 14 | status, _headers, body = router.call(env_for_event('deployment')) 15 | expect(status).to eq 200 16 | expect(body).to eq ['deployment event'] 17 | 18 | status, _headers, _body = router.call(env_for_event('ping')) 19 | expect(status).to eq 204 20 | end 21 | end 22 | 23 | def env_for_event(event) 24 | env = Rack::MockRequest.env_for('/') 25 | env['HTTP_X_GITHUB_EVENT'] = event 26 | env 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/hookshot_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Hookshot do 4 | describe '.signature' do 5 | it 'calculates the signature' do 6 | signature = Hookshot.signature '{"event":"data"}', '1234' 7 | expect(signature).to eq 'ade133892a181fba3a21c163cd5cbc3f5f8e915c' 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/models/deployment_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Deployment, type: :model do 4 | describe '#organization' do 5 | it 'returns the organization' do 6 | deployment = Deployment.new( 7 | url: 'https://api.github.com/repos/octocat/example/deployments/1', 8 | repository: 'octocat/example' 9 | ) 10 | expect(deployment.organization).to eq 'octocat' 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/models/environment_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Environment, type: :model do 4 | describe '#in_channel' do 5 | it 'defaults to true for production environments' do 6 | environment = Environment.new(name: 'production') 7 | expect(environment.in_channel).to be_truthy 8 | end 9 | 10 | it 'defaults to false for other environments' do 11 | environment = Environment.new(name: 'staging') 12 | expect(environment.in_channel).to be_falsy 13 | end 14 | end 15 | 16 | describe '#default_ref' do 17 | context 'when the environment has a default ref provided' do 18 | it 'returns that value' do 19 | environment = Environment.new(default_ref: 'develop') 20 | expect(environment.default_ref).to eq 'develop' 21 | end 22 | end 23 | 24 | context 'when the environment does not have a default ref' do 25 | it 'returns the global default' do 26 | environment = Environment.new(default_ref: '') 27 | expect(environment.default_ref).to eq 'master' 28 | 29 | environment = Environment.new 30 | expect(environment.default_ref).to eq 'master' 31 | end 32 | end 33 | end 34 | 35 | describe '#match_name' do 36 | context 'when the repository has a config set' do 37 | it 'returns the correct value' do 38 | repo = Repository.with_name('acme-inc/api') 39 | repo.configure! <<-YAML 40 | environments: 41 | production: 42 | aliases: [prod] 43 | YAML 44 | 45 | environment = Environment.new(name: 'production', repository: repo) 46 | expect(environment.match_name?('production')).to eq true 47 | expect(environment.match_name?('prod')).to eq true 48 | expect(environment.match_name?('pro')).to eq false 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/models/lock_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Lock, type: :model do 4 | describe '#lock!' do 5 | fixtures :users 6 | let(:user) { users(:david) } 7 | 8 | it 'only allows for 1 active lock per environment' do 9 | repo = Repository.with_name('acme-inc/api') 10 | repo.raw_config = <<-YAML 11 | environments: 12 | production: {} 13 | staging: {} 14 | YAML 15 | 16 | # Trying the lock the same environment should result in an error. 17 | staging = repo.environment('staging') 18 | expect { staging.lock! user }.to change { staging.locks.count }.by(1) 19 | expect do 20 | expect { staging.lock! user }.to raise_error ActiveRecord::RecordNotUnique 21 | end.to_not change { staging.locks.count } 22 | 23 | # Trying to lock a different environment for the same repo should be fine. 24 | prod = repo.environment('production') 25 | expect { prod.lock! user }.to change { prod.locks.count }.by(1) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/models/message_action_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe MessageAction, type: :model do 4 | end 5 | -------------------------------------------------------------------------------- /spec/models/repository_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Repository, type: :model do 4 | describe '#name' do 5 | it 'gets validated as a GitHub repository name' do 6 | repo = Repository.new(name: 'foo') 7 | expect(repo).to be_invalid 8 | expect(repo.errors[:name]).to eq ['not a valid GitHub repository'] 9 | end 10 | end 11 | 12 | describe '#organization' do 13 | it 'returns the organization' do 14 | repo = Repository.new(name: 'ejholmes/foo') 15 | expect(repo.organization).to eq 'ejholmes' 16 | end 17 | end 18 | 19 | describe '#environment' do 20 | context 'when given and environment name' do 21 | it 'returns the environment with that name' do 22 | repo = Repository.with_name('acme-inc/api') 23 | repo.raw_config = <<-YAML 24 | environments: 25 | production: {} 26 | staging: {} 27 | YAML 28 | environment = repo.environment('staging') 29 | expect(environment).to_not be_nil 30 | expect(environment.name).to eq 'staging' 31 | end 32 | end 33 | end 34 | 35 | describe '#default_environment' do 36 | 37 | before do 38 | @repo = Repository.with_name('acme-inc/api') 39 | @repo.configure! <<-YAML 40 | default_environment: production 41 | environments: 42 | production: 43 | aliases: [prod] 44 | stage: 45 | aliases: [staging] 46 | YAML 47 | 48 | @env_prod = Environment.new(name: 'production', repository: @repo) 49 | @env_stage = Environment.new(name: 'stage', repository: @repo) 50 | end 51 | 52 | context 'when not given a name' do 53 | it 'returns the default environment' do 54 | environment = @repo.environment 55 | expect(environment).to_not be_nil 56 | expect(environment.name).to eq "production" 57 | end 58 | end 59 | 60 | context 'when not given an alias' do 61 | it 'repo returns the correct environment' do 62 | environment = @repo.environment("prod") 63 | expect(environment).to_not be_nil 64 | expect(environment.name).to eq "production" 65 | end 66 | end 67 | 68 | context 'when default_environment set' do 69 | it 'repository and environment objects react correctly' do 70 | expect(@env_prod.is_default?).to eq true 71 | expect(@env_stage.is_default?).to eq false 72 | expect(@repo.default_environment.name).to eq "production" 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/models/slack_team_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe SlackTeam, type: :model do 4 | end 5 | -------------------------------------------------------------------------------- /spec/models/user_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe User, type: :model do 4 | describe '#unlock_all!' do 5 | 6 | # slashdeploy/spec/fixtures/users.yml 7 | fixtures :users 8 | let(:user1) { users(:david) } 9 | let(:user2) { users(:steve) } 10 | 11 | it 'allows david to unlock both environments with unlock_all without messing with steve' do 12 | config = <<-yaml.strip_heredoc 13 | environments: 14 | stage: {} 15 | prod: {} 16 | yaml 17 | 18 | repo1 = Repository.with_name('acme-inc/api1') 19 | repo1.update_attributes! raw_config: config 20 | repo2 = Repository.with_name('acme-inc/api2') 21 | repo2.update_attributes! raw_config: config 22 | 23 | # david runs lock repo1 in stage environment. 24 | repo1_stage = repo1.environment('stage') 25 | expect { repo1_stage.lock! user1 } 26 | .to change { repo1_stage.locks.active.count }.from(0).to(1) 27 | .and change { user1.locks.active.count }.from(0).to(1) 28 | 29 | # david runs lock repo1 in prod environment. 30 | repo1_prod = repo1.environment('prod') 31 | expect { repo1_prod.lock! user1 } 32 | .to change { repo1_prod.locks.active.count }.from(0).to(1) 33 | .and change { user1.locks.active.count }.from(1).to(2) 34 | 35 | # david unlocks and then relocks repo1 in prod environment. 36 | repo1_prod.active_lock.unlock!() 37 | expect { repo1_prod.lock! user1 } 38 | .to change { repo1_prod.locks.active.count }.from(0).to(1) 39 | .and change { user1.locks.active.count }.from(1).to(2) 40 | 41 | # steve runs lock repo2 in stage environment. 42 | repo2_stage = repo2.environment('stage') 43 | expect { repo2_stage.lock! user2 } 44 | .to change { repo2_stage.locks.active.count }.from(0).to(1) 45 | .and change { user2.locks.active.count }.from(0).to(1) 46 | 47 | # david runs unlock_all! 48 | unlocked_locks = user1.unlock_all! 49 | 50 | # expect the unlock_all! method should return locks which were unlocked. 51 | expect(unlocked_locks.count).to eq(2) 52 | expect(unlocked_locks[0].repository.name).to eq("acme-inc/api1") 53 | 54 | # expect david to have no active locks. 55 | expect(user1.locks.active.count).to eq(0) 56 | 57 | # expect david to have 3 unactive locks. 58 | expect(user1.locks.count).to eq(3) 59 | 60 | # expect steve to have 1 active locks. 61 | expect(user2.locks.active.count).to eq(1) 62 | 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/omniauth/strategies/jwt_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'omniauth/strategies/jwt' 3 | 4 | RSpec.describe OmniAuth::Strategies::JWT do 5 | include Rack::Test::Methods 6 | 7 | let(:secret) { 'abcd' } 8 | let(:backend) { -> (_env) { [200, {}, ['Hello World']] } } 9 | let(:app) { described_class.new backend, callback_path: '/auth/jwt/callback', secret: secret } 10 | 11 | before do 12 | allow(OmniAuth.config).to receive(:test_mode).and_return(false) 13 | end 14 | 15 | context 'callback phase' do 16 | before do 17 | env 'rack.session', '' 18 | end 19 | 20 | it 'raises an error when there is no JWT token' do 21 | get '/auth/jwt/callback' 22 | expect(last_request.env['omniauth.error']).to be_present 23 | end 24 | 25 | it 'raises an error when the JWT token is invalid' do 26 | get '/auth/jwt/callback', 'jwt': 'foo' 27 | expect(last_request.env['omniauth.error']).to be_present 28 | end 29 | 30 | it 'authenticates succesfully when the JWT token is valid' do 31 | claims = { 32 | id: 5, 33 | exp: 1.minutes.from_now.to_i, 34 | iat: Time.now.to_i 35 | } 36 | get '/auth/jwt/callback', 'jwt': JWT.encode(claims, secret) 37 | expect(last_request.env['omniauth.auth'].uid).to eq 5 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/perty/logger_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'stringio' 3 | require 'perty/logger' 4 | 5 | RSpec.describe Perty::Logger do 6 | it 'logs to a logger' do 7 | buf = StringIO.new 8 | logger = Perty::Logger.new(Logger.new(buf)) 9 | logger.info 'foo' 10 | expect(buf.string).to eq "foo\n" 11 | end 12 | 13 | describe '#with_requet_id' do 14 | it 'prefixes the log lines with the request id' do 15 | buf = StringIO.new 16 | logger = Perty::Logger.new(Logger.new(buf)) 17 | logger.with_request_id('id') { logger.info 'foo' } 18 | expect(buf.string).to eq "request_id=id foo\n" 19 | end 20 | end 21 | 22 | describe '#with_module' do 23 | it 'prefixes the log line with a module' do 24 | buf = StringIO.new 25 | logger = Perty::Logger.new(Logger.new(buf)) 26 | logger.with_module('slack commands') { logger.info 'foo' } 27 | expect(buf.string).to eq "[slack commands] foo\n" 28 | end 29 | 30 | it 'allows for nested modules' do 31 | buf = StringIO.new 32 | logger = Perty::Logger.new(Logger.new(buf)) 33 | logger.with_module('slack commands') do 34 | logger.with_module('DeployCommand') do 35 | logger.info 'foo' 36 | end 37 | logger.info 'bar' 38 | end 39 | expect(buf.string).to eq <<-MSG.strip_heredoc 40 | [slack commands] [DeployCommand] foo 41 | [slack commands] bar 42 | MSG 43 | end 44 | end 45 | 46 | describe 'with request id and module' do 47 | it 'puts the request id first' do 48 | buf = StringIO.new 49 | logger = Perty::Logger.new(Logger.new(buf)) 50 | logger.with_module('slack commands') { logger.with_request_id('id') { logger.info 'foo' } } 51 | expect(buf.string).to eq "[slack commands] request_id=id foo\n" 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/slack/client/faraday_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Slack::Client::Faraday do 4 | let(:client) { described_class.build } 5 | 6 | describe '#direct_message' do 7 | let(:slack_account) { stub_model(SlackAccount, id: 'U123BC2BD', bot_access_token: 'access_token') } 8 | 9 | describe 'when the message is properly formatted' do 10 | it 'posts a chat message' do 11 | stub_request(:post, 'https://slack.com/api/chat.postMessage') 12 | .with(body: { 'channel' => 'U123BC2BD', 'text' => 'Hello World', 'token' => 'access_token' }) 13 | .to_return(status: 200, body: '{"ok":true}', headers: { 'Content-Type' => 'application/json' }) 14 | message = Slack::Message.new text: 'Hello World' 15 | client.direct_message(slack_account, message) 16 | end 17 | end 18 | 19 | describe 'when the message has attachements' do 20 | it 'posts a chat message' do 21 | stub_request(:post, 'https://slack.com/api/chat.postMessage') 22 | .with(body: { 'attachments' => "[{\"mrkdwn_in\":[],\"text\":\"Hello World\",\"fields\":[],\"actions\":[]}]", 'channel' => 'U123BC2BD', 'text' => '', 'token' => 'access_token' }) 23 | .to_return(status: 200, body: '{"ok":true}', headers: { 'Content-Type' => 'application/json' }) 24 | message = Slack::Message.new attachments: [Slack::Attachment.new(text: 'Hello World')] 25 | client.direct_message(slack_account, message) 26 | end 27 | end 28 | 29 | describe 'when the message is malformed' do 30 | it 'raises an error' do 31 | stub_request(:post, 'https://slack.com/api/chat.postMessage') 32 | .with(body: { 'channel' => 'U123BC2BD', 'text' => 'Hello World', 'token' => 'access_token' }) 33 | .to_return(status: 200, body: '{"ok":false,"error":"invalid_array_arg"}', headers: { 'Content-Type' => 'application/json' }) 34 | message = Slack::Message.new text: 'Hello World' 35 | expect do 36 | client.direct_message(slack_account, message) 37 | end.to raise_error Slack::Client::Error, 'invalid_array_arg' 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/slack/message_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Slack::Message do 4 | it 'builds messages' do 5 | m = Slack::Message.new 6 | m.text = 'Hello World' 7 | m.attachments = [ 8 | Slack::Attachment.new( 9 | text: 'Some attachment' 10 | ) 11 | ] 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/slash/middleware/verify_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Slash::Middleware::Verify do 4 | let(:handler) { instance_double(Slash::Handler) } 5 | 6 | describe '#call' do 7 | context 'when the token matches' do 8 | it 'calls the handler' do 9 | h = described_class.new handler, 'secret' 10 | env = { 'cmd' => Slash::Command.from_params('token' => 'secret'), 'type' => 'cmd' } 11 | 12 | expect(handler).to receive(:call).with(env) 13 | h.call(env) 14 | end 15 | end 16 | 17 | context 'when the token does not match' do 18 | it 'raises' do 19 | h = described_class.new handler, 'secret' 20 | env = { 'cmd' => Slash::Command.from_params('token' => 'l33thacks'), 'type' => 'cmd' } 21 | 22 | expect(handler).to_not receive(:call).with(env) 23 | expect { h.call(env) }.to raise_error Slash::UnverifiedError 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/slash/rack_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Slash::Rack do 4 | let(:handler) { instance_double(Slash::Handler) } 5 | let(:app) { described_class.new handler } 6 | 7 | describe '#call' do 8 | context 'when a response is returned from the handler' do 9 | it 'returns the json representation of the response' do 10 | env = Rack::MockRequest.env_for( 11 | '/', 12 | method: 13 | 'POST', 14 | input: 'command=/deploy&text=thing&token=foo' 15 | ) 16 | expect(handler).to receive(:call).with( 17 | 'cmd' => Slash::Command.new( 18 | Slash::CommandPayload.new( 19 | command: '/deploy', 20 | text: 'thing', 21 | token: 'foo' 22 | ) 23 | ), 24 | 'type' => 'cmd' 25 | ).and_return(Slash.say(Slack::Message.new(text: 'Hello'))) 26 | status, headers, body = app.call(env) 27 | expect(status).to eq 200 28 | expect(headers).to eq('Content-Type' => 'application/json') 29 | expect(body).to eq ['{"text":"Hello","attachments":[],"response_type":"in_channel"}'] 30 | end 31 | end 32 | 33 | context 'when a response is not returned from the handler' do 34 | it 'returns an empty response' do 35 | env = Rack::MockRequest.env_for('/') 36 | expect(handler).to receive(:call).and_return(nil) 37 | status, headers, body = app.call(env) 38 | expect(status).to eq 200 39 | expect(headers).to eq({}) 40 | expect(body).to eq [''] 41 | end 42 | end 43 | 44 | context 'when the handler raises a Slash::UnverifiedError' do 45 | it 'returns a 403' do 46 | env = Rack::MockRequest.env_for('/') 47 | expect(handler).to receive(:call).and_raise(Slash::UnverifiedError) 48 | status, headers, body = app.call(env) 49 | expect(status).to eq 403 50 | expect(headers).to eq({}) 51 | expect(body).to eq [''] 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/slash/response_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Slash::Response do 4 | describe '#to_json' do 5 | it 'returns a suitable json response for an incoming webhook' do 6 | message = Slack::Message.new(text: 'Hello World') 7 | 8 | response = Slash::Response.new(message: message) 9 | expect(response.to_json).to eq '{"text":"Hello World","attachments":[]}' 10 | 11 | response = Slash::Response.new(message: message, in_channel: true) 12 | expect(response.to_json).to eq '{"text":"Hello World","attachments":[],"response_type":"in_channel"}' 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/slash/router_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Slash::Router do 4 | describe '#match' do 5 | it 'adds a route that matches the text against the regular expression' do 6 | help = instance_double(Slash::Handler) 7 | lock = instance_double(Slash::Handler) 8 | 9 | router = Slash::Router.new 10 | router.match Slash.match_regexp(/^help$/), help 11 | router.match Slash.match_regexp(/^lock (?\S+?)$/), lock 12 | 13 | expect(help).to receive(:call) 14 | router.call('cmd' => Slash::Command.from_params('text' => 'help')) 15 | 16 | expect(lock).to receive(:call).with(hash_including('params' => { 'thing' => 'foo' })) 17 | router.call('cmd' => Slash::Command.from_params('text' => 'lock foo')) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/slashdeploy/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe SlashDeploy::Config do 4 | describe '#from_yaml' do 5 | it 'loads a config from yaml' do 6 | config = described_class.from_yaml <<-YAML 7 | environments: 8 | staging: 9 | aliases: 10 | - stage 11 | production: 12 | aliases: 13 | - prod 14 | continuous_delivery: 15 | ref: ref/heads/master 16 | YAML 17 | 18 | expect(config.environments.length).to eq 2 19 | expect(config.environments["staging"].aliases).to eq ["stage"] 20 | expect(config.environments["production"].continuous_delivery.ref).to eq "ref/heads/master" 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/omniauth.rb: -------------------------------------------------------------------------------- 1 | OmniAuth.config.logger = Logger.new(nil) 2 | OmniAuth.config.test_mode = true 3 | -------------------------------------------------------------------------------- /spec/support/sidekiq.rb: -------------------------------------------------------------------------------- 1 | # load sidekiq testing for our tests and set testing mode to fake. 2 | # Fake uses a queue object instead of talking to a real redis queue. 3 | require 'sidekiq/testing' 4 | Sidekiq::Testing.fake! 5 | -------------------------------------------------------------------------------- /spec/support/warden.rb: -------------------------------------------------------------------------------- 1 | class MockWarden 2 | attr_reader :user 3 | 4 | def authenticated? 5 | @user.present? 6 | end 7 | 8 | def set_user(user, *_args) 9 | @user = user 10 | end 11 | 12 | def logout 13 | @user = nil 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/validators/repository_validator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe RepositoryValidator do 4 | describe '.check' do 5 | it 'validates correctly' do 6 | expect(check('acme-inc/api')).to be_truthy 7 | expect(check('acme-inc/private_stacks')).to be_truthy 8 | expect(check('acme-inc/Thing')).to be_truthy 9 | expect(check('acme-inc/acme-inc.github.io')).to be_truthy 10 | expect(check('acme-inc/api@master')).to be_falsy 11 | expect(check('acme-inc/api:master')).to be_falsy 12 | expect(check('acme-inc')).to be_falsy 13 | expect(check('acme-inc')).to be_falsy 14 | end 15 | 16 | def check(repository) 17 | RepositoryValidator.check repository 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /vendor/assets/fonts/glyphicons/flat-ui-icons-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/glyphicons/flat-ui-icons-regular.eot -------------------------------------------------------------------------------- /vendor/assets/fonts/glyphicons/flat-ui-icons-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/glyphicons/flat-ui-icons-regular.ttf -------------------------------------------------------------------------------- /vendor/assets/fonts/glyphicons/flat-ui-icons-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/glyphicons/flat-ui-icons-regular.woff -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-black.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-black.eot -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-black.ttf -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-black.woff -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-bold.eot -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-bold.ttf -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-bold.woff -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-bolditalic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-bolditalic.eot -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-bolditalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-bolditalic.ttf -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-bolditalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-bolditalic.woff -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-italic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-italic.eot -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-italic.ttf -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-italic.woff -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-light.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-light.eot -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-light.ttf -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-light.woff -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-regular.eot -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-regular.ttf -------------------------------------------------------------------------------- /vendor/assets/fonts/lato/lato-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/fonts/lato/lato-regular.woff -------------------------------------------------------------------------------- /vendor/assets/javascripts/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/javascripts/.keep -------------------------------------------------------------------------------- /vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remind101/slashdeploy/342375402e40c424a1c87e190f612d68f77d163e/vendor/assets/stylesheets/.keep --------------------------------------------------------------------------------