├── .dockerignore ├── .github └── CONTRIBUTING.md ├── .gitignore ├── .travis.yml ├── CHANGES ├── CODE_OF_CONDUCT.md ├── COPYING ├── Dockerfile ├── Gemfile ├── README.md ├── Rakefile ├── config.ru ├── docker-compose.yml ├── docker └── jenkins │ ├── Dockerfile │ ├── disable-cli.groovy │ └── plugins.txt ├── janky.gemspec ├── lib ├── janky.rb └── janky │ ├── app.rb │ ├── branch.rb │ ├── build.rb │ ├── build_request.rb │ ├── builder.rb │ ├── builder │ ├── client.rb │ ├── http.rb │ ├── mock.rb │ ├── payload.rb │ ├── receiver.rb │ └── runner.rb │ ├── chat_service.rb │ ├── chat_service │ ├── campfire.rb │ ├── hipchat.rb │ ├── hubot.rb │ ├── mock.rb │ └── slack.rb │ ├── commit.rb │ ├── database │ ├── migrate │ │ ├── 1312115512_init.rb │ │ ├── 1312117285_non_unique_repo_uri.rb │ │ ├── 1312198807_repo_enabled.rb │ │ ├── 1313867551_add_build_output_column.rb │ │ ├── 1313871652_add_commit_url_column.rb │ │ ├── 1317384618_add_repo_hook_url.rb │ │ ├── 1317384619_add_build_room_id.rb │ │ ├── 1317384629_drop_default_room_id.rb │ │ ├── 1317384649_github_team_id.rb │ │ ├── 1317384650_add_build_indexes.rb │ │ ├── 1317384651_add_more_build_indexes.rb │ │ ├── 1317384652_change_commit_message_to_text.rb │ │ ├── 1317384653_add_build_pusher.rb │ │ ├── 1317384654_add_build_queued_at.rb │ │ ├── 1317384655_add_template.rb │ │ ├── 1398262033_add_context.rb │ │ └── 1400144784_change_room_id_to_string.rb │ ├── schema.rb │ └── seed.dump.gz │ ├── exception.rb │ ├── github.rb │ ├── github │ ├── api.rb │ ├── commit.rb │ ├── mock.rb │ ├── payload.rb │ ├── payload_parser.rb │ └── receiver.rb │ ├── helpers.rb │ ├── hubot.rb │ ├── job_creator.rb │ ├── notifier.rb │ ├── notifier │ ├── chat_service.rb │ ├── failure_service.rb │ ├── github_status.rb │ ├── mock.rb │ └── multi.rb │ ├── public │ ├── css │ │ └── base.css │ ├── images │ │ ├── building-bot.gif │ │ ├── disclosure-arrow.png │ │ ├── logo.png │ │ └── robawt-status.gif │ └── javascripts │ │ ├── application.js │ │ ├── jquery.js │ │ └── jquery.relatize.js │ ├── repository.rb │ ├── tasks.rb │ ├── templates │ ├── console.mustache │ ├── index.mustache │ └── layout.mustache │ ├── version.rb │ └── views │ ├── console.rb │ ├── index.rb │ └── layout.rb ├── script ├── bootstrap ├── cibuild ├── server ├── setup └── test └── test ├── commit_test.rb ├── custom.xml.erb ├── default.xml.erb ├── github_status_test.rb ├── janky_test.rb ├── repository_test.rb └── test_helper.rb /.dockerignore: -------------------------------------------------------------------------------- 1 | docker/jenkins 2 | .git 3 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: https://github.com/github/janky/fork 4 | [pr]: https://github.com/github/janky/compare 5 | [style]: https://github.com/styleguide/ruby 6 | [code-of-conduct]: CODE_OF_CONDUCT.md 7 | 8 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 9 | 10 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE.md). 11 | 12 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 13 | 14 | ## Submitting a pull request 15 | 16 | 0. [Fork][fork] and clone the repository 17 | 0. Configure and install the dependencies: `script/bootstrap` 18 | 0. Make sure the tests pass on your machine: `rake` 19 | 0. Create a new branch: `git checkout -b my-branch-name` 20 | 0. Make your change, add tests, and make sure the tests still pass 21 | 0. Push to your fork and [submit a pull request][pr] 22 | 0. Pat your self on the back and wait for your pull request to be reviewed and merged. 23 | 24 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 25 | 26 | - Follow the [style guide][style]. 27 | - Write tests. 28 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 29 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 30 | 31 | ## Resources 32 | 33 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 34 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 35 | - [GitHub Help](https://help.github.com) 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | bin 3 | vendor/gems 4 | Gemfile.lock 5 | *.gem 6 | .ruby-version 7 | vendor/bundle 8 | tags 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.3.5 4 | - 2.2.3 5 | - 2.1.7 6 | - 2.0.0 7 | - 1.9.3 8 | matrix: 9 | fast_finish: true 10 | allow_failures: 11 | - rvm: 2.3.5 12 | script: "./script/cibuild" 13 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | = 0.12.0 / 2017-06-05 2 | * Update yajl-ruby to work with Ruby 2.4 3 | * Add the ability to use MySQL 5.7 4 | * Update mocha to a more recent version 5 | * Allow DATABASE_URL to be specified for tests 6 | 7 | = 0.11.1 / 2015-02-20 8 | * Update gemspec to include the hubot chat service files 9 | * Update recommended Jenkins version in the README 10 | 11 | = 0.11.0 / 2014-11-14 12 | 13 | * Add support for Slack chat rooms 14 | * Convert chat room ids from integers to strings 15 | * Ensure build completion works with new Jenkins versions 16 | * Allow custom build templates to be provided when setting up projects 17 | * Mark builds that are queued in Jenkins as pending on GitHub 18 | * Delete and recreate hooks when setting up repositories 19 | * Add the ability to delete repos via `/ci delete` 20 | * Get detailed info about repos via `/ci show` 21 | * Send updates from Janky as a separate context to GitHub 22 | 23 | = 0.10.2 / 2013-10-02 24 | 25 | * Revert AR deprecation warnings 26 | * Revert previous configure jobs throwing error change 27 | 28 | = 0.10.1 / 2013-09-20 29 | 30 | * Refresh landing page every 5 seconds for updates 31 | * Previously configured jobs reconfigure instead of throwing errors 32 | * Add an API endpoint to get a json response full of build statuses 33 | * Fixup stupid AR deprecation warnings 34 | 35 | = 0.10.0 / 2013-09-19 36 | 37 | * Upgrade sinatra_auth_github to work with latest octokit 38 | 39 | = 0.9.15 / 2013-07-25 40 | 41 | * Upgrade sinatra_auth_github to make this work better with latest GitHub.com 42 | requirements 43 | 44 | * Truncate MD5 digests in job names to 12 characters 45 | 46 | = 0.9.14 / 2013-02-26 47 | 48 | * Many doc changes and improvements [Rob Sanheim] 49 | 50 | * Make the Jenkins job name human readable [Riley Guerin] 51 | 52 | * Added GithubStatus Notifier for GitHub Status API. 53 | See https://github.com/blog/1227-status-api [Rob Sainheim] 54 | 55 | * Added Build#queued_at for tracking when builds were queued vs actually 56 | built. [Simon Rozet] 57 | 58 | = 0.9.13 / 2012-06-24 59 | 60 | * Pull down the branch HEAD commit from the GitHub API for manual builds. 61 | This ensures that the latest code is built even when the webhook fails. 62 | [Jake Douglas, Simon Rozet] 63 | 64 | * Record the name of the user that triggered a build in the database. This 65 | is mostly useful for applications consuming the Janky API and isn't exposed 66 | anywhere in the UI. [Jake Douglas, Simon Rozet] 67 | 68 | * Use full SHA1s when interacting with other systems to avoid ambiguous 69 | commits. [Jake Douglas] 70 | 71 | * Deprecate support for Ruby < 1.9.3. [Simon Rozet] 72 | 73 | * Add missing database migrations that were wrongfully omitted from the 74 | 0.9.12 gem package. [Simon Rozet] 75 | 76 | = 0.9.12 / 2012-06-23 77 | 78 | * Upgrade OAuth authentication gem to use GitHub API v3. [Corey Donohoe] 79 | 80 | * Upgrade to ActiveRecord 3.2.X. [Simon Rozet] 81 | 82 | * Allow configuring the path to the database socket with JANKY_DATABASE_SOCKET 83 | setting. [Simon Rozet] 84 | 85 | * Add a few indexes on the builds table to improve performance. [Simon Rozet]. 86 | 87 | * Eager load the builds.output column to improve performance. [Simon Rozet, 88 | Jesse Dearing, Rafael Mendonça França] 89 | 90 | * Fix deprecation logic for Campfire settings. [Piet Jaspers] 91 | 92 | * Destroy all associated records after destroying a repository record. [Eric 93 | Holmes] 94 | 95 | * Handle commit messages longer than 255 characters. [Shay Frendt] 96 | 97 | = 0.9.11 / 2011-03-01 98 | 99 | * Fix HipChat setup instructions. [Andre Sachs] 100 | 101 | * Fix OAuth authentication bug introduced in version 0.9.9. [Lucas Mazza] 102 | 103 | * Fix `db:migrate` Rake task on Heroku when using HipChat. See the updated 104 | gist at https://gist.github.com/1497335. [Simon Rozet] 105 | 106 | = 0.9.10 / 2011-02-11 107 | 108 | * Fix an issue where Campfire settings are overridden on Heroku [Simon Rozet] 109 | 110 | = 0.9.9 / 2011-02-11 111 | 112 | * HipChat support [Justin Smestad, Seth Chisamore, Simon Rozet] 113 | 114 | * Support for GitHub Enterprise. [Dusty Burwell, Simon Rozet] 115 | 116 | * Support for GitHub logins containing dashes. [Thom May] 117 | 118 | * Support for branches containing slashes in their name. [Andres Torres] 119 | 120 | * Respond with proper status code to invalid Jenkins notification 121 | requests. [Chris Mytton] 122 | 123 | * Support for Jenkins servers running behind SSL. [Vivek Pandey, 124 | Gavin Heavyside, Thom May] 125 | 126 | * Support for Jenkins servers running under a path [Piet Jaspers] 127 | 128 | * Deprecate JANKY_CAMPFIRE_ACCOUNT. Please use JANKY_CHAT_CAMPFIRE_ACCOUNT 129 | instead. [Simon Rozet] 130 | 131 | * Deprecate JANKY_CAMPFIRE_TOKEN. Please use JANKY_CHAT_CAMPFIRE_TOKEN 132 | instead. [Simon Rozet] 133 | 134 | * Deprecate JANKY_CAMPFIRE_DEFAULT_ROOM. Please use 135 | JANKY_CHAT_DEFAULT_ROOM instead. [Simon Rozet] 136 | 137 | * Both JANKY_BASE_URL and JANKY_DEFAULT_BUILDER now require a trailing 138 | slash. [Simon Rozet] 139 | 140 | = 0.9 / 2011-12-19 141 | 142 | * Initial public release. 143 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at codemattr@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | 78 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2012 GitHub, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.4 2 | 3 | RUN touch /etc/app-env 4 | 5 | RUN sed -i '/jessie-updates/d' /etc/apt/sources.list 6 | 7 | RUN apt-key adv --recv-keys --keyserver keyserver.ubuntu.com 5072E1F5 && \ 8 | echo "deb http://repo.mysql.com/apt/debian/ jessie mysql-5.7" > /etc/apt/sources.list.d/mysql.list && \ 9 | apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs libmysqlclient-dev mysql-client 10 | 11 | WORKDIR /app 12 | 13 | COPY . . 14 | RUN mkdir /app/log && bundle install -j2 --binstubs 15 | 16 | EXPOSE 9393 17 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | gemspec 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Janky 2 | ===== 3 | 4 | **As of April 2022, this repository is no longer used or maintained.** 5 | 6 | ---- 7 | 8 | This is Janky, a continuous integration server built on top of 9 | [Jenkins][], controlled by [Hubot][], and designed for [GitHub][]. 10 | 11 | * **Built on top of Jenkins.** The power, vast amount of plugins and large 12 | community of the popular CI server all wrapped up in a great experience. 13 | 14 | * **Controlled by Hubot.** Day to day operations are exposed as simple 15 | Hubot commands that the whole team can use. 16 | 17 | * **Designed for GitHub.** Janky creates the appropriate [web hooks][w] for 18 | you and the web app restricts access to members of your GitHub organization. 19 | 20 | [GitHub]: https://github.com 21 | [Hubot]: http://hubot.github.com 22 | [Jenkins]: http://jenkins-ci.org 23 | [w]: http://developer.github.com/v3/repos/hooks/ 24 | 25 | Hubot usage 26 | ----------- 27 | 28 | Start by setting up a new Jenkins job and GitHub web hook for a 29 | repository: `[ORG]/[REPO]` 30 | 31 | hubot ci setup github/janky 32 | 33 | The `setup` command can safely be run over and over again. It won't do 34 | anything unless it needs to. It takes an optional `name` argument: `[ORG]/[REPO] [NAME]` 35 | 36 | hubot ci setup github/janky janky-ruby1.9.2 37 | 38 | It also takes an optional `template` argument: `[ORG]/[REPO] [NAME] [TEMPLATE]` 39 | 40 | hubot ci setup github/janky janky-ruby1.9.2 ruby-build 41 | 42 | All branches are built automatically on push. Disable auto build with: 43 | 44 | hubot ci toggle [REPO] 45 | 46 | **NOTE**: If `name` was set you'll need to use it intested. 47 | 48 | hubot ci toggle [NAME] 49 | 50 | Run the command again to re-enable it. Force a build of the master 51 | branch: 52 | 53 | hubot ci build [REPO] 54 | 55 | **NOTE**: If `name` was set you'll need to use it intested. 56 | 57 | hubot ci build [NAME] 58 | 59 | Of a specific branch: `[REPO]/[BRANCH]` 60 | 61 | hubot ci build janky/libgit2 62 | 63 | Different builds aren't relevant to the same chat room and so Janky 64 | lets you choose where notifications are sent to. First get a list of 65 | available rooms: 66 | 67 | hubot ci rooms 68 | 69 | Then pick one: 70 | 71 | hubot ci set room janky The Serious Room 72 | 73 | Get the status of a build: 74 | 75 | hubot ci status janky 76 | 77 | Specific branch: `[REPO]/[BRANCH]` 78 | 79 | hubot ci status janky/libgit2 80 | 81 | All builds: 82 | 83 | hubot ci status 84 | 85 | Finally, get a quick reference of the available commands with: 86 | 87 | hubot ci? 88 | 89 | Installing 90 | ---------- 91 | 92 | ### Jenkins 93 | 94 | Janky requires access to a Jenkins server. Version **1.580** is 95 | recommended. Refer to the Jenkins [documentation][doc] for installation 96 | instructions and install the [Notification Plugin][np] version 1.4. 97 | 98 | Remember to set the Jenkins URL in `http://your-jenkins-server.com/configure`. 99 | Janky will still trigger builds but will not update the build status without this set. 100 | 101 | [doc]: https://wiki.jenkins-ci.org/display/JENKINS/Installing+Jenkins 102 | [np]: https://wiki.jenkins-ci.org/display/JENKINS/Notification+Plugin 103 | 104 | ### Deploying 105 | 106 | Janky is designed to be deployed to [Heroku](https://heroku.com). 107 | 108 | Grab all the necessary files from [the gist][gist]: 109 | 110 | $ git clone git://gist.github.com/1497335 janky 111 | 112 | Then push it up to a new Heroku app: 113 | 114 | $ cd janky 115 | $ heroku create --stack cedar 116 | $ bundle install 117 | $ git add Gemfile.lock 118 | $ git commit Gemfile.lock -m "lock bundle" 119 | $ git push heroku master 120 | 121 | After configuring the app (see below), create the database: 122 | 123 | $ heroku run rake db:migrate 124 | 125 | **NOTE:** Ruby version 2.0.0+ is required to run Janky. 126 | 127 | [gist]: https://gist.github.com/1497335 128 | 129 | Upgrading 130 | --------- 131 | 132 | We **strongly recommend** backing up your Janky database before upgrading. 133 | 134 | The general process is to then upgrade the gem, and then run migrate. Here is how 135 | you do that on a local box you have access to (this process will differ for Heroku): 136 | 137 | cd [PATH-TO-JANKY] 138 | gem update janky 139 | rake db:migrate 140 | 141 | Configuring 142 | ----------- 143 | 144 | Janky is configured using environment variables. Use the `heroku config` 145 | command: 146 | 147 | $ heroku config:add VARIABLE=value 148 | 149 | Required settings: 150 | 151 | * `JANKY_BASE_URL`: The application URL **with** a trailing slash. Example: 152 | `http://mf-doom-42.herokuapp.com/`. 153 | * `JANKY_BUILDER_DEFAULT`: The Jenkins server URL **with** a trailing slash. 154 | Example: `http://jenkins.example.com/`. For basic auth, include the 155 | credentials in the URL: `http://user:pass@jenkins.example.com/`. 156 | Using GitHub OAuth with Jenkins is not supported by Janky. 157 | * `JANKY_CONFIG_DIR`: Directory where build config templates are stored. 158 | Typically set to `/app/config` on Heroku. 159 | * `JANKY_HUBOT_USER`: Login used to protect the Hubot API. 160 | * `JANKY_HUBOT_PASSWORD`: Password for the Hubot API. 161 | * `JANKY_GITHUB_USER`: The login of the GitHub user used to access the 162 | API. Requires Administrative privileges to set up service hooks. 163 | * `JANKY_GITHUB_PASSWORD`: The password for the GitHub user. 164 | * `JANKY_GITHUB_HOOK_SECRET`: Secret used to sign hook requests from 165 | GitHub. 166 | * `JANKY_CHAT_DEFAULT_ROOM`: Chat room where notifications are sent by default. 167 | 168 | Optional database settings: 169 | 170 | * `DATABASE_URL`: Database connection URL. Example: 171 | `postgres://user:password@host:port/db_name`. 172 | * `JANKY_DATABASE_SOCKET`: Path to the database socket. Example: 173 | `/var/run/mysql5/mysqld.sock`. 174 | 175 | ### GitHub Enterprise 176 | 177 | Using Janky with [GitHub Enterprise][ghe] requires one extra setting: 178 | 179 | * `JANKY_GITHUB_API_URL`: Full API URL of the instance, *with* a trailing 180 | slash. Example: `https://github.example.com/api/v3/`. 181 | 182 | [ghe]: https://enterprise.github.com 183 | 184 | ### GitHub Status API 185 | 186 | https://github.com/blog/1227-commit-status-api 187 | 188 | To update pull requests with the build status generate an OAuth token 189 | via the GitHub API: 190 | 191 | curl -u username:password \ 192 | -d '{ "scopes": [ "repo:status" ], "note": "janky" }' \ 193 | https://api.github.com/authorizations 194 | 195 | then set `JANKY_GITHUB_STATUS_TOKEN`. Optionally, you can also set 196 | `JANKY_GITHUB_STATUS_CONTEXT` to send a context to the GitHub API by 197 | default 198 | 199 | `username` and `password` in the above example should be the same as the 200 | values provided for `JANKY_GITHUB_USER` and `JANKY_GITHUB_PASSWORD` 201 | respectively. 202 | 203 | ### Chat notifications 204 | 205 | #### HipChat 206 | 207 | Required settings: 208 | 209 | * `JANKY_CHAT=hipchat` 210 | * `JANKY_CHAT_HIPCHAT_TOKEN`: authentication token (This token needs to be an 211 | admin token, not a notification token.) 212 | * `JANKY_CHAT_HIPCHAT_FROM`: name that messages will appear be sent from. 213 | Defaults to `CI`. 214 | * `JANKY_HUBOT_USER` should be XMPP/Jabber username in format xxxxx_xxxxxx 215 | rather than email 216 | * `JANKY_CHAT_DEFAULT_ROOM` should be the name of the room instead of the 217 | XMPP format, for example: `Engineers` instead of xxxx_xxxxxx. 218 | 219 | Installation: 220 | 221 | * Add `require "janky/chat_service/hipchat"` to the `config/environment.rb` 222 | file **before** the `Janky.setup(ENV)` line. 223 | * `echo 'gem "hipchat", "~>0.4"' >> Gemfile` 224 | * `bundle` 225 | * `git commit -am "install hipchat"` 226 | 227 | #### Slack 228 | 229 | Required settings: 230 | 231 | * `JANKY_CHAT=slack` 232 | * `JANKY_CHAT_SLACK_TEAM`: slack team name 233 | * `JANKY_CHAT_SLACK_TOKEN`: authentication token for the user sending build notifications. 234 | * `JANKY_CHAT_SLACK_USERNAME`: name that messages will appear be sent from. 235 | Defaults to `CI`. 236 | * `JANKY_CHAT_SLACK_ICON_URL`: URL to an image to use as the icon for this message. 237 | 238 | Installation: 239 | 240 | * Add `require "janky/chat_service/slack"` to the `config/environment.rb` 241 | file **before** the `Janky.setup(ENV)` line. 242 | * `echo 'gem "slack.rb"' >> Gemfile` 243 | * `bundle` 244 | * `git commit -am "install slack"` 245 | 246 | #### Hubot 247 | 248 | Sends notifications to Hubot via [janky script](http://git.io/hubot-janky). 249 | 250 | Required settings: 251 | 252 | * `JANKY_CHAT=hubot` 253 | * `JANKY_CHAT_HUBOT_URL`: URL to your Hubot instance. 254 | * `JANKY_CHAT_HUBOT_ROOMS`: List of rooms which can be set via `ci set room`. 255 | * For IRC: Comma-separated list of channels `"#room, #another-room"` 256 | * For Campfire/HipChat: List with room id and name `"34343:room, 23223:another-room"` 257 | * For Slack: List with room names `"room, another-room"` 258 | 259 | Installation: 260 | * Add `require "janky/chat_service/hubot"` to the `config/environment.rb` 261 | file **before** the `Janky.setup(ENV)` line. 262 | 263 | ### Authentication 264 | 265 | To restrict access to members of a GitHub organization, [register a new 266 | OAuth application on GitHub](https://github.com/settings/applications) 267 | with the callback set to `$JANKY_BASE_URL/auth/github/callback` then set 268 | a few extra settings: 269 | 270 | * `JANKY_SESSION_SECRET`: Random session cookie secret. Typically 271 | generated by a tool like `pwgen`. 272 | * `JANKY_AUTH_CLIENT_ID`: The client ID of the OAuth application. 273 | * `JANKY_AUTH_CLIENT_SECRET`: The client secret of the OAuth application. 274 | * `JANKY_AUTH_ORGANIZATION`: The organization name. Example: "github". 275 | * `JANKY_AUTH_TEAM_ID`: An optional team ID to give auth to. Example: "1234". 276 | 277 | ### Hubot 278 | 279 | Install the [janky script](http://git.io/hubot-janky) in your Hubot 280 | then set the `HUBOT_JANKY_URL` environment variable. Example: 281 | `http://user:password@janky.example.com/_hubot/`, with user and password 282 | replaced by `JANKY_HUBOT_USER` and `JANKY_HUBOT_PASSWORD` respectively. 283 | 284 | ### Custom build configuration 285 | 286 | The default build command should suffice for most Ruby applications: 287 | 288 | $ bundle install --path vendor/gems --binstubs 289 | $ bundle exec rake 290 | 291 | For more control you can add a `script/cibuild` at the root of your 292 | repository for Jenkins to execute instead. 293 | 294 | For total control, whole Jenkins' `config.xml` files can be associated 295 | with Janky builds. Given a build called `windows` and a template name 296 | of `psake`, Janky will try `config/jobs/psake.xml.erb` to use a template, 297 | `config/jobs/windows.xml.erb` to try the job name if the template does 298 | not exit, before finally falling back to the default 299 | configuration, `config/jobs/default.xml.erb`. After updating or adding 300 | a custom config, run `hubot ci setup` again to update the Jenkins 301 | server. 302 | 303 | Hacking 304 | ------- 305 | 306 | Docker and docker-compose are required for hacking on this project. 307 | 308 | Get your environment up and running: 309 | 310 | script/bootstrap 311 | 312 | Create the databases, tables, and seed data: 313 | 314 | script/setup 315 | 316 | Start the server: 317 | 318 | docker-compose run --service-ports app script/server 319 | 320 | Open the app: 321 | 322 | open http://localhost:9393/ 323 | 324 | Run the test suite: 325 | 326 | docker-compose run --rm app script/test 327 | 328 | Contributing 329 | ------------ 330 | 331 | Fork the [Janky repository on GitHub](https://github.com/github/janky) and 332 | send a Pull Request. Note that any changes to behavior without tests will 333 | be rejected. If you are adding significant new features, please add both 334 | tests and documentation. 335 | 336 | Maintainers 337 | ----------- 338 | 339 | * [@mattr-](https://github.com/mattr-) 340 | 341 | Copying 342 | ------- 343 | 344 | Copyright © 2011-2014, GitHub, Inc. See the `COPYING` file for license 345 | rights and limitations (MIT). 346 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.expand_path("../lib", __FILE__)) 2 | ENV["RACK_ENV"] ||= "development" 3 | 4 | require "janky" 5 | 6 | class ActiveRecord::ConnectionAdapters::Mysql2Adapter 7 | NATIVE_DATABASE_TYPES[:primary_key] = "int(11) auto_increment PRIMARY KEY" 8 | end 9 | Janky.setup(ENV) 10 | require "janky/tasks" 11 | 12 | task "db:seed" do 13 | if ENV["RACK_ENV"] != "development" 14 | fail "refusing to load seed data into non-development database" 15 | end 16 | 17 | dump = File.expand_path("../lib/janky/database/seed.dump.gz", __FILE__) 18 | 19 | Replicate::Loader.new do |loader| 20 | loader.log_to $stderr, false, false 21 | loader.read Zlib::GzipReader.open(dump) 22 | end 23 | end 24 | 25 | task :test do 26 | abort "Use script/test to run the test suite." 27 | end 28 | task :default => :test 29 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require "janky" 2 | Janky.setup(ENV) 3 | run Janky.app 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | services: 3 | db: 4 | image: mysql:5.7 5 | command: --sql_mode="NO_ENGINE_SUBSTITUTION" 6 | environment: 7 | MYSQL_ALLOW_EMPTY_PASSWORD: 1 8 | volumes: 9 | - "db-data:/var/lib/mysql" 10 | jenkins: 11 | build: 12 | context: docker/jenkins 13 | dockerfile: Dockerfile 14 | volumes: 15 | - "jenkins-data:/var/jenkins_home" 16 | app: 17 | build: 18 | context: . 19 | image: janky-app 20 | command: 'script/server' 21 | environment: 22 | - RACK_ENV 23 | - RACK_ROOT 24 | - JANKY_BASE_URL 25 | volumes: 26 | - "${VOLUME:-.:/app}" 27 | ports: 28 | - "9393:9393" 29 | depends_on: 30 | - db 31 | - jenkins 32 | volumes: 33 | db-data: 34 | jenkins-data: 35 | -------------------------------------------------------------------------------- /docker/jenkins/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jenkins/jenkins:lts 2 | 3 | COPY plugins.txt /usr/share/jenkins/ref/plugins.txt 4 | COPY disable-cli.groovy /usr/share/jenkins/ref/init.groovy.d/disable-cli.groovy 5 | 6 | RUN /usr/local/bin/install-plugins.sh < /usr/share/jenkins/ref/plugins.txt 7 | 8 | # disable the first start wizard and the additional plugin warning 9 | ENV JAVA_OPTS="-Djenkins.install.runSetupWizard=false" 10 | RUN echo "2.0" > /usr/share/jenkins/ref/jenkins.install.UpgradeWizard.state 11 | -------------------------------------------------------------------------------- /docker/jenkins/disable-cli.groovy: -------------------------------------------------------------------------------- 1 | # Disable the Jenkins CLI. 2 | # 3 | # It's generally a security risk and for Janky, we'll do it all over the API anyways 4 | # 5 | # Original code from: https://support.cloudbees.com/hc/en-us/articles/234709648-Disable-Jenkins-CLI 6 | 7 | import jenkins.AgentProtocol 8 | import jenkins.model.Jenkins 9 | import hudson.model.RootAction 10 | import java.util.logging.Logger 11 | 12 | Logger logger = Logger.getLogger("disable-cli.groovy") 13 | logger.info("Disabling the Jenkins CLI...") 14 | 15 | // disabled CLI access over TCP listener (separate port) 16 | def protocols = AgentProtocol.all() 17 | protocols.each { protocol -> 18 | if (protocol.name?.contains("CLI")) { 19 | logger.info("Removing protocol ${protocol.name}") 20 | protocols.remove(protocol) 21 | } 22 | } 23 | 24 | // disable CLI access over /cli URL 25 | def removal = { extensions -> 26 | extensions.each { extension -> 27 | if (extension.getClass().name.contains("CLIAction")) { 28 | logger.info("Removing extension ${extension.getClass().name}") 29 | extensions.remove(extension) 30 | } 31 | } 32 | } 33 | def jenkins = Jenkins.instance 34 | removal(jenkins.getExtensionList(RootAction.class)) 35 | removal(jenkins.actions) 36 | logger.info("CLI disabled") 37 | -------------------------------------------------------------------------------- /docker/jenkins/plugins.txt: -------------------------------------------------------------------------------- 1 | notification 2 | -------------------------------------------------------------------------------- /janky.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path("../lib/janky/version", __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "janky" 5 | s.version = Janky::VERSION 6 | s.description = "Janky is a Continuous Integration server" 7 | s.summary = "Continuous Integration server built on top of Jenkins and " \ 8 | "designed for GitHub and Hubot" 9 | s.authors = ["Simon Rozet", "Matt Rogers"] 10 | s.email = 'codemattr@gmail.com' 11 | s.homepage = "https://github.com/github/janky" 12 | s.license = "MIT" 13 | 14 | s.post_install_message = <<-EOL 15 | If you are upgrading from Janky 0.9.13, you will want to add a JANKY_BRANCH parameter 16 | to your config/default.xml.erb. See 17 | https://github.com/github/janky/commit/0fc6214e3a75cc138aed46a2493980440e848aa3#commitcomment-1815400 for details. 18 | EOL 19 | 20 | # runtime 21 | s.add_dependency "rake", "~>12.0" 22 | s.add_dependency "sinatra", "~>1.3" 23 | s.add_dependency "sinatra_auth_github", "~>1.0.0" 24 | s.add_dependency "mustache", "~>0.11" 25 | s.add_dependency "yajl-ruby", "~>1.3.1" 26 | s.add_dependency "activerecord", "~>4.2.0" 27 | s.add_dependency "activerecord-deprecated_finders", "~>1.0.4" 28 | s.add_dependency "broach", "~>0.2" 29 | s.add_dependency "replicate", "~>1.4" 30 | 31 | # development 32 | s.add_development_dependency "shotgun", "~>0.9" 33 | s.add_development_dependency "thin", "~>1.2" 34 | s.add_development_dependency "mysql2", "~>0.3.0" 35 | s.add_development_dependency "test-unit", "~>3.2.0" 36 | 37 | # test 38 | s.add_development_dependency "database_cleaner", "1.6.2" 39 | s.add_development_dependency "mocha", "~>1.5.0" 40 | 41 | s.files = %w[ 42 | CHANGES 43 | COPYING 44 | Gemfile 45 | README.md 46 | Rakefile 47 | config.ru 48 | janky.gemspec 49 | lib/janky.rb 50 | lib/janky/app.rb 51 | lib/janky/branch.rb 52 | lib/janky/build.rb 53 | lib/janky/build_request.rb 54 | lib/janky/builder.rb 55 | lib/janky/builder/client.rb 56 | lib/janky/builder/http.rb 57 | lib/janky/builder/mock.rb 58 | lib/janky/builder/payload.rb 59 | lib/janky/builder/receiver.rb 60 | lib/janky/builder/runner.rb 61 | lib/janky/chat_service.rb 62 | lib/janky/chat_service/campfire.rb 63 | lib/janky/chat_service/hipchat.rb 64 | lib/janky/chat_service/hubot.rb 65 | lib/janky/chat_service/slack.rb 66 | lib/janky/chat_service/mock.rb 67 | lib/janky/commit.rb 68 | lib/janky/database/migrate/1312115512_init.rb 69 | lib/janky/database/migrate/1312117285_non_unique_repo_uri.rb 70 | lib/janky/database/migrate/1312198807_repo_enabled.rb 71 | lib/janky/database/migrate/1313867551_add_build_output_column.rb 72 | lib/janky/database/migrate/1313871652_add_commit_url_column.rb 73 | lib/janky/database/migrate/1317384618_add_repo_hook_url.rb 74 | lib/janky/database/migrate/1317384619_add_build_room_id.rb 75 | lib/janky/database/migrate/1317384629_drop_default_room_id.rb 76 | lib/janky/database/migrate/1317384649_github_team_id.rb 77 | lib/janky/database/migrate/1317384650_add_build_indexes.rb 78 | lib/janky/database/migrate/1317384651_add_more_build_indexes.rb 79 | lib/janky/database/migrate/1317384652_change_commit_message_to_text.rb 80 | lib/janky/database/migrate/1317384653_add_build_pusher.rb 81 | lib/janky/database/migrate/1317384654_add_build_queued_at.rb 82 | lib/janky/database/migrate/1317384655_add_template.rb 83 | lib/janky/database/migrate/1398262033_add_context.rb 84 | lib/janky/database/migrate/1400144784_change_room_id_to_string.rb 85 | lib/janky/database/schema.rb 86 | lib/janky/database/seed.dump.gz 87 | lib/janky/exception.rb 88 | lib/janky/github.rb 89 | lib/janky/github/api.rb 90 | lib/janky/github/commit.rb 91 | lib/janky/github/mock.rb 92 | lib/janky/github/payload.rb 93 | lib/janky/github/payload_parser.rb 94 | lib/janky/github/receiver.rb 95 | lib/janky/helpers.rb 96 | lib/janky/hubot.rb 97 | lib/janky/job_creator.rb 98 | lib/janky/notifier.rb 99 | lib/janky/notifier/chat_service.rb 100 | lib/janky/notifier/failure_service.rb 101 | lib/janky/notifier/github_status.rb 102 | lib/janky/notifier/mock.rb 103 | lib/janky/notifier/multi.rb 104 | lib/janky/public/css/base.css 105 | lib/janky/public/images/building-bot.gif 106 | lib/janky/public/images/disclosure-arrow.png 107 | lib/janky/public/images/logo.png 108 | lib/janky/public/images/robawt-status.gif 109 | lib/janky/public/javascripts/application.js 110 | lib/janky/public/javascripts/jquery.js 111 | lib/janky/public/javascripts/jquery.relatize.js 112 | lib/janky/repository.rb 113 | lib/janky/tasks.rb 114 | lib/janky/templates/console.mustache 115 | lib/janky/templates/index.mustache 116 | lib/janky/templates/layout.mustache 117 | lib/janky/version.rb 118 | lib/janky/views/console.rb 119 | lib/janky/views/index.rb 120 | lib/janky/views/layout.rb 121 | ] 122 | 123 | s.test_files = %w[ 124 | test/default.xml.erb 125 | test/janky_test.rb 126 | test/test_helper.rb 127 | ] 128 | end 129 | -------------------------------------------------------------------------------- /lib/janky.rb: -------------------------------------------------------------------------------- 1 | if RUBY_VERSION < "1.9.3" 2 | warn "Support for Ruby versions lesser than 1.9.3 is deprecated and will be " \ 3 | "removed in Janky 1.0." 4 | end 5 | 6 | require "net/http" 7 | require "digest/md5" 8 | 9 | require "active_record" 10 | require "replicate" 11 | require "sinatra/base" 12 | require "mustache/sinatra" 13 | require "yajl" 14 | require "yajl/json_gem" 15 | require "tilt" 16 | require "broach" 17 | require "sinatra/auth/github" 18 | 19 | require "janky/repository" 20 | require "janky/branch" 21 | require "janky/commit" 22 | require "janky/build" 23 | require "janky/build_request" 24 | require "janky/github" 25 | require "janky/github/api" 26 | require "janky/github/mock" 27 | require "janky/github/payload" 28 | require "janky/github/commit" 29 | require "janky/github/payload_parser" 30 | require "janky/github/receiver" 31 | require "janky/job_creator" 32 | require "janky/helpers" 33 | require "janky/hubot" 34 | require "janky/builder" 35 | require "janky/builder/client" 36 | require "janky/builder/runner" 37 | require "janky/builder/http" 38 | require "janky/builder/mock" 39 | require "janky/builder/payload" 40 | require "janky/builder/receiver" 41 | require "janky/chat_service" 42 | require "janky/chat_service/campfire" 43 | require "janky/chat_service/mock" 44 | require "janky/exception" 45 | require "janky/notifier" 46 | require "janky/notifier/chat_service" 47 | require "janky/notifier/failure_service" 48 | require "janky/notifier/mock" 49 | require "janky/notifier/multi" 50 | require "janky/notifier/github_status" 51 | require "janky/app" 52 | require "janky/views/layout" 53 | require "janky/views/index" 54 | require "janky/views/console" 55 | 56 | # TODO Remove after upgrading to activerecord 4.x 57 | require "active_record/connection_adapters/mysql2_adapter" 58 | class ActiveRecord::ConnectionAdapters::Mysql2Adapter 59 | NATIVE_DATABASE_TYPES[:primary_key] = "int(11) auto_increment PRIMARY KEY" 60 | end 61 | 62 | # This is Janky, a continuous integration server. Checkout the 'app' 63 | # method on this module for an overview of the different components 64 | # involved. 65 | module Janky 66 | # The base exception class raised when errors are encountered. 67 | class Error < StandardError; end 68 | 69 | # Setup the application, including the database and Jenkins connections. 70 | # 71 | # settings - Hash of app settings. Typically ENV but any object that responds 72 | # to #[], #[]= and #each is valid. See required_settings for 73 | # required keys. The RACK_ENV key is always required. 74 | # 75 | # Raises an Error when required settings are missing. 76 | # Returns nothing. 77 | def self.setup(settings) 78 | env = settings["RACK_ENV"] 79 | if env.nil? || env.empty? 80 | raise Error, "RACK_ENV is required" 81 | end 82 | 83 | required_settings.each do |setting| 84 | next if !settings[setting].nil? && !settings[setting].empty? 85 | 86 | if env == "production" 87 | raise Error, "#{setting} setting is required" 88 | end 89 | end 90 | 91 | if env != "production" 92 | settings["DATABASE_URL"] ||= "mysql2://root@db/janky_#{env}" 93 | settings["JANKY_BASE_URL"] ||= "http://localhost:9393/" 94 | settings["JANKY_BUILDER_DEFAULT"] ||= "http://localhost:8080/" 95 | settings["JANKY_CONFIG_DIR"] ||= File.dirname(__FILE__) 96 | settings["JANKY_CHAT"] ||= "campfire" 97 | settings["JANKY_CHAT_CAMPFIRE_ACCOUNT"] ||= "account" 98 | settings["JANKY_CHAT_CAMPFIRE_TOKEN"] ||= "token" 99 | end 100 | 101 | database = URI(settings["DATABASE_URL"]) 102 | adapter = database.scheme == "postgres" ? "postgresql" : database.scheme 103 | encoding = database.scheme == "postgres" ? "unicode" : "utf8" 104 | base_url = URI(settings["JANKY_BASE_URL"]).to_s 105 | Build.base_url = base_url 106 | 107 | connection = { 108 | :adapter => adapter, 109 | :encoding => encoding, 110 | :pool => 5, 111 | :database => database.path[1..-1], 112 | :username => database.user, 113 | :password => database.password, 114 | :host => database.host, 115 | :port => database.port, 116 | :reconnect => true, 117 | } 118 | if socket = settings["JANKY_DATABASE_SOCKET"] 119 | connection[:socket] = socket 120 | end 121 | ActiveRecord::Base.establish_connection(connection) 122 | 123 | self.jobs_config_dir = config_dir = Pathname(settings["JANKY_CONFIG_DIR"]) 124 | if !config_dir.directory? 125 | raise Error, "#{config_dir} is not a directory" 126 | end 127 | 128 | # Setup the callback URL of this Janky host. 129 | Janky::Builder.setup(base_url + "_builder") 130 | 131 | # Setup the default Jenkins build host 132 | if settings["JANKY_BUILDER_DEFAULT"][-1] != ?/ 133 | raise Error, "JANKY_BUILDER_DEFAULT must have a trailing slash" 134 | end 135 | Janky::Builder[:default] = settings["JANKY_BUILDER_DEFAULT"] 136 | 137 | if settings.key?("JANKY_GITHUB_API_URL") 138 | api_url = settings["JANKY_GITHUB_API_URL"] 139 | git_host = URI(api_url).host 140 | else 141 | api_url = "https://api.github.com/" 142 | git_host = "github.com" 143 | end 144 | if api_url[-1] != ?/ 145 | raise Error, "JANKY_GITHUB_API_URL must have a trailing slash" 146 | end 147 | hook_url = base_url + "_github" 148 | Janky::GitHub.setup( 149 | settings["JANKY_GITHUB_USER"], 150 | settings["JANKY_GITHUB_PASSWORD"], 151 | settings["JANKY_GITHUB_HOOK_SECRET"], 152 | hook_url, 153 | api_url, 154 | git_host 155 | ) 156 | 157 | if settings.key?("JANKY_SESSION_SECRET") 158 | Janky::App.register Sinatra::Auth::Github 159 | Janky::App.set({ 160 | :sessions => true, 161 | :session_secret => settings["JANKY_SESSION_SECRET"], 162 | :github_team_id => settings["JANKY_AUTH_TEAM_ID"], 163 | :github_organization => settings["JANKY_AUTH_ORGANIZATION"], 164 | :github_options => { 165 | :secret => settings["JANKY_AUTH_CLIENT_SECRET"], 166 | :client_id => settings["JANKY_AUTH_CLIENT_ID"], 167 | :scopes => "repo", 168 | }, 169 | }) 170 | end 171 | 172 | Janky::Hubot.set( 173 | :base_url => settings["JANKY_BASE_URL"], 174 | :username => settings["JANKY_HUBOT_USER"], 175 | :password => settings["JANKY_HUBOT_PASSWORD"] 176 | ) 177 | 178 | Janky::Exception.setup(Janky::Exception::Logger.new($stderr)) 179 | 180 | if campfire_account = settings["JANKY_CAMPFIRE_ACCOUNT"] 181 | warn "JANKY_CAMPFIRE_ACCOUNT is deprecated. Please use " \ 182 | "JANKY_CHAT_CAMPFIRE_ACCOUNT instead." 183 | settings["JANKY_CHAT_CAMPFIRE_ACCOUNT"] ||= 184 | settings["JANKY_CAMPFIRE_ACCOUNT"] 185 | end 186 | 187 | if campfire_token = settings["JANKY_CAMPFIRE_TOKEN"] 188 | warn "JANKY_CAMPFIRE_TOKEN is deprecated. Please use " \ 189 | "JANKY_CHAT_CAMPFIRE_TOKEN instead." 190 | settings["JANKY_CHAT_CAMPFIRE_TOKEN"] ||= 191 | settings["JANKY_CAMPFIRE_TOKEN"] 192 | end 193 | 194 | chat_name = settings["JANKY_CHAT"] || "campfire" 195 | chat_settings = {} 196 | settings.each do |key, value| 197 | if key =~ /^JANKY_CHAT_#{chat_name.upcase}_/ 198 | chat_settings[key] = value 199 | end 200 | end 201 | chat_room = settings["JANKY_CHAT_DEFAULT_ROOM"] || 202 | settings["JANKY_CAMPFIRE_DEFAULT_ROOM"] 203 | if settings["JANKY_CAMPFIRE_DEFAULT_ROOM"] 204 | warn "JANKY_CAMPFIRE_DEFAULT_ROOM is deprecated. Please use " \ 205 | "JANKY_CHAT_DEFAULT_ROOM instead." 206 | end 207 | ChatService.setup(chat_name, chat_settings, chat_room) 208 | 209 | if token = settings["JANKY_GITHUB_STATUS_TOKEN"] 210 | context = settings["JANKY_GITHUB_STATUS_CONTEXT"] 211 | Notifier.setup([ 212 | Notifier::GithubStatus.new(token, api_url, context), 213 | Notifier::ChatService, 214 | Notifier::FailureService 215 | ]) 216 | else 217 | Notifier.setup(Notifier::ChatService) 218 | end 219 | end 220 | 221 | # List of settings required in production. 222 | # 223 | # Returns an Array of Strings. 224 | def self.required_settings 225 | %w[RACK_ENV DATABASE_URL 226 | JANKY_BASE_URL 227 | JANKY_BUILDER_DEFAULT 228 | JANKY_CONFIG_DIR 229 | JANKY_GITHUB_USER JANKY_GITHUB_PASSWORD JANKY_GITHUB_HOOK_SECRET 230 | JANKY_HUBOT_USER JANKY_HUBOT_PASSWORD] 231 | end 232 | 233 | class << self 234 | # Directory where Jenkins job configuration templates are located. 235 | # 236 | # Returns the directory as a Pathname. 237 | attr_accessor :jobs_config_dir 238 | end 239 | 240 | # Mock out all network-dependant components. Must be called after setup. 241 | # Typically used in test environments. 242 | # 243 | # Returns nothing. 244 | def self.enable_mock! 245 | Janky::Builder.enable_mock! 246 | Janky::GitHub.enable_mock! 247 | Janky::Notifier.enable_mock! 248 | Janky::ChatService.enable_mock! 249 | Janky::App.disable :github_team_id 250 | end 251 | 252 | # Reset the state of the mocks. 253 | # 254 | # Returns nothing. 255 | def self.reset! 256 | Janky::Notifier.reset! 257 | Janky::Builder.reset! 258 | end 259 | 260 | # The Janky Rack application, assembled from four apps. Exceptions raised 261 | # during the request cycle are caught by the Exception middleware which 262 | # typically reports them to an external service before re-raising the 263 | # exception. 264 | # 265 | # Returns a memoized Rack application. 266 | def self.app 267 | @app ||= Rack::Builder.app { 268 | # GitHub Post-Receive requests. 269 | map "/_github" do 270 | run Janky::GitHub.receiver 271 | end 272 | 273 | # Jenkins callback requests. 274 | map "/_builder" do 275 | run Janky::Builder.receiver 276 | end 277 | 278 | # Hubot API, protected by Basic Auth. 279 | map "/_hubot" do 280 | use Rack::Auth::Basic do |username, password| 281 | username == Janky::Hubot.username && 282 | password == Janky::Hubot.password 283 | end 284 | 285 | run Janky::Hubot 286 | end 287 | 288 | # Web dashboard 289 | map "/" do 290 | run Janky::App 291 | end 292 | } 293 | end 294 | 295 | # Register a Chat service implementation. 296 | # 297 | # name - Service name as a String, e.g. "irc". 298 | # service - Constant for the implementation. 299 | # 300 | # Returns nothing. 301 | def self.register_chat_service(name, service) 302 | Janky::ChatService.adapters[name] = service 303 | end 304 | 305 | register_chat_service "campfire", ChatService::Campfire 306 | end 307 | -------------------------------------------------------------------------------- /lib/janky/app.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | class App < Sinatra::Base 3 | register Mustache::Sinatra 4 | register Helpers 5 | 6 | set :app_file, __FILE__ 7 | enable :static 8 | 9 | set :mustache, { 10 | :namespace => Janky, 11 | :views => File.join(root, "views"), 12 | :templates => File.join(root, "templates") 13 | } 14 | 15 | before do 16 | if organization = github_organization 17 | github_organization_authenticate!(organization) 18 | end 19 | end 20 | 21 | def github_organization 22 | settings.respond_to?(:github_organization) && settings.github_organization 23 | end 24 | 25 | def github_team_id 26 | settings.respond_to?(:github_team_id) && settings.github_team_id 27 | end 28 | 29 | def authorize_index 30 | if github_team_id 31 | github_team_authenticate!(github_team_id) 32 | end 33 | end 34 | 35 | def authorize_repo(repo) 36 | if team_id = (repo.github_team_id || github_team_id) 37 | github_team_authenticate!(team_id) 38 | end 39 | end 40 | 41 | get "/?" do 42 | authorize_index 43 | @builds = Build.queued.first(50) 44 | mustache :index 45 | end 46 | 47 | get "/:build_id/output" do |build_id| 48 | @build = Build.select(:output).find(build_id) 49 | authorize_repo(@build.repo) 50 | mustache :console, :layout => false 51 | end 52 | 53 | get "/:repo_name" do |repo_name| 54 | repo = find_repo(repo_name) 55 | authorize_repo(repo) 56 | 57 | @builds = repo.builds.queued.first(50) 58 | mustache :index 59 | end 60 | 61 | get %r{^(?!\/auth\/github\/callback)\/([-_\.0-9a-zA-Z]+)\/([-_\.a-zA-z0-9\/]+)} do |repo_name, branch| 62 | repo = find_repo(repo_name) 63 | authorize_repo(repo) 64 | 65 | @builds = repo.branch_for(branch).queued_builds.first(50) 66 | mustache :index 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/janky/branch.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | class Branch < ActiveRecord::Base 3 | belongs_to :repository 4 | has_many :builds, :dependent => :destroy 5 | 6 | # Is this branch green? 7 | # 8 | # Returns a Boolean. 9 | def green? 10 | if current_build 11 | current_build.green? 12 | end 13 | end 14 | 15 | # Is this branch red? 16 | # 17 | # Returns a Boolean. 18 | def red? 19 | if current_build 20 | current_build.red? 21 | end 22 | end 23 | 24 | # Is this branch building? 25 | # 26 | # Returns a Boolean. 27 | def building? 28 | if current_build 29 | current_build.building? 30 | end 31 | end 32 | 33 | # Is this branch completed? 34 | # 35 | # Returns a Boolean. 36 | def completed? 37 | if current_build 38 | current_build.completed? 39 | end 40 | end 41 | 42 | # Find all completed builds, sorted by completion date, most recent first. 43 | # 44 | # Returns an Array of Builds. 45 | def completed_builds 46 | builds.completed 47 | end 48 | 49 | # See Build.queued. 50 | def queued_builds 51 | builds.queued 52 | end 53 | 54 | # Create a build for the given commit. 55 | # 56 | # commit - the Janky::Commit instance to build. 57 | # user - The login of the GitHub user who pushed. 58 | # compare - optional String GitHub Compare View URL. Defaults to the 59 | # commit last build, if any. 60 | # room_id - optional String room ID. Defaults to the room set on 61 | # the repository. 62 | # 63 | # Returns the newly created Janky::Build. 64 | def build_for(commit, user, room_id = nil, compare = nil) 65 | if compare.nil? && build = commit.last_build 66 | compare = build.compare 67 | end 68 | 69 | room_id = room_id.to_s 70 | if room_id.empty? || room_id == "0" 71 | room_id = repository.room_id 72 | end 73 | 74 | builds.create!( 75 | :compare => compare, 76 | :user => user, 77 | :commit => commit, 78 | :room_id => room_id 79 | ) 80 | end 81 | 82 | # Fetch the HEAD commit of this branch using the GitHub API and create a 83 | # build and commit record. 84 | # 85 | # room_id - See build_for documentation. This is passed as is to the 86 | # build_for method. 87 | # user - Ditto. 88 | # 89 | # Returns the newly created Janky::Build. 90 | def head_build_for(room_id, user) 91 | sha_to_build = GitHub.branch_head_sha(repository.nwo, name) 92 | return if !sha_to_build 93 | 94 | commit = repository.commit_for_sha(sha_to_build) 95 | 96 | current_sha = current_build ? current_build.sha1 : "#{sha_to_build}^" 97 | compare_url = repository.github_url("compare/#{current_sha}...#{commit.sha1}") 98 | build_for(commit, user, room_id, compare_url) 99 | end 100 | 101 | # The current build, e.g. the most recent one. 102 | # 103 | # Returns a Build. 104 | def current_build 105 | builds.last 106 | end 107 | 108 | # Human readable status of this branch 109 | # 110 | # Returns a String. 111 | def status 112 | if current_build && current_build.building? 113 | "building" 114 | elsif build = completed_builds.first 115 | if build.green? 116 | "green" 117 | elsif build.red? 118 | "red" 119 | end 120 | elsif completed_builds.empty? || builds.empty? 121 | "no build" 122 | else 123 | raise Error, "unexpected branch status: #{id.inspect}" 124 | end 125 | end 126 | 127 | # Hash representation of this branch status. 128 | # 129 | # Returns a Hash with the name, status, sha1 and compare url. 130 | def to_hash 131 | { 132 | :name => repository.name, 133 | :status => status, 134 | :sha1 => (current_build && current_build.sha1), 135 | :compare => (current_build && current_build.compare) 136 | } 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/janky/build.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | class Build < ActiveRecord::Base 3 | belongs_to :branch 4 | belongs_to :commit 5 | 6 | default_scope do 7 | columns = (column_names - ["output"]).map do |column_name| 8 | arel_table[column_name] 9 | end 10 | 11 | select(columns) 12 | end 13 | 14 | scope :building, lambda { 15 | where("started_at IS NOT NULL AND completed_at IS NULL") 16 | } 17 | 18 | # Transition the Build to the started state. 19 | # 20 | # id - the Fixnum ID used to find the build. 21 | # url - the full String URL of the build. 22 | # 23 | # Returns nothing or raises an Error for inexistant builds. 24 | def self.start(id, url) 25 | if build = find_by_id(id) 26 | build.start(url, Time.now) 27 | else 28 | raise Error, "Unknown build: #{id.inspect}" 29 | end 30 | end 31 | 32 | # Transition the Build to the completed state. 33 | # 34 | # id - the Fixnum ID used to find the build. 35 | # green - Boolean indicating build success. 36 | # 37 | # Returns nothing or raises an Error for inexistant builds. 38 | def self.complete(id, green) 39 | if build = find_by_id(id) 40 | build.complete(green, Time.now) 41 | else 42 | raise Error, "Unknown build: #{id.inspect}" 43 | end 44 | end 45 | 46 | # Find all builds that have been queued in Jenkins, most recent first. 47 | # 48 | # Returns an Array of Build objects. 49 | def self.queued 50 | where("queued_at IS NOT NULL").order("queued_at DESC, id DESC") 51 | end 52 | 53 | # Find all started builds, most recent first. 54 | # 55 | # Returns an Array of Builds. 56 | def self.started 57 | where("started_at IS NOT NULL").order("started_at DESC, id DESC") 58 | end 59 | 60 | # Find all completed builds, most recent first. 61 | # 62 | # Returns an Array of Builds. 63 | def self.completed 64 | started. 65 | where("completed_at IS NOT NULL") 66 | end 67 | 68 | # Find all green builds, most recent first. 69 | # 70 | # Returns an Array of Builds. 71 | def self.green 72 | completed.where(:green => true) 73 | end 74 | 75 | # Has this build been queued in Jenkins? 76 | # 77 | # Returns true when the build is complete or currently being built, 78 | # false otherwise. 79 | def queued? 80 | ! queued_at.nil? 81 | end 82 | 83 | # Is this build currently sitting in the queue waiting to be built? 84 | # 85 | # Returns true if the build is queued and not started, false otherwise. 86 | def pending? 87 | queued? && !started? 88 | end 89 | 90 | # Is this build currently being built? 91 | # 92 | # Returns a Boolean. 93 | def building? 94 | started? && !completed? 95 | end 96 | 97 | # Is this build red? 98 | # 99 | # Returns a Boolean, nothing when the build hasn't completed yet. 100 | def red? 101 | completed? && !green? 102 | end 103 | 104 | # Was this build ever started? 105 | # 106 | # Returns a Boolean. 107 | def started? 108 | ! started_at.nil? 109 | end 110 | 111 | # Did this build complete? 112 | # 113 | # Returns a Boolean. 114 | def completed? 115 | ! completed_at.nil? 116 | end 117 | 118 | # Trigger a Jenkins build using the appropriate builder. 119 | # 120 | # Returns nothing. 121 | def run 122 | builder.run(self) 123 | update_attributes!(:queued_at => Time.now) 124 | end 125 | 126 | # See Repository#builder. 127 | def builder 128 | branch.repository.builder 129 | end 130 | 131 | # Run a copy of itself. Typically used to force a build in case of 132 | # temporary test failure or when auto-build is disabled. 133 | # 134 | # new_room_id - optional Campfire room String ID. Defaults to the room of the 135 | # build being re-run. 136 | # 137 | # Returns the build copy. 138 | def rerun(new_room_id = nil) 139 | build = branch.build_for(commit, new_room_id) 140 | build.run 141 | build 142 | end 143 | 144 | # Cached or remote build output. 145 | # 146 | # Returns the String output. 147 | def output 148 | if completed? 149 | read_attribute(:output) 150 | elsif started? 151 | output_remote 152 | else 153 | "" 154 | end 155 | end 156 | 157 | # Retrieve the build output from the Jenkins server. 158 | # 159 | # Returns the String output. 160 | def output_remote 161 | if started? 162 | builder.output(self) 163 | end 164 | end 165 | 166 | # Mark the build as started. 167 | # 168 | # url - the full String URL of the build on the Jenkins server. 169 | # now - the Time at which the build started. 170 | # 171 | # Returns nothing or raise an Error for weird transitions. 172 | def start(url, now) 173 | if started? 174 | raise Error, "Build #{id} already started" 175 | elsif completed? 176 | raise Error, "Build #{id} already completed" 177 | else 178 | update_attributes!(:url => url, :started_at => now) 179 | Notifier.started(self) 180 | end 181 | end 182 | 183 | # Mark the build as complete, store the build output and notify Campfire. 184 | # 185 | # green - Boolean indicating build success. 186 | # now - the Time at which the build completed. 187 | # 188 | # Returns nothing or raise an Error for weird transitions. 189 | def complete(green, now) 190 | if ! started? 191 | raise Error, "Build #{id} not started" 192 | elsif completed? 193 | raise Error, "Build #{id} already completed" 194 | else 195 | update_attributes!( 196 | :green => green, 197 | :completed_at => now, 198 | :output => output_remote 199 | ) 200 | Notifier.completed(self) 201 | end 202 | end 203 | 204 | # The time it took to peform this build in seconds. 205 | # 206 | # Returns an Integer seconds. 207 | def duration 208 | if completed? 209 | Integer(completed_at - started_at) 210 | end 211 | end 212 | 213 | # The name of the Campfire room where notifications are sent. 214 | # 215 | # Returns the String room name. 216 | def room_name 217 | if room_id && !room_id.empty? 218 | ChatService.room_name(room_id) 219 | end 220 | end 221 | 222 | class << self 223 | # The full URL of the web app as a String, including the protocol. 224 | attr_accessor :base_url 225 | 226 | # The full URL to the Jenkins build page, as a String. 227 | attr_reader :url 228 | end 229 | 230 | # URL of this build's web page, served by Janky::App. 231 | # 232 | # Returns the URL as a String. 233 | def web_url 234 | return if new_record? 235 | self.class.base_url + "#{id}/output" 236 | end 237 | 238 | # URL of the web page for this build's branch, served by Janky::App. 239 | # 240 | # Returns the URL as a String. 241 | def branch_url 242 | return if new_record? 243 | self.class.base_url + "#{repo_name}/#{branch_name}" 244 | end 245 | 246 | def repo_id 247 | repository.id 248 | end 249 | 250 | def repo_job_name 251 | repository.job_name 252 | end 253 | 254 | def repo_name 255 | repository.name 256 | end 257 | 258 | def repo_nwo 259 | repository.nwo 260 | end 261 | 262 | def repository 263 | branch.repository 264 | end 265 | 266 | def repo 267 | branch.repository 268 | end 269 | 270 | def sha1 271 | commit.sha1 272 | end 273 | 274 | def short_sha1 275 | sha1[0,7] 276 | end 277 | 278 | def commit_url 279 | commit.url 280 | end 281 | 282 | def commit_message 283 | commit.message 284 | end 285 | 286 | def commit_author 287 | commit.author 288 | end 289 | 290 | def number 291 | id.to_s 292 | end 293 | 294 | def branch_name 295 | branch.name 296 | end 297 | end 298 | end 299 | -------------------------------------------------------------------------------- /lib/janky/build_request.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | class BuildRequest 3 | def self.handle(repo_uri, branch_name, pusher, commit, compare, room_id) 4 | repos = Repository.where(uri: repo_uri) 5 | repos.each do |repo| 6 | begin 7 | new(repo, branch_name, pusher, commit, compare, room_id).handle 8 | rescue Janky::Error => boom 9 | Exception.report(boom, :repo => repo.name) 10 | end 11 | end 12 | 13 | repos.size 14 | end 15 | 16 | def initialize(repo, branch_name, pusher, commit, compare, room_id) 17 | @repo = repo 18 | @branch_name = branch_name 19 | @pusher = pusher 20 | @commit = commit 21 | @compare = compare 22 | @room_id = room_id 23 | end 24 | 25 | def handle 26 | current_build = commit.last_build 27 | build = branch.build_for(commit, @pusher, @room_id, @compare) 28 | 29 | if !current_build || (current_build && current_build.red?) 30 | if @repo.enabled? 31 | build.run 32 | Notifier.queued(build) 33 | end 34 | end 35 | end 36 | 37 | def branch 38 | @repo.branch_for(@branch_name) 39 | end 40 | 41 | def commit 42 | @repo.commit_for( 43 | :sha1 => @commit.sha1, 44 | :url => @commit.url, 45 | :message => @commit.message, 46 | :author => @commit.author, 47 | :committed_at => @commit.committed_at 48 | ) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/janky/builder.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | # Triggers Jenkins builds and handles callbacks. 3 | # 4 | # The HTTP requests flow goes like this: 5 | # 6 | # 1. Send a Build request to the Jenkins server over HTTP. The resulting 7 | # build URL is stored in Build#url. 8 | # 9 | # 2. Once Jenkins picks up the build and starts running it, it sends a callback 10 | # handled by the `receiver` Rack app, which transitions the build into a 11 | # building state. 12 | # 13 | # 3. Finally, Jenkins sends another callback with the build result and the 14 | # build is transitioned to a completed and green/red state. 15 | # 16 | # The Mock adapter provides methods to simulate that flow without having to 17 | # go over the wire. 18 | module Builder 19 | # Set the callback URL of builder clients. Must be called before 20 | # registering any client. 21 | # 22 | # callback_url - The absolute callback URL as a String. 23 | # 24 | # Returns nothing. 25 | def self.setup(callback_url) 26 | @callback_url = callback_url 27 | end 28 | 29 | # Public: Define the rule for picking a builder. 30 | # 31 | # block - Required block that will be given a Repository object when 32 | # picking a builder. Must return a Client object. 33 | # 34 | # Returns nothing. 35 | def self.choose(&block) 36 | @chooser = block 37 | end 38 | 39 | # Pick the appropriate builder for a repo based on the rule set by the 40 | # choose method. Uses the default builder when no rule is defined. 41 | # 42 | # repo - a Repository object. 43 | # 44 | # Returns a Client object. 45 | def self.pick_for(repo) 46 | if block = @chooser 47 | block.call(repo) 48 | else 49 | self[:default] 50 | end 51 | end 52 | 53 | # Register a new build host. 54 | # 55 | # url - The String URL of the Jenkins server. 56 | # 57 | # Returns the new Client instance. 58 | def self.[]=(builder, url) 59 | builders[builder] = Client.new(url, @callback_url) 60 | end 61 | 62 | # Get the Client for a registered build host. 63 | # 64 | # builder - the String name of the build host. 65 | # 66 | # Returns the Client instance. 67 | def self.[](builder) 68 | builders[builder] || 69 | raise(Error, "Unknown builder: #{builder.inspect}") 70 | end 71 | 72 | # Registered build hosts. 73 | # 74 | # Returns an Array of Client. 75 | def self.builders 76 | @builders ||= {} 77 | end 78 | 79 | # Rack app handling HTTP callbacks coming from the Jenkins server. 80 | def self.receiver 81 | @receiver ||= Janky::Builder::Receiver 82 | end 83 | 84 | def self.enable_mock! 85 | builders.values.each { |b| b.enable_mock! } 86 | end 87 | 88 | def self.green! 89 | builders.values.each { |b| b.green! } 90 | end 91 | 92 | def self.red! 93 | builders.values.each { |b| b.red! } 94 | end 95 | 96 | def self.reset! 97 | builders.values.each { |b| b.reset! } 98 | end 99 | 100 | def self.start! 101 | builders.values.each { |b| b.start! } 102 | end 103 | 104 | def self.complete! 105 | builders.values.each { |b| b.complete! } 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/janky/builder/client.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Builder 3 | class Client 4 | def initialize(url, callback_url) 5 | @url = URI(url) 6 | @callback_url = URI(callback_url) 7 | end 8 | 9 | # The String absolute URL of the Jenkins server. 10 | attr_reader :url 11 | 12 | # The String absoulte URL callback of this Janky host. 13 | attr_reader :callback_url 14 | 15 | # Trigger a Jenkins build for the given Build. 16 | # 17 | # build - a Build object. 18 | # 19 | # Returns the Jenkins build URL. 20 | def run(build) 21 | Runner.new(@url, build, adapter).run 22 | end 23 | 24 | # Retrieve the output of the given Build. 25 | # 26 | # build - a Build object. Must have an url attribute. 27 | # 28 | # Returns the String build output. 29 | def output(build) 30 | Runner.new(@url, build, adapter).output 31 | end 32 | 33 | # Setup a job on the Jenkins server. 34 | # 35 | # name - The desired job name as a String. 36 | # repo_uri - The repository git URI as a String. 37 | # template_path - The Pathname to the XML config template. 38 | # 39 | # Returns nothing. 40 | def setup(name, repo_uri, template_path) 41 | job_creator.run(name, repo_uri, template_path) 42 | end 43 | 44 | # The adapter used to trigger builds. Defaults to HTTP, which hits the 45 | # Jenkins server configured by `setup`. 46 | def adapter 47 | @adapter ||= HTTP.new(url.user, url.password) 48 | end 49 | 50 | def job_creator 51 | @job_creator ||= JobCreator.new(url, @callback_url) 52 | end 53 | 54 | # Enable the mock adapter and make subsequent builds green. 55 | def green! 56 | @adapter = Mock.new(true, Janky.app) 57 | job_creator.enable_mock! 58 | end 59 | 60 | # Alias green! as enable_mock! 61 | alias_method :enable_mock!, :green! 62 | 63 | # Alias green! as reset! 64 | alias_method :reset!, :green! 65 | 66 | # Enable the mock adapter and make subsequent builds red. 67 | def red! 68 | @adapter = Mock.new(false, Janky.app) 69 | end 70 | 71 | # Simulate the first callback. Only available when mocked. 72 | def start! 73 | @adapter.start 74 | end 75 | 76 | # Simulate the last callback. Only available when mocked. 77 | def complete! 78 | @adapter.complete 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/janky/builder/http.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Builder 3 | class HTTP 4 | def initialize(username, password) 5 | @username = username 6 | @password = password 7 | end 8 | 9 | def run(params, create_url) 10 | http = Net::HTTP.new(create_url.host, create_url.port) 11 | if create_url.scheme == "https" 12 | http.use_ssl = true 13 | end 14 | 15 | request = Net::HTTP::Post.new(create_url.path) 16 | if @username && @password 17 | request.basic_auth(@username, @password) 18 | end 19 | request.form_data = {"json" => params} 20 | 21 | response = http.request(request) 22 | 23 | if !%w[302 201].include?(response.code) 24 | Exception.push_http_response(response) 25 | raise Error, "Failed to create build" 26 | end 27 | end 28 | 29 | def output(url) 30 | http = Net::HTTP.new(url.host, url.port) 31 | if url.scheme == "https" 32 | http.use_ssl = true 33 | end 34 | 35 | request = Net::HTTP::Get.new(url.path) 36 | if @username && @password 37 | request.basic_auth(@username, @password) 38 | end 39 | 40 | response = http.request(request) 41 | 42 | unless response.code == "200" 43 | Exception.push_http_response(response) 44 | raise Error, "Failed to get build output" 45 | end 46 | 47 | response.body 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/janky/builder/mock.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Builder 3 | class Mock 4 | def initialize(green, app) 5 | @green = green 6 | @app = app 7 | @builds = [] 8 | end 9 | 10 | def run(params, create_url) 11 | params = Yajl.load(params)["parameter"] 12 | param = params.detect{ |p| p["name"] == "JANKY_ID" } 13 | build_id = param["value"] 14 | url = create_url.to_s.gsub("build", build_id.to_s) 15 | 16 | @builds << [build_id, "#{url}/", @green] 17 | end 18 | 19 | def output(build) 20 | "....FFFUUUUUUU" 21 | end 22 | 23 | def start 24 | @builds.each do |id, url, _| 25 | payload = Payload.start(id, url) 26 | request(payload) 27 | end 28 | end 29 | 30 | def complete 31 | @builds.each do |id, _, green| 32 | payload = Payload.complete(id, green) 33 | request(payload) 34 | end 35 | @builds.clear 36 | end 37 | 38 | def request(payload) 39 | Rack::MockRequest.new(@app).post("/_builder", 40 | :input => payload.to_json 41 | ) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/janky/builder/payload.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Builder 3 | class Payload 4 | def self.parse(json, base_url) 5 | parsed = Yajl.load(json) 6 | build = parsed["build"] 7 | 8 | full_url = build["full_url"] 9 | path = build["url"] 10 | build_url = full_url || "#{base_url}#{path}" 11 | 12 | new( 13 | build["phase"], 14 | build["parameters"]["JANKY_ID"], 15 | build_url, 16 | build["status"] 17 | ) 18 | end 19 | 20 | def self.start(id, url) 21 | new("STARTED", id, url, nil) 22 | end 23 | 24 | def self.complete(id, green) 25 | status = (green ? "SUCCESS" : "FAILED") 26 | new("FINISHED", id, nil, status) 27 | end 28 | 29 | def initialize(phase, id, url, status) 30 | @phase = phase 31 | @id = id 32 | @url = url 33 | @status = status 34 | end 35 | 36 | attr_reader :id, :url 37 | 38 | def started? 39 | @phase == "STARTED" 40 | end 41 | 42 | def completed? 43 | @phase == "FINISHED" || @phase == "FINALIZED" 44 | end 45 | 46 | def green? 47 | if completed? 48 | @status == "SUCCESS" 49 | else 50 | false 51 | end 52 | end 53 | 54 | def to_json 55 | { :build => { 56 | :phase => @phase, 57 | :status => @status, 58 | :full_url => @url, 59 | :parameters => { 60 | "JANKY_ID" => @id 61 | } 62 | } 63 | }.to_json 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/janky/builder/receiver.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Builder 3 | class Receiver 4 | def self.call(env) 5 | request = Rack::Request.new(env) 6 | default_base_url = Builder[:default].url 7 | payload = Payload.parse(request.body, default_base_url) 8 | 9 | if payload.started? 10 | Build.start(payload.id, payload.url) 11 | elsif payload.completed? 12 | Build.complete(payload.id, payload.green?) 13 | else 14 | return Rack::Response.new("Bad Request", 400).finish 15 | end 16 | 17 | Rack::Response.new("OK", 201).finish 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/janky/builder/runner.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Builder 3 | class Runner 4 | def initialize(base_url, build, adapter) 5 | @base_url = base_url 6 | @build = build 7 | @adapter = adapter 8 | end 9 | 10 | def run 11 | context_push 12 | @adapter.run(json_params, create_url) 13 | end 14 | 15 | def output 16 | context_push 17 | @adapter.output(output_url) 18 | end 19 | 20 | def json_params 21 | Yajl.dump(:parameter => [ 22 | { :name => "JANKY_SHA1", :value => @build.sha1 }, 23 | { :name => "JANKY_BRANCH", :value => @build.branch_name }, 24 | { :name => "JANKY_ID", :value => @build.id } 25 | ]) 26 | end 27 | 28 | def output_url 29 | URI(@build.url + "consoleText") 30 | end 31 | 32 | def create_url 33 | URI.join(@base_url, "job/#{@build.repo_job_name}/build") 34 | end 35 | 36 | def context_push 37 | Exception.push( 38 | :base_url => @base_url.inspect, 39 | :build => @build.inspect, 40 | :adapter => @adapter.inspect, 41 | :params => json_params.inspect, 42 | :create_url => create_url.inspect 43 | ) 44 | end 45 | end 46 | end 47 | end 48 | 49 | -------------------------------------------------------------------------------- /lib/janky/chat_service.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module ChatService 3 | Room = Struct.new(:id, :name) 4 | 5 | # Setup the adapter used to notify chat rooms of build status. 6 | # 7 | # name - Service name as a string. 8 | # settings - Service-specific setting hash. 9 | # default - Name of the default chat room as a String. 10 | # 11 | # Returns nothing. 12 | def self.setup(name, settings, default) 13 | klass = adapters[name] 14 | 15 | if !klass 16 | raise Error, "Unknown chat service: #{name.inspect}. Available " \ 17 | "services are #{adapters.keys.join(", ")}" 18 | end 19 | 20 | @adapter = klass.new(settings) 21 | @default_room_name = default 22 | end 23 | 24 | class << self 25 | attr_accessor :adapter, :default_room_name 26 | end 27 | 28 | # Registry of available chat implementations. 29 | def self.adapters 30 | @adapters ||= {} 31 | end 32 | 33 | def self.default_room_id 34 | room_id(default_room_name) 35 | end 36 | 37 | # Send a message to a Chat room. 38 | # 39 | # message - The String message. 40 | # room_id - The String room ID. 41 | # options - Optional hash passed to the chat adapter. 42 | # 43 | # Returns nothing. 44 | def self.speak(message, room_id, options = {}) 45 | adapter.speak(message, room_id, options) 46 | end 47 | 48 | # Get the ID of a room. 49 | # 50 | # slug - the String name of the room. 51 | # 52 | # Returns the room ID or nil for unknown rooms. 53 | def self.room_id(name) 54 | if room = rooms.detect { |room| room.name == name } 55 | room.id 56 | end 57 | end 58 | 59 | # Get the name of a room given its ID. 60 | # 61 | # id - the String room ID. 62 | # 63 | # Returns the name as a String or nil when not found. 64 | def self.room_name(id) 65 | if room = rooms.detect { |room| room.id.to_s == id.to_s } 66 | room.name 67 | end 68 | end 69 | 70 | # Get a list of all rooms names. 71 | # 72 | # Returns an Array of room name as Strings. 73 | def self.room_names 74 | rooms.map { |room| room.name }.sort 75 | end 76 | 77 | # Memoized list of available rooms. 78 | # 79 | # Returns an Array of Room objects. 80 | def self.rooms 81 | adapter.rooms 82 | end 83 | 84 | # Enable mocking. Once enabled, messages are discarded. 85 | # 86 | # Returns nothing. 87 | def self.enable_mock! 88 | @adapter = Mock.new 89 | end 90 | 91 | # Configure available rooms. Only available in mock mode. 92 | # 93 | # value - Hash of room map (String ID => String name) 94 | # 95 | # Returns nothing. 96 | def self.rooms=(value) 97 | adapter.rooms = value 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/janky/chat_service/campfire.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module ChatService 3 | class Campfire 4 | def initialize(settings) 5 | account = settings["JANKY_CHAT_CAMPFIRE_ACCOUNT"] 6 | if account.nil? || account.empty? 7 | raise Error, "JANKY_CHAT_CAMPFIRE_ACCOUNT setting is required" 8 | end 9 | 10 | token = settings["JANKY_CHAT_CAMPFIRE_TOKEN"] 11 | if token.nil? || token.empty? 12 | raise Error, "JANKY_CHAT_CAMPFIRE_TOKEN setting is required" 13 | end 14 | 15 | Broach.settings = { 16 | "account" => account, 17 | "token" => token, 18 | "use_ssl" => true, 19 | } 20 | end 21 | 22 | def speak(message, room_id, opts={}) 23 | Broach.speak(ChatService.room_name(room_id), message) 24 | end 25 | 26 | def rooms 27 | @rooms ||= Broach.rooms.map do |room| 28 | Room.new(room.id, room.name) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/janky/chat_service/hipchat.rb: -------------------------------------------------------------------------------- 1 | require "hipchat" 2 | 3 | module Janky 4 | module ChatService 5 | class HipChat 6 | def initialize(settings) 7 | token = settings["JANKY_CHAT_HIPCHAT_TOKEN"] 8 | if token.nil? || token.empty? 9 | raise Error, "JANKY_CHAT_HIPCHAT_TOKEN setting is required" 10 | end 11 | 12 | @client = ::HipChat::Client.new(token) 13 | @from = settings["JANKY_CHAT_HIPCHAT_FROM"] || "CI" 14 | end 15 | 16 | def speak(message, room_id, options = {}) 17 | default = { 18 | :color => "yellow", 19 | :message_format => "text" 20 | } 21 | options = default.merge(options) 22 | @client[room_id].send(@from, message, options) 23 | end 24 | 25 | def rooms 26 | @rooms ||= @client.rooms.map do |room| 27 | Room.new(room.room_id, room.name) 28 | end 29 | end 30 | end 31 | end 32 | 33 | register_chat_service "hipchat", ChatService::HipChat 34 | end 35 | -------------------------------------------------------------------------------- /lib/janky/chat_service/hubot.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module ChatService 3 | class Hubot 4 | def initialize(settings) 5 | @available_rooms = settings["JANKY_CHAT_HUBOT_ROOMS"] 6 | @default_room = settings["JANKY_CHAT_HUBOT_DEFAULT_ROOM"] 7 | url = settings["JANKY_CHAT_HUBOT_URL"] 8 | if url.nil? || url.empty? 9 | raise Error, "JANKY_CHAT_HUBOT_URL setting is required" 10 | end 11 | @url = URI(url) 12 | end 13 | 14 | def speak(message, room, options = {:color => "yellow"}) 15 | request(message, room) 16 | end 17 | 18 | def rooms 19 | @available_rooms.split(',').map do |room| 20 | id, name = room.strip.split(':') 21 | name ||= id 22 | Room.new(id, name) 23 | end 24 | end 25 | 26 | def request(message, room) 27 | room ||= @default_room 28 | uri = @url 29 | user = uri.user 30 | pass = uri.password 31 | path = File.join(uri.path, "janky") 32 | 33 | http = Net::HTTP.new(uri.host, uri.port) 34 | if uri.scheme == "https" 35 | http.use_ssl = true 36 | end 37 | 38 | post = Net::HTTP::Post.new(path) 39 | post.basic_auth(user, pass) if user && pass 40 | post["Content-Type"] = "application/json" 41 | post.body = {:message => message, :room => room}.to_json 42 | response = http.request(post) 43 | unless response.code == "200" 44 | Exception.push_http_response(response) 45 | raise Error, "Failed to notify" 46 | end 47 | end 48 | end 49 | end 50 | 51 | register_chat_service "hubot", ChatService::Hubot 52 | end 53 | -------------------------------------------------------------------------------- /lib/janky/chat_service/mock.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module ChatService 3 | # Mock chat implementation used in testing environments. 4 | class Mock 5 | def initialize 6 | @rooms = {} 7 | end 8 | 9 | attr_writer :rooms 10 | 11 | def speak(room_name, message) 12 | if !@rooms.values.include?(room_name) 13 | raise Error, "Unknown room #{room_name.inspect}" 14 | end 15 | end 16 | 17 | def rooms 18 | acc = [] 19 | @rooms.each do |id, name| 20 | acc << Room.new(id, name) 21 | end 22 | acc 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/janky/chat_service/slack.rb: -------------------------------------------------------------------------------- 1 | require "slack" 2 | 3 | module Janky 4 | module ChatService 5 | class Slack 6 | def initialize(settings) 7 | team = settings["JANKY_CHAT_SLACK_TEAM"] 8 | 9 | if team.nil? || team.empty? 10 | raise Error, "JANKY_CHAT_SLACK_TEAM setting is required" 11 | end 12 | 13 | token = settings["JANKY_CHAT_SLACK_TOKEN"] 14 | 15 | if token.nil? || token.empty? 16 | raise Error, "JANKY_CHAT_SLACK_TOKEN setting is required" 17 | end 18 | 19 | username = settings["JANKY_CHAT_SLACK_USERNAME"] || 'CI' 20 | icon_url = settings["JANKY_CHAT_SLACK_ICON_URL"] 21 | 22 | @client = ::Slack::Client.new(team: team, token: token, username: username, icon_url: icon_url) 23 | end 24 | 25 | def speak(message, room_id, options = {}) 26 | room_name = ChatService.room_name(room_id) || room_id 27 | 28 | if options[:build].present? 29 | @client.post_message(nil, room_name, {attachments: attachments(message, options[:build])}) 30 | else 31 | @client.post_message(message, room_name, options) 32 | end 33 | end 34 | 35 | def rooms 36 | @rooms ||= @client.channels.map do |channel| 37 | Room.new(channel['id'], channel['name']) 38 | end 39 | end 40 | 41 | private 42 | 43 | def attachments(fallback, build) 44 | status = build.green? ? "was successful" : "failed" 45 | color = build.green? ? "good" : "danger" 46 | 47 | message = "Build #%s of %s/%s %s" % [ 48 | build.number, 49 | build.repo_name, 50 | build.branch_name, 51 | status 52 | ] 53 | 54 | janky_field = ::Slack::AttachmentField.new("Janky", build.web_url, false) 55 | commit_field = ::Slack::AttachmentField.new("Commit", "<#{build.commit_url}|#{build.short_sha1}>", true) 56 | duration_field = ::Slack::AttachmentField.new("Duration", "#{build.duration}s", true) 57 | fields = [janky_field.to_h, commit_field.to_h, duration_field.to_h] 58 | 59 | [::Slack::Attachment.new(fallback, message, nil, color, ["text", "title", "fallback"], fields)] 60 | end 61 | end 62 | end 63 | 64 | register_chat_service "slack", ChatService::Slack 65 | end 66 | -------------------------------------------------------------------------------- /lib/janky/commit.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | class Commit < ActiveRecord::Base 3 | belongs_to :repository 4 | has_many :builds 5 | 6 | def last_build 7 | builds.last 8 | end 9 | 10 | def build!(user, room_id = nil, compare = nil) 11 | compare = repository.github_url("compare/#{sha1}^...#{sha1}") 12 | 13 | room_id = room_id.to_s 14 | if room_id.empty? || room_id == "0" 15 | room_id = repository.room_id 16 | end 17 | 18 | builds.create!( 19 | :compare => compare, 20 | :user => user, 21 | :commit => self, 22 | :room_id => room_id, 23 | :branch_id => repository.branch_for('master').id 24 | ) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1312115512_init.rb: -------------------------------------------------------------------------------- 1 | class Init < ActiveRecord::Migration 2 | def self.up 3 | create_table :repositories, :force => true do |t| 4 | t.string :name, :null => false 5 | t.string :uri, :null => false 6 | t.integer :room_id, :null => false 7 | t.timestamps 8 | end 9 | add_index :repositories, :name, :unique => true 10 | add_index :repositories, :uri, :unique => true 11 | 12 | create_table :branches, :force => true do |t| 13 | t.string :name, :null => false 14 | t.belongs_to :repository, :null => false 15 | t.timestamps 16 | end 17 | add_index :branches, [:name, :repository_id], :unique => true 18 | 19 | create_table :commits, :force => true do |t| 20 | t.string :sha1, :null => false 21 | t.string :message, :null => false 22 | t.string :author, :null => false 23 | t.datetime :committed_at 24 | t.belongs_to :repository, :null => false 25 | t.timestamps 26 | end 27 | add_index :commits, [:sha1, :repository_id], :unique => true 28 | 29 | create_table :builds, :force => true do |t| 30 | t.boolean :green, :default => false 31 | t.string :url, :null => true 32 | t.string :compare, :null => false 33 | t.datetime :started_at 34 | t.datetime :completed_at 35 | t.belongs_to :commit, :null => false 36 | t.belongs_to :branch, :null => false 37 | t.timestamps 38 | end 39 | add_index :builds, :url, :unique => true 40 | end 41 | 42 | def self.down 43 | drop_table :repositories 44 | drop_table :branches 45 | drop_table :commits 46 | drop_table :builds 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1312117285_non_unique_repo_uri.rb: -------------------------------------------------------------------------------- 1 | class NonUniqueRepoUri < ActiveRecord::Migration 2 | def self.up 3 | remove_index :repositories, :uri 4 | add_index :repositories, :uri 5 | end 6 | 7 | def self.down 8 | add_index :repositories, :uri, :unique => true 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1312198807_repo_enabled.rb: -------------------------------------------------------------------------------- 1 | class RepoEnabled < ActiveRecord::Migration 2 | def self.up 3 | add_column :repositories, :enabled, :boolean, :null => false, :default => true 4 | add_index :repositories, :enabled 5 | end 6 | 7 | def self.down 8 | remove_column :repositories, :enabled 9 | remove_index :repositories, :enabled 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1313867551_add_build_output_column.rb: -------------------------------------------------------------------------------- 1 | class AddBuildOutputColumn < ActiveRecord::Migration 2 | def self.up 3 | add_column :builds, :output, :text, :null => true, :default => nil 4 | end 5 | 6 | def self.down 7 | remove_column :builds, :output 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1313871652_add_commit_url_column.rb: -------------------------------------------------------------------------------- 1 | class AddCommitUrlColumn < ActiveRecord::Migration 2 | def self.up 3 | add_column :commits, :url, :string, :null => false 4 | end 5 | 6 | def self.down 7 | remove_column :commits, :url 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1317384618_add_repo_hook_url.rb: -------------------------------------------------------------------------------- 1 | class AddRepoHookUrl < ActiveRecord::Migration 2 | def self.up 3 | add_column :repositories, :hook_url, :string, :null => true 4 | end 5 | 6 | def self.down 7 | remove_column :repositories, :hook_url 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1317384619_add_build_room_id.rb: -------------------------------------------------------------------------------- 1 | class AddBuildRoomId < ActiveRecord::Migration 2 | def self.up 3 | add_column :builds, :room_id, :integer, :null => true 4 | end 5 | 6 | def self.down 7 | remove_column :builds, :room_id 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1317384629_drop_default_room_id.rb: -------------------------------------------------------------------------------- 1 | class DropDefaultRoomId < ActiveRecord::Migration 2 | def self.up 3 | change_column :repositories, :room_id, :integer, :default => nil, :null => true 4 | end 5 | 6 | def self.down 7 | change_column :repositories, :room_id, :integer, :default => 376289, :null => false 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1317384649_github_team_id.rb: -------------------------------------------------------------------------------- 1 | class GithubTeamId < ActiveRecord::Migration 2 | def self.up 3 | add_column :repositories, :github_team_id, :integer, :null => true 4 | end 5 | 6 | def self.down 7 | remove_column :repositories, :github_team_id 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1317384650_add_build_indexes.rb: -------------------------------------------------------------------------------- 1 | class AddBuildIndexes < ActiveRecord::Migration 2 | def self.up 3 | add_index :builds, :commit_id 4 | add_index :builds, :branch_id 5 | end 6 | 7 | def self.down 8 | remove_index :builds, :commit_id 9 | remove_index :builds, :branch_id 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1317384651_add_more_build_indexes.rb: -------------------------------------------------------------------------------- 1 | class AddMoreBuildIndexes < ActiveRecord::Migration 2 | def self.up 3 | add_index :builds, :started_at 4 | add_index :builds, :completed_at 5 | add_index :builds, :green 6 | end 7 | 8 | def self.down 9 | remove_index :builds, :started_at 10 | remove_index :builds, :completed_at 11 | remove_index :builds, :green 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1317384652_change_commit_message_to_text.rb: -------------------------------------------------------------------------------- 1 | class ChangeCommitMessageToText < ActiveRecord::Migration 2 | def change 3 | change_column :commits, :message, :text, :null => false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1317384653_add_build_pusher.rb: -------------------------------------------------------------------------------- 1 | class AddBuildPusher < ActiveRecord::Migration 2 | def self.up 3 | add_column :builds, :user, :string, :null => true 4 | end 5 | 6 | def self.down 7 | remove_column :builds, :user 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1317384654_add_build_queued_at.rb: -------------------------------------------------------------------------------- 1 | class AddBuildQueuedAt < ActiveRecord::Migration 2 | def self.up 3 | add_column :builds, :queued_at, :datetime, :null => true 4 | Janky::Build.started.each do |b| 5 | b.update_attributes!(:queued_at => b.created_at) 6 | end 7 | end 8 | 9 | def self.down 10 | remove_column :builds, :queued_at 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1317384655_add_template.rb: -------------------------------------------------------------------------------- 1 | class AddTemplate < ActiveRecord::Migration 2 | def self.up 3 | add_column :repositories, :job_template, :string, :null => true 4 | end 5 | 6 | def self.down 7 | remove_column :repositories, :job_template 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1398262033_add_context.rb: -------------------------------------------------------------------------------- 1 | class AddContext < ActiveRecord::Migration 2 | def self.up 3 | add_column :repositories, :context, :string, :null => true 4 | end 5 | 6 | def self.down 7 | remove_column :repositories, :context 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/janky/database/migrate/1400144784_change_room_id_to_string.rb: -------------------------------------------------------------------------------- 1 | class ChangeRoomIdToString < ActiveRecord::Migration 2 | def change 3 | change_column :repositories, :room_id, :string 4 | change_column :builds, :room_id, :string 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/janky/database/schema.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # This file is auto-generated from the current state of the database. Instead 3 | # of editing this file, please use the migrations feature of Active Record to 4 | # incrementally modify your database, and then regenerate this schema definition. 5 | # 6 | # Note that this schema.rb definition is the authoritative source for your 7 | # database schema. If you need to create the application database on another 8 | # system, you should be using db:schema:load, not running all the migrations 9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 10 | # you'll amass, the slower it'll run and the greater likelihood for issues). 11 | # 12 | # It's strongly recommended that you check this file into your version control system. 13 | 14 | ActiveRecord::Schema.define(version: 1400144784) do 15 | 16 | create_table "branches", force: :cascade do |t| 17 | t.string "name", limit: 255, null: false 18 | t.integer "repository_id", limit: 4, null: false 19 | t.datetime "created_at" 20 | t.datetime "updated_at" 21 | end 22 | 23 | add_index "branches", ["name", "repository_id"], name: "index_branches_on_name_and_repository_id", unique: true, using: :btree 24 | 25 | create_table "builds", force: :cascade do |t| 26 | t.boolean "green", default: false 27 | t.string "url", limit: 255 28 | t.string "compare", limit: 255, null: false 29 | t.datetime "started_at" 30 | t.datetime "completed_at" 31 | t.integer "commit_id", limit: 4, null: false 32 | t.integer "branch_id", limit: 4, null: false 33 | t.datetime "created_at" 34 | t.datetime "updated_at" 35 | t.text "output", limit: 65535 36 | t.string "room_id", limit: 255 37 | t.string "user", limit: 255 38 | t.datetime "queued_at" 39 | end 40 | 41 | add_index "builds", ["branch_id"], name: "index_builds_on_branch_id", using: :btree 42 | add_index "builds", ["commit_id"], name: "index_builds_on_commit_id", using: :btree 43 | add_index "builds", ["completed_at"], name: "index_builds_on_completed_at", using: :btree 44 | add_index "builds", ["green"], name: "index_builds_on_green", using: :btree 45 | add_index "builds", ["started_at"], name: "index_builds_on_started_at", using: :btree 46 | add_index "builds", ["url"], name: "index_builds_on_url", unique: true, using: :btree 47 | 48 | create_table "commits", force: :cascade do |t| 49 | t.string "sha1", limit: 255, null: false 50 | t.text "message", limit: 65535, null: false 51 | t.string "author", limit: 255, null: false 52 | t.datetime "committed_at" 53 | t.integer "repository_id", limit: 4, null: false 54 | t.datetime "created_at" 55 | t.datetime "updated_at" 56 | t.string "url", limit: 255, null: false 57 | end 58 | 59 | add_index "commits", ["sha1", "repository_id"], name: "index_commits_on_sha1_and_repository_id", unique: true, using: :btree 60 | 61 | create_table "repositories", force: :cascade do |t| 62 | t.string "name", limit: 255, null: false 63 | t.string "uri", limit: 255, null: false 64 | t.string "room_id", limit: 255 65 | t.datetime "created_at" 66 | t.datetime "updated_at" 67 | t.boolean "enabled", default: true, null: false 68 | t.string "hook_url", limit: 255 69 | t.integer "github_team_id", limit: 4 70 | t.string "job_template", limit: 255 71 | t.string "context", limit: 255 72 | end 73 | 74 | add_index "repositories", ["enabled"], name: "index_repositories_on_enabled", using: :btree 75 | add_index "repositories", ["name"], name: "index_repositories_on_name", unique: true, using: :btree 76 | add_index "repositories", ["uri"], name: "index_repositories_on_uri", using: :btree 77 | 78 | end 79 | -------------------------------------------------------------------------------- /lib/janky/database/seed.dump.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/janky/e2e76cc4c3b1421688b9a4feb77ffc5df761562e/lib/janky/database/seed.dump.gz -------------------------------------------------------------------------------- /lib/janky/exception.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Exception 3 | def self.setup(notifier) 4 | @notifier = notifier 5 | end 6 | 7 | def self.report(exception, context={}) 8 | @notifier.report(exception, context) 9 | end 10 | 11 | def self.push(context) 12 | @notifier.push(context) 13 | end 14 | 15 | def self.reset! 16 | @notifier.reset! 17 | end 18 | 19 | def self.push_http_response(response) 20 | push( 21 | :response_code => response.code.inspect, 22 | :response_body => response.body.inspect 23 | ) 24 | end 25 | 26 | class Logger 27 | def initialize(stream) 28 | @stream = stream 29 | @context = {} 30 | end 31 | 32 | def reset! 33 | @context = {} 34 | end 35 | 36 | def report(e, context={}) 37 | @stream.puts "ERROR: #{e.class} - #{e.message}\n" 38 | @context.each do |k, v| 39 | @stream.puts "%12s %4s\n" % [k, v] 40 | end 41 | @stream.puts "\n#{e.backtrace.join("\n")}" 42 | end 43 | 44 | def push(context) 45 | @context.update(context) 46 | end 47 | end 48 | 49 | class Mock 50 | def self.push(context) 51 | end 52 | 53 | def self.report(e, context={}) 54 | end 55 | 56 | def self.reset! 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/janky/github.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module GitHub 3 | # Setup the GitHub API client and Post-Receive hook endpoint. 4 | # 5 | # user - API user as a String. 6 | # password - API password as a String. 7 | # secret - Secret used to sign hook requests from GitHub. 8 | # hook_url - String URL that handles Post-Receive requests. 9 | # api_url - GitHub API URL as a String. Requires a trailing slash. 10 | # git_host - Hostname where git repos are hosted. e.g. "github.com" 11 | # 12 | # Returns nothing. 13 | def self.setup(user, password, secret, hook_url, api_url, git_host) 14 | @user = user 15 | @password = password 16 | @secret = secret 17 | @hook_url = hook_url 18 | @api_url = api_url 19 | @git_host = git_host 20 | end 21 | 22 | class << self 23 | attr_reader :secret, :git_host 24 | end 25 | 26 | # URL of the GitHub website. 27 | # 28 | # Retuns the URL as a String. Example: https://github.com 29 | def self.github_url 30 | api_uri = URI.parse(@api_url) 31 | "#{api_uri.scheme}://#{@git_host}" 32 | end 33 | 34 | # Rack app that handles Post-Receive hook requests from GitHub. 35 | # 36 | # Returns a GitHub::Receiver. 37 | def self.receiver 38 | @receiver ||= Receiver.new(@secret) 39 | end 40 | 41 | # Fetch repository details. 42 | # http://developer.github.com/v3/repos/#get 43 | # 44 | # nwo - qualified "owner/repo" name. 45 | # 46 | # Returns the Hash representation of the repo, nil when it doesn't exists 47 | # or access was denied. 48 | # Raises an Error for any unexpected response. 49 | def self.repo_get(nwo) 50 | response = api.repo_get(nwo) 51 | 52 | case response.code 53 | when "200" 54 | Yajl.load(response.body) 55 | when "403", "404" 56 | nil 57 | else 58 | Exception.push_http_response(response) 59 | raise Error, "Failed to get hook" 60 | end 61 | end 62 | 63 | # Fetch the SHA1 of the given branch HEAD. 64 | # 65 | # nwo - qualified "owner/repo" name. 66 | # branch - Name of the branch as a String. 67 | # 68 | # Returns the SHA1 as a String or nil when the branch doesn't exists. 69 | def self.branch_head_sha(nwo, branch) 70 | response = api.branch(nwo, branch) 71 | 72 | branch = Yajl.load(response.body) 73 | branch && branch["sha"] 74 | end 75 | 76 | # Fetch commit details for the given SHA1. 77 | # 78 | # nwo - qualified "owner/repo" name. 79 | # sha - SHA1 of the commit as a String. 80 | # 81 | # Example 82 | # 83 | # commit("github/janky", "35fff49dc18376845dd37e785c1ea88c6133f928") 84 | # => { "commit" => { 85 | # "author" => { 86 | # "name" => "Simon Rozet", 87 | # "email" => "sr@github.com", 88 | # }, 89 | # "message" => "document and clean up Branch#build_for_head", 90 | # } 91 | # } 92 | # 93 | # Returns the commit Hash. 94 | def self.commit(nwo, sha) 95 | response = api.commit(nwo, sha) 96 | 97 | if response.code != "200" 98 | Exception.push_http_response(response) 99 | raise Error, "Failed to get commit" 100 | end 101 | 102 | Yajl.load(response.body) 103 | end 104 | 105 | # Create a Post-Receive hook for the given repository. 106 | # http://developer.github.com/v3/repos/hooks/#create-a-hook 107 | # 108 | # nwo - qualified "owner/repo" name. 109 | # 110 | # Returns the newly created hook URL as String when successful. 111 | # Raises an Error for any other response. 112 | def self.hook_create(nwo) 113 | response = api.create(nwo, @secret, @hook_url) 114 | 115 | if response.code == "201" 116 | Yajl.load(response.body)["url"] 117 | else 118 | Exception.push_http_response(response) 119 | raise Error, "Failed to create hook" 120 | end 121 | end 122 | 123 | # Check existance of a hook. 124 | # http://developer.github.com/v3/repos/hooks/#get-single-hook 125 | # 126 | # url - Hook URL as a String. 127 | def self.hook_exists?(url) 128 | api.get(url).code == "200" 129 | end 130 | 131 | # Delete a post-receive hook for the given repository. 132 | # 133 | # hook_url - The repository's hook_url 134 | # 135 | # Returns true or raises an exception. 136 | def self.hook_delete(url) 137 | response = api.delete(url) 138 | 139 | if response.code == "204" 140 | true 141 | else 142 | Exception.push_http_response(response) 143 | raise Error, "Failed to delete hook" 144 | end 145 | end 146 | 147 | # Default API implementation that goes over the wire (HTTP). 148 | # 149 | # Returns nothing. 150 | def self.api 151 | @api ||= API.new(@api_url, @user, @password) 152 | end 153 | 154 | # Turn on mock mode, meaning no request goes over the wire. Useful in 155 | # testing environments. 156 | # 157 | # Returns nothing. 158 | def self.enable_mock! 159 | @api = Mock.new(@user, @password) 160 | end 161 | 162 | # Make any subsequent response for the given repository look like as if 163 | # it was a private repo. 164 | # 165 | # nwo - qualified "owner/repo" name. 166 | # 167 | # Returns nothing. 168 | def self.repo_make_private(nwo) 169 | api.make_private(nwo) 170 | end 171 | 172 | # Make any subsequent request to the given repository succeed. Only 173 | # available in mock mode. 174 | # 175 | # nwo - qualified "owner/repo" name. 176 | # 177 | # Returns nothing. 178 | def self.repo_make_public(nwo) 179 | api.make_public(nwo) 180 | end 181 | 182 | # Make any subsequent request for the given repository fail with an 183 | # unauthorized response. Only available when mocked. 184 | # 185 | # nwo - qualified "owner/repo" name. 186 | # 187 | # Returns nothing. 188 | def self.repo_make_unauthorized(nwo) 189 | api.make_unauthorized(nwo) 190 | end 191 | 192 | # Set the SHA of the named branch for the given repo. Mock only. 193 | def self.set_branch_head(nwo, branch, sha) 194 | api.set_branch_head(nwo, branch, sha) 195 | end 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /lib/janky/github/api.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module GitHub 3 | class API 4 | def initialize(url, user, password) 5 | @url = url 6 | @user = user 7 | @password = password 8 | end 9 | 10 | def create(nwo, secret, url) 11 | request = Net::HTTP::Post.new(build_path("repos/#{nwo}/hooks")) 12 | payload = build_payload(url, secret) 13 | request.body = Yajl.dump(payload) 14 | request.basic_auth(@user, @password) 15 | 16 | http.request(request) 17 | end 18 | 19 | def delete(hook_url) 20 | path = build_path(URI(hook_url).path) 21 | request = Net::HTTP::Delete.new(path) 22 | request.basic_auth(@user, @password) 23 | 24 | http.request(request) 25 | end 26 | 27 | def trigger(hook_url) 28 | path = build_path(URI(hook_url).path + "/test") 29 | request = Net::HTTP::Post.new(path) 30 | request.basic_auth(@user, @password) 31 | 32 | http.request(request) 33 | end 34 | 35 | def get(hook_url) 36 | path = build_path(URI(hook_url).path) 37 | request = Net::HTTP::Get.new(path) 38 | request.basic_auth(@user, @password) 39 | 40 | http.request(request) 41 | end 42 | 43 | def repo_get(nwo) 44 | path = build_path("repos/#{nwo}") 45 | request = Net::HTTP::Get.new(path) 46 | request.basic_auth(@user, @password) 47 | 48 | http.request(request) 49 | end 50 | 51 | def branch(nwo, branch) 52 | path = build_path("repos/#{nwo}/commits/#{branch}") 53 | request = Net::HTTP::Get.new(path) 54 | request.basic_auth(@user, @password) 55 | 56 | http.request(request) 57 | end 58 | 59 | def commit(nwo, sha) 60 | path = build_path("repos/#{nwo}/commits/#{sha}") 61 | request = Net::HTTP::Get.new(path) 62 | request.basic_auth(@user, @password) 63 | 64 | http.request(request) 65 | end 66 | 67 | def build_path(path) 68 | if path[0] == ?/ 69 | URI.join(@url, path[1..-1]).path 70 | else 71 | URI.join(@url, path).path 72 | end 73 | end 74 | 75 | def build_payload(url, secret) 76 | { "name" => "web", 77 | "active" => true, 78 | "config" => { 79 | "url" => url, 80 | "secret" => secret, 81 | "content_type" => "json" 82 | } 83 | } 84 | end 85 | 86 | def http 87 | @http ||= http! 88 | end 89 | 90 | def http! 91 | uri = URI(@url) 92 | http = Net::HTTP.new(uri.host, uri.port) 93 | 94 | http.use_ssl = true 95 | http.verify_mode = OpenSSL::SSL::VERIFY_PEER 96 | http.ca_path = "/etc/ssl/certs" 97 | 98 | http 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/janky/github/commit.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module GitHub 3 | class Commit 4 | def initialize(sha1, url, message, author, time) 5 | @sha1 = sha1 6 | @url = url 7 | @message = message 8 | @author = author 9 | @time = time 10 | end 11 | 12 | attr_reader :sha1, :url, :message, :author 13 | 14 | def committed_at 15 | @time 16 | end 17 | 18 | def to_hash 19 | { :id => @sha1, 20 | :url => @url, 21 | :message => @message, 22 | :author => {:name => @author}, 23 | :timestamp => @time } 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/janky/github/mock.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module GitHub 3 | class Mock 4 | Response = Struct.new(:code, :body) 5 | 6 | def initialize(user, password) 7 | @repos = {} 8 | @branch_shas = {} 9 | end 10 | 11 | def make_private(nwo) 12 | @repos[nwo] = :private 13 | end 14 | 15 | def make_public(nwo) 16 | @repos[nwo] = :public 17 | end 18 | 19 | def make_unauthorized(nwo) 20 | @repos[nwo] = :unauthorized 21 | end 22 | 23 | def set_branch_head(nwo, branch, sha) 24 | @branch_shas[[nwo, branch]] = sha 25 | end 26 | 27 | def create(nwo, secret, url) 28 | data = {"url" => "https://api.github.com/hooks/#{Time.now.to_f}"} 29 | Response.new("201", Yajl.dump(data)) 30 | end 31 | 32 | def get(url) 33 | Response.new("200") 34 | end 35 | 36 | def delete(url) 37 | Response.new("204") 38 | end 39 | 40 | def repo_get(nwo) 41 | repo = { 42 | "name" => nwo.split("/").last, 43 | "private" => (@repos[nwo] == :private), 44 | "git_url" => "git://github.com/#{nwo}", 45 | "ssh_url" => "git@github.com:#{nwo}" 46 | } 47 | 48 | if @repos[nwo] == :unauthorized 49 | Response.new("404", Yajl.dump({})) 50 | else 51 | Response.new("200", Yajl.dump(repo)) 52 | end 53 | end 54 | 55 | def branch(nwo, branch) 56 | 57 | data = { "sha" => @branch_shas[[nwo, branch]] } 58 | 59 | Response.new("200", Yajl.dump(data)) 60 | end 61 | 62 | def commit(nwo, sha) 63 | data = { 64 | "commit" => { 65 | "author" => { 66 | "name" => "Test Author", 67 | "email" => "test@github.com" 68 | }, 69 | "message" => "Test Message" 70 | } 71 | } 72 | 73 | Response.new("200", Yajl.dump(data)) 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/janky/github/payload.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module GitHub 3 | class Payload 4 | def self.parse(json) 5 | parsed = PayloadParser.new(json) 6 | new(parsed.uri, parsed.branch, parsed.head, parsed.pusher, 7 | parsed.commits, 8 | parsed.compare) 9 | end 10 | 11 | def initialize(uri, branch, head, pusher, commits, compare) 12 | @uri = uri 13 | @branch = branch 14 | @head = head 15 | @pusher = pusher 16 | @commits = commits 17 | @compare = compare 18 | end 19 | 20 | attr_reader :uri, :branch, :head, :pusher, :commits, :compare 21 | 22 | def head_commit 23 | @commits.detect do |commit| 24 | commit.sha1 == @head 25 | end 26 | end 27 | 28 | def to_json 29 | { :after => @head, 30 | :ref => "refs/heads/#{@branch}", 31 | :pusher => {:name => @pusher}, 32 | :uri => @uri, 33 | :commits => @commits, 34 | :compare => @compare }.to_json 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/janky/github/payload_parser.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module GitHub 3 | class PayloadParser 4 | def initialize(json) 5 | @payload = Yajl.load(json) 6 | end 7 | 8 | def pusher 9 | @payload["pusher"]["name"] 10 | end 11 | 12 | def head 13 | @payload["after"] 14 | end 15 | 16 | def compare 17 | @payload["compare"] 18 | end 19 | 20 | def commits 21 | @payload["commits"].map do |commit| 22 | GitHub::Commit.new( 23 | commit["id"], 24 | commit["url"], 25 | commit["message"], 26 | normalize_author(commit["author"]), 27 | commit["timestamp"] 28 | ) 29 | end 30 | end 31 | 32 | def normalize_author(author) 33 | if email = author["email"] 34 | "#{author["name"]} <#{email}>" 35 | else 36 | author 37 | end 38 | end 39 | 40 | def uri 41 | if uri = @payload["uri"] 42 | return uri 43 | end 44 | 45 | repository = @payload["repository"] 46 | 47 | if repository["private"] 48 | "git@#{GitHub.git_host}:#{URI(repository["url"]).path[1..-1]}" 49 | else 50 | uri = URI(repository["url"]) 51 | uri.scheme = "git" 52 | uri.to_s 53 | end 54 | end 55 | 56 | def branch 57 | @payload["ref"].split("refs/heads/").last 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/janky/github/receiver.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module GitHub 3 | # Rack app handling GitHub Post-Receive [1] requests. 4 | # 5 | # The JSON payload is parsed into a GitHub::Payload. We then find the 6 | # associated Repository record based on the Payload's repository git URL 7 | # and create the associated records: Branch, Commit and Build. 8 | # 9 | # Finally, we trigger a new Jenkins build. 10 | # 11 | # [1]: http://help.github.com/post-receive-hooks/ 12 | class Receiver 13 | def initialize(secret) 14 | @secret = secret 15 | end 16 | 17 | def call(env) 18 | dup.call!(env) 19 | end 20 | 21 | def call!(env) 22 | @request = Rack::Request.new(env) 23 | 24 | if !valid_signature? 25 | return Rack::Response.new("Invalid signature", 403).finish 26 | end 27 | 28 | if @request.content_type != "application/json" 29 | return Rack::Response.new("Invalid Content-Type", 400).finish 30 | end 31 | 32 | if !payload.head_commit 33 | return Rack::Response.new("Ignored", 400).finish 34 | end 35 | 36 | result = BuildRequest.handle( 37 | payload.uri, 38 | payload.branch, 39 | payload.pusher, 40 | payload.head_commit, 41 | payload.compare, 42 | @request.POST["room"] 43 | ) 44 | 45 | Rack::Response.new("OK: #{result}", 201).finish 46 | end 47 | 48 | def valid_signature? 49 | digest = OpenSSL::Digest::SHA1.new 50 | signature = @request.env["HTTP_X_HUB_SIGNATURE"].split("=").last 51 | 52 | signature == OpenSSL::HMAC.hexdigest(digest, @secret, data) 53 | end 54 | 55 | def payload 56 | @payload ||= GitHub::Payload.parse(data) 57 | end 58 | 59 | def data 60 | @data ||= data! 61 | end 62 | 63 | def data! 64 | body = "" 65 | @request.body.each { |chunk| body << chunk } 66 | body 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/janky/helpers.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Helpers 3 | def self.registered(app) 4 | app.enable :raise_errors 5 | app.disable :show_exceptions 6 | app.helpers self 7 | end 8 | 9 | def find_repo(name) 10 | unless repo = Repository.find_by_name(name) 11 | halt(404, "Unknown repository: #{name.inspect}") 12 | end 13 | 14 | repo 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/janky/hubot.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | # Web API taylored for Hubot's needs. Supports setting up and disabling 3 | # repositories, querying the status of branch or a repository and triggering 4 | # builds. 5 | # 6 | # The client side implementation is at 7 | # 8 | class Hubot < Sinatra::Base 9 | register Helpers 10 | 11 | # Setup a new repository. 12 | post "/setup" do 13 | nwo = params["nwo"] 14 | name = params["name"] 15 | tmpl = params["template"] 16 | repo = Repository.setup(nwo, name, tmpl) 17 | 18 | if repo 19 | url = "#{settings.base_url}#{repo.name}" 20 | [201, "Setup #{repo.name} at #{repo.uri} with #{repo.job_config_path.basename} | #{url}"] 21 | else 22 | [400, "Couldn't access #{nwo}. Check the permissions."] 23 | end 24 | end 25 | 26 | # Activate/deactivate auto-build for the given repository. 27 | post "/toggle/:repo_name" do |repo_name| 28 | repo = find_repo(repo_name) 29 | status = repo.toggle_auto_build ? "enabled" : "disabled" 30 | 31 | [200, "#{repo.name} is now #{status}"] 32 | end 33 | 34 | # Build a repository's branch. 35 | post %r{\/([-_\.0-9a-zA-Z]+)\/([-_\.a-zA-z0-9\/]+)} do |repo_name, branch_name| 36 | repo = find_repo(repo_name) 37 | branch = repo.branch_for(branch_name) 38 | room_id = (params["room_id"] rescue nil) 39 | user = params["user"] 40 | build = branch.head_build_for(room_id, user) 41 | build ||= repo.build_sha(branch_name, user, room_id) 42 | 43 | if build 44 | build.run 45 | [201, "Going ham on #{build.repo_name}/#{build.branch_name}"] 46 | else 47 | [404, "Unknown branch #{branch_name.inspect}. Push again"] 48 | end 49 | end 50 | 51 | # Get a list of available rooms. 52 | get "/rooms" do 53 | Yajl.dump(ChatService.room_names) 54 | end 55 | 56 | # Update a repository's notification room. 57 | put "/:repo_name" do |repo_name| 58 | repo = find_repo(repo_name) 59 | room = params["room"] 60 | 61 | if room_id = ChatService.room_id(room) 62 | repo.update_attributes!(:room_id => room_id) 63 | [200, "Room for #{repo.name} updated to #{room}"] 64 | else 65 | [403, "Unknown room: #{room.inspect}"] 66 | end 67 | end 68 | 69 | # Update a repository's context 70 | put %r{\/([-_\.0-9a-zA-Z]+)\/context} do |repo_name| 71 | context = params["context"] 72 | repo = find_repo(repo_name) 73 | 74 | if repo 75 | repo.context = context 76 | repo.save 77 | [200, "Context #{context} set for #{repo_name}"] 78 | else 79 | [404, "Unknown Repository #{repo_name}"] 80 | end 81 | end 82 | 83 | # Get the status of all projects. 84 | get "/" do 85 | content_type "text/plain" 86 | repos = Repository.includes(:branches, :commits, :builds).all.map do |repo| 87 | master = repo.branch_for("master") 88 | 89 | "%-17s %-13s %-10s %40s" % [ 90 | repo.name, 91 | master.status, 92 | repo.campfire_room, 93 | repo.uri 94 | ] 95 | end 96 | repos.join("\n") 97 | end 98 | 99 | # Get the lasts builds 100 | get "/builds" do 101 | limit = params["limit"] 102 | building = params["building"] 103 | 104 | builds = Build.unscoped 105 | if building.blank? || building == 'false' 106 | builds = builds.completed 107 | else 108 | builds = builds.building 109 | end 110 | builds = builds.limit(limit) unless limit.blank? 111 | 112 | builds.map do |build| 113 | build_to_hash(build) 114 | end 115 | 116 | builds.to_json 117 | end 118 | 119 | # Get information about how a project is configured 120 | get %r{\/show\/([-_\.0-9a-zA-Z]+)} do |repo_name| 121 | repo = find_repo(repo_name) 122 | res = { 123 | :name => repo.name, 124 | :configured_job_template => repo.job_template, 125 | :used_job_template => repo.job_config_path.basename.to_s, 126 | :repo => repo.uri, 127 | :room_id => repo.room_id, 128 | :enabled => repo.enabled, 129 | :hook_url => repo.hook_url, 130 | :context => repo.context 131 | } 132 | res.to_json 133 | end 134 | 135 | delete %r{\/([-_\.0-9a-zA-Z]+)} do |repo_name| 136 | repo = find_repo(repo_name) 137 | repo.destroy 138 | "Janky project #{repo_name} deleted" 139 | end 140 | 141 | # Delete a repository's context 142 | delete %r{\/([-_\.0-9a-zA-Z]+)\/context} do |repo_name| 143 | repo = find_repo(repo_name) 144 | 145 | if repo 146 | repo.context = nil 147 | repo.save 148 | [200, "Context removed for #{repo_name}"] 149 | else 150 | [404, "Unknown Repository #{repo_name}"] 151 | end 152 | end 153 | 154 | # Get the status of a repository's branch. 155 | get %r{\/([-_\.0-9a-zA-Z]+)\/([-_\+\.a-zA-z0-9\/]+)} do |repo_name, branch_name| 156 | limit = params["limit"] 157 | 158 | repo = find_repo(repo_name) 159 | branch = repo.branch_for(branch_name) 160 | builds = branch.queued_builds.limit(limit).map do |build| 161 | build_to_hash(build) 162 | end 163 | 164 | builds.to_json 165 | end 166 | 167 | # Learn everything you need to know about Janky. 168 | get "/help" do 169 | content_type "text/plain" 170 | <<-EOS 171 | ci build janky 172 | ci build janky/fix-everything 173 | ci setup github/janky [name] 174 | ci setup github/janky name template 175 | ci toggle janky 176 | ci rooms 177 | ci set room janky development 178 | ci set context janky ci/janky 179 | ci unset context janky 180 | ci status 181 | ci status janky 182 | ci status janky/master 183 | ci builds limit [building] 184 | ci show janky 185 | ci delete janky 186 | EOS 187 | end 188 | 189 | get "/boomtown" do 190 | fail "BOOM (janky)" 191 | end 192 | 193 | private 194 | 195 | def build_to_hash(build) 196 | { :sha1 => build.sha1, 197 | :repo => build.repo_name, 198 | :branch => build.branch_name, 199 | :user => build.user, 200 | :green => build.green?, 201 | :building => build.building?, 202 | :queued => build.queued?, 203 | :pending => build.pending?, 204 | :number => build.number, 205 | :status => (build.green? ? "was successful" : "failed"), 206 | :compare => build.compare, 207 | :duration => build.duration, 208 | :web_url => build.web_url } 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /lib/janky/job_creator.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | class JobCreator 3 | def initialize(server_url, callback_url) 4 | @server_url = server_url 5 | @callback_url = callback_url 6 | end 7 | 8 | def run(name, uri, template_path) 9 | creator.run(name, uri, template_path) 10 | end 11 | 12 | def creator 13 | @creator ||= Creator.new(HTTP, @server_url, @callback_url) 14 | end 15 | 16 | def enable_mock! 17 | @creator = Creator.new(Mock.new, @server_url, @callback_url) 18 | end 19 | 20 | class Creator 21 | def initialize(adapter, server_url, callback_url) 22 | @adapter = adapter 23 | @server_url = server_url 24 | @callback_url = callback_url 25 | end 26 | 27 | def run(name, uri, template_path) 28 | template = Tilt.new(template_path.to_s) 29 | config = template.render(Object.new, { 30 | :name => name, 31 | :repo => uri, 32 | :callback_url => @callback_url 33 | }) 34 | 35 | exception_context(config, name, uri) 36 | 37 | if !@adapter.exists?(@server_url, name) 38 | @adapter.run(@server_url, name, config) 39 | true 40 | end 41 | end 42 | 43 | def exception_context(config, name, uri) 44 | Exception.push( 45 | :server_url => @server_url.inspect, 46 | :callback_url => @callback_url.inspect, 47 | :adapter => @adapter.inspect, 48 | :config => config.inspect, 49 | :name => name.inspect, 50 | :repo => uri.inspect 51 | ) 52 | end 53 | end 54 | 55 | class Mock 56 | def run(server_url, name, config) 57 | name || raise(Error, "no name") 58 | config || raise(Error, "no config") 59 | (URI === server_url) || raise(Error, "server_url is not a URI") 60 | 61 | true 62 | end 63 | 64 | def exists?(server_url, name) 65 | false 66 | end 67 | end 68 | 69 | class HTTP 70 | def self.exists?(server_url, name) 71 | uri = server_url 72 | user = uri.user 73 | pass = uri.password 74 | path = uri.path 75 | http = Net::HTTP.new(uri.host, uri.port) 76 | if uri.scheme == "https" 77 | http.use_ssl = true 78 | end 79 | 80 | get = Net::HTTP::Get.new("#{path}/job/#{name}/") 81 | get.basic_auth(user, pass) if user && pass 82 | response = http.request(get) 83 | 84 | case response.code 85 | when "200" 86 | true 87 | when "404" 88 | false 89 | else 90 | Exception.push_http_response(response) 91 | raise "Failed to determine job existance" 92 | end 93 | end 94 | 95 | def self.run(server_url, name, config) 96 | uri = server_url 97 | user = uri.user 98 | pass = uri.password 99 | path = uri.path 100 | http = Net::HTTP.new(uri.host, uri.port) 101 | if uri.scheme == "https" 102 | http.use_ssl = true 103 | end 104 | 105 | post = Net::HTTP::Post.new("#{path}/createItem?name=#{name}") 106 | post.basic_auth(user, pass) if user && pass 107 | post["Content-Type"] = "application/xml" 108 | post.body = config 109 | 110 | response = http.request(post) 111 | 112 | unless response.code == "200" 113 | Exception.push_http_response(response) 114 | raise Error, "Failed to create job" 115 | end 116 | end 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/janky/notifier.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Notifier 3 | # Setup the notifier. 4 | # 5 | # notifiers - One or more notifiers implementation to notify with. 6 | # 7 | # Returns nothing. 8 | def self.setup(notifiers) 9 | @adapter = Multi.new(Array(notifiers)) 10 | end 11 | 12 | # Called whenever a build is queued 13 | # 14 | # build - the Build record. 15 | # 16 | # Returns nothing 17 | def self.queued(build) 18 | adapter.queued(build) 19 | end 20 | 21 | # Called whenever a build starts. 22 | # 23 | # build - the Build record. 24 | # 25 | # Returns nothing. 26 | def self.started(build) 27 | adapter.started(build) 28 | end 29 | 30 | # Called whenever a build completes. 31 | # 32 | # build - the Build record. 33 | # 34 | # Returns nothing. 35 | def self.completed(build) 36 | adapter.completed(build) 37 | end 38 | 39 | # The implementation used to send notifications. 40 | # 41 | # Returns a Multi instance by default or Mock when in mock mode. 42 | def self.adapter 43 | @adapter ||= Multi.new(@notifiers) 44 | end 45 | 46 | # Enable mocking. Once enabled, notifications are stored in a 47 | # in-memory Array exposed by the notifications method. 48 | # 49 | # Returns nothing. 50 | def self.enable_mock! 51 | @adapter = Mock.new 52 | end 53 | 54 | # Reset notification log. Only available when mocked. Typically called 55 | # before each test. 56 | # 57 | # Returns nothing. 58 | def self.reset! 59 | adapter.reset! 60 | end 61 | 62 | # Was any notification sent out? Only available when mocked. 63 | # 64 | # Returns a Boolean. 65 | def self.empty? 66 | notifications.empty? 67 | end 68 | 69 | # Was a success notification sent to the given room for the given 70 | # repo and branch? 71 | # 72 | # repo - the String repository name. 73 | # branch - the String branch name. 74 | # room - the optional String Campfire room slug. 75 | # 76 | # Returns a boolean. 77 | def self.success?(repo, branch, room=nil) 78 | adapter.success?(repo, branch, room) 79 | end 80 | 81 | # Same as `success?` but for failed notifications. 82 | def self.failure?(repo, branch, room=nil) 83 | adapter.failure?(repo, branch, room) 84 | end 85 | 86 | # Access the notification log. Only available when mocked. 87 | # 88 | # Returns an Array of notified Builds. 89 | def self.notifications 90 | adapter.notifications 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/janky/notifier/chat_service.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Notifier 3 | class ChatService 4 | def self.completed(build) 5 | status = build.green? ? "was successful" : "failed" 6 | color = build.green? ? "green" : "red" 7 | 8 | message = "Build #%s (%s) of %s/%s %s (%ss) %s" % [ 9 | build.number, 10 | build.short_sha1, 11 | build.repo_name, 12 | build.branch_name, 13 | status, 14 | build.duration, 15 | build.web_url 16 | ] 17 | 18 | ::Janky::ChatService.speak(message, build.room_id, {:color => color, :build => build}) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/janky/notifier/failure_service.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Notifier 3 | class FailureService < ChatService 4 | def self.completed(build) 5 | return unless need_failure_notification?(build) 6 | ::Janky::ChatService.speak(message(build), failure_room, {:color => color(build)}) 7 | end 8 | 9 | def self.failure_room 10 | ENV["JANKY_CHAT_FAILURE_ROOM"] 11 | end 12 | 13 | def self.need_failure_notification?(build) 14 | build.red? && failure_room != build.room_id 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/janky/notifier/github_status.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Notifier 3 | # Create GitHub Status updates for builds. 4 | # 5 | # Note that Statuses are immutable - so we create one for 6 | # "pending" status when a build starts, then create a new status for 7 | # "success" or "failure" when the build is complete. 8 | class GithubStatus 9 | # Initialize with an OAuth token to POST Statuses with 10 | def initialize(token, api_url, context = nil) 11 | @token = token 12 | @api_url = URI(api_url) 13 | @default_context = context 14 | end 15 | 16 | def context(build) 17 | repository_context(build.repository) || @default_context 18 | end 19 | 20 | def repository_context(repository) 21 | repository && repository.context 22 | end 23 | 24 | # Create a Pending Status for the Commit when it is queued. 25 | def queued(build) 26 | repo = build.repo_nwo 27 | path = "repos/#{repo}/statuses/#{build.sha1}" 28 | 29 | post(path, "pending", build.web_url, "Build ##{build.number} queued", context(build)) 30 | end 31 | 32 | # Create a Pending Status for the Commit when it starts. 33 | def started(build) 34 | repo = build.repo_nwo 35 | path = "repos/#{repo}/statuses/#{build.sha1}" 36 | 37 | post(path, "pending", build.web_url, "Build ##{build.number} started", context(build)) 38 | end 39 | 40 | # Create a Success or Failure Status for the Commit. 41 | def completed(build) 42 | repo = build.repo_nwo 43 | path = "repos/#{repo}/statuses/#{build.sha1}" 44 | status = build.green? ? "success" : "failure" 45 | 46 | desc = case status 47 | when "success" then "Build ##{build.number} succeeded in #{build.duration}s" 48 | when "failure" then "Build ##{build.number} failed in #{build.duration}s" 49 | end 50 | 51 | post(path, status, build.web_url, desc, context(build)) 52 | end 53 | 54 | # Internal: POST the new status to the API 55 | def post(path, status, url, desc, context = nil) 56 | http = Net::HTTP.new(@api_url.host, @api_url.port) 57 | post = Net::HTTP::Post.new("#{@api_url.path}#{path}") 58 | 59 | http.use_ssl = true 60 | 61 | post["Content-Type"] = "application/json" 62 | post["Authorization"] = "token #{@token}" 63 | 64 | body = { 65 | :state => status, 66 | :target_url => url, 67 | :description => desc, 68 | } 69 | 70 | unless context.nil? 71 | post["Accept"] = "application/vnd.github.she-hulk-preview+json" 72 | body[:context] = context 73 | end 74 | 75 | post.body = body.to_json 76 | 77 | http.request(post) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/janky/notifier/mock.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Notifier 3 | # Mock notifier implementation used in testing environments. 4 | class Mock 5 | def initialize 6 | @notifications = [] 7 | end 8 | 9 | attr_reader :notifications 10 | 11 | def queued(build) 12 | end 13 | 14 | def reset! 15 | @notifications.clear 16 | end 17 | 18 | def started(build) 19 | end 20 | 21 | def completed(build) 22 | notify(:completed, build) 23 | end 24 | 25 | def notify(state, build) 26 | @notifications << [state, build] 27 | end 28 | 29 | def success?(repo, branch, room_name) 30 | room_name ||= Janky::ChatService.default_room_name 31 | 32 | builds = @notifications.select do |state, build| 33 | state == :completed && 34 | build.green? && 35 | build.repo_name == repo && 36 | build.branch_name == branch && 37 | build.room_name == room_name 38 | end 39 | 40 | builds.size == 1 41 | end 42 | 43 | def failure?(repo, branch, room_name) 44 | room_name ||= Janky::ChatService.default_room_name 45 | 46 | builds = @notifications.select do |state, build| 47 | state == :completed && 48 | build.red? && 49 | build.repo_name == repo && 50 | build.branch_name == branch && 51 | build.room_name == room_name 52 | end 53 | 54 | builds.size == 1 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/janky/notifier/multi.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Notifier 3 | # Dispatches notifications to multiple notifiers. 4 | class Multi 5 | def initialize(notifiers) 6 | @notifiers = notifiers 7 | end 8 | 9 | def queued(build) 10 | @notifiers.each do |notifier| 11 | notifier.queued(build) if notifier.respond_to?(:queued) 12 | end 13 | end 14 | 15 | def started(build) 16 | @notifiers.each do |notifier| 17 | notifier.started(build) if notifier.respond_to?(:started) 18 | end 19 | end 20 | 21 | def completed(build) 22 | @notifiers.each do |notifier| 23 | notifier.completed(build) if notifier.respond_to?(:completed) 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/janky/public/css/base.css: -------------------------------------------------------------------------------- 1 | /*------------------------------------------------------------------------------------ 2 | @group Global Reset 3 | ------------------------------------------------------------------------------------*/ 4 | * { 5 | padding:0; 6 | margin:0; 7 | } 8 | h1, h2, h3, h4, h5, h6, p, pre, blockquote, label, ul, ol, dl, fieldset, address { margin:1em 0; } 9 | li, dd { margin-left:5%; } 10 | fieldset { padding: .5em; } 11 | select option{ padding:0 5px; } 12 | 13 | .access{ display:none; } /* For accessibility related elements */ 14 | .clear{ clear:both; height:0px; font-size:0px; line-height:0px; overflow:hidden; } 15 | a{ outline:none; } 16 | a img{ border:none; } 17 | 18 | .clearfix:after { 19 | content: "."; 20 | display: block; 21 | height: 0; 22 | clear: both; 23 | visibility: hidden; 24 | } 25 | * html .clearfix {height: 1%;} 26 | .clearfix {display:inline-block;} 27 | .clearfix {display: block;} 28 | .right {float: right;} 29 | 30 | /* @end */ 31 | 32 | /*---------------------------------------------------------------------------- 33 | @group Base Layout 34 | ----------------------------------------------------------------------------*/ 35 | 36 | body{ 37 | margin:0; 38 | padding:0; 39 | font-size:14px; 40 | line-height:1.6; 41 | font-family:Helvetica, Arial, sans-serif; 42 | background:#fff; 43 | } 44 | 45 | #wrapper{ 46 | margin:0 auto; 47 | width:600px; 48 | } 49 | .wide #wrapper{ 50 | width:1000px; 51 | } 52 | 53 | h2#logo{ 54 | width:600px; 55 | margin:0 auto 25px auto; 56 | } 57 | h2#logo a{ 58 | display:block; 59 | height:156px; 60 | text-indent:-9999px; 61 | text-decoration:none; 62 | background:url(../images/logo.png); 63 | } 64 | 65 | .content{ 66 | padding:5px; 67 | background:#ededed; 68 | border-radius:4px; 69 | margin-bottom: 50px; 70 | } 71 | .content > .inside{ 72 | border:1px solid #ddd; 73 | background:#fff; 74 | border-radius:3px; 75 | } 76 | 77 | /* @end */ 78 | 79 | /*---------------------------------------------------------------------------- 80 | @group Builds 81 | ----------------------------------------------------------------------------*/ 82 | 83 | ul.builds{ 84 | margin:0; 85 | } 86 | 87 | ul.builds li{ 88 | list-style-type:none; 89 | margin:0; 90 | padding:12px 10px; 91 | border-bottom:1px solid #e5e5e5; 92 | border-top:1px solid #fff; 93 | background:-webkit-gradient(linear, left top, left bottom, from(#fdfdfd), to(#f2f2f2)); 94 | background:-moz-linear-gradient(top, #fdfdfd, #f2f2f2); 95 | } 96 | ul.builds li:first-child{ 97 | border-top:none; 98 | border-top-left-radius: 3px; 99 | border-top-right-radius: 3px; 100 | } 101 | ul.builds li:last-child{ 102 | border-bottom:none; 103 | border-bottom-left-radius: 3px; 104 | border-bottom-right-radius: 3px; 105 | } 106 | ul.builds li:hover{ 107 | background:-webkit-gradient(linear, left top, left bottom, from(#f5f9fb), to(#e9eef0)); 108 | background:-moz-linear-gradient(top, #f5f9fb, #e9eef0); 109 | } 110 | ul.builds li.building:hover{ 111 | background:-webkit-gradient(linear, left top, left bottom, from(#fdfdfd), to(#f2f2f2)); 112 | background:-moz-linear-gradient(top, #fdfdfd, #f2f2f2); 113 | } 114 | 115 | ul.builds a{ 116 | text-decoration:none; 117 | } 118 | 119 | ul.builds a.console{ 120 | float: right; 121 | display:block; 122 | width: 22px; 123 | height: 40px; 124 | margin-left: 10px; 125 | background:url(../images/disclosure-arrow.png) 65% 10px no-repeat; 126 | } 127 | ul.builds li:hover a.console{ 128 | background-position:65% -90px; 129 | } 130 | 131 | ul.builds .status{ 132 | float:left; 133 | margin-top:5px; 134 | margin-right:10px; 135 | width:37px; 136 | height:34px; 137 | background:url(../images/robawt-status.gif) 0 0 no-repeat; 138 | } 139 | ul.builds .building .status{ 140 | background:url(../images/building-bot.gif); 141 | } 142 | ul.builds .janky .status{ 143 | background-position:0 -200px; 144 | } 145 | ul.builds .pending .status{ 146 | background-position:0 -100px; 147 | } 148 | 149 | ul.builds h2{ 150 | margin:0; 151 | font-size:16px; 152 | text-shadow:0 1px #fff; 153 | } 154 | ul.builds h2 span{ 155 | font-weight: normal; 156 | color: #666666; 157 | } 158 | ul.builds .good a{ 159 | color:#358c00; 160 | } 161 | ul.builds .good h2{ 162 | color:#358c00; 163 | } 164 | ul.builds .building a{ 165 | color:#e59741; 166 | } 167 | ul.builds .building h2{ 168 | color:#e59741; 169 | } 170 | ul.builds .pending a{ 171 | color:#e59741; 172 | } 173 | ul.builds .pending h2{ 174 | color:#e59741; 175 | } 176 | ul.builds .janky a{ 177 | color:#ae0000; 178 | } 179 | ul.builds .janky h2{ 180 | color:#ae0000; 181 | } 182 | ul.builds p.sha1{ 183 | margin-top: 2px; 184 | } 185 | ul.builds p{ 186 | margin:-2px 0 0 0; 187 | font-size:13px; 188 | font-weight:200; 189 | color:#666; 190 | text-shadow:0 1px #fff; 191 | } 192 | ul.builds .building p{ 193 | color:#999; 194 | } 195 | 196 | /* @end */ 197 | 198 | /*---------------------------------------------------------------------------- 199 | @group Text Styles 200 | ----------------------------------------------------------------------------*/ 201 | 202 | pre{ 203 | margin:10px; 204 | font-size:12px; 205 | overflow:auto; 206 | } 207 | pre::-webkit-scrollbar { 208 | height: 8px; 209 | width: 8px; 210 | } 211 | pre::-webkit-scrollbar-track-piece{ 212 | margin-bottom:10px; 213 | background-color: #e5e5e5; 214 | border-bottom-left-radius: 4px 4px; 215 | border-bottom-right-radius: 4px 4px; 216 | border-top-left-radius: 4px 4px; 217 | border-top-right-radius: 4px 4px; 218 | } 219 | 220 | pre::-webkit-scrollbar-thumb:vertical{ 221 | height: 25px; 222 | background-color: #ccc; 223 | -webkit-border-radius: 4px; 224 | -webkit-box-shadow: 0 1px 1px rgba(255,255,255,1); 225 | } 226 | pre::-webkit-scrollbar-thumb:horizontal{ 227 | width: 25px; 228 | background-color: #ccc; 229 | -webkit-border-radius: 4px; 230 | } 231 | 232 | /* @end */ 233 | -------------------------------------------------------------------------------- /lib/janky/public/images/building-bot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/janky/e2e76cc4c3b1421688b9a4feb77ffc5df761562e/lib/janky/public/images/building-bot.gif -------------------------------------------------------------------------------- /lib/janky/public/images/disclosure-arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/janky/e2e76cc4c3b1421688b9a4feb77ffc5df761562e/lib/janky/public/images/disclosure-arrow.png -------------------------------------------------------------------------------- /lib/janky/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/janky/e2e76cc4c3b1421688b9a4feb77ffc5df761562e/lib/janky/public/images/logo.png -------------------------------------------------------------------------------- /lib/janky/public/images/robawt-status.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/janky/e2e76cc4c3b1421688b9a4feb77ffc5df761562e/lib/janky/public/images/robawt-status.gif -------------------------------------------------------------------------------- /lib/janky/public/javascripts/application.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | function refresh() { 3 | $('.builds').load(location.pathname + ' .builds', function() { 4 | $('.relatize').relatize() 5 | }) 6 | } 7 | 8 | setInterval(refresh, 5000) 9 | $('.relatize').relatize() 10 | }) 11 | -------------------------------------------------------------------------------- /lib/janky/public/javascripts/jquery.relatize.js: -------------------------------------------------------------------------------- 1 | // jQuery Port of Rick Olson's relatize date Prototype plugin 2 | (function($) { 3 | $.fn.relatize = function() { 4 | return $(this).each(function() { 5 | if ($(this).hasClass( 'relatized' )) return 6 | $(this).text( $.relatize(this) ).addClass( 'relatized' ) 7 | }) 8 | } 9 | 10 | $.relatize = function(element) { 11 | var dateStr = $(element).text() 12 | var dateObj = new Date(dateStr) 13 | if (isNaN(dateObj)){ 14 | // Rails outputs something like Thu Nov 12 16:00:33 -0800 2009 15 | // IE7 can't parse this, it wants something like 16 | // Thu Nov 12 2009 16:00:33 -0800 17 | var regex = /(\d\d:\d\d:\d\d [+-]\d{4}) (\d{4})$/ 18 | dateObj = new Date(dateStr.replace(regex, "$2 $1")) 19 | if (isNaN(dateObj)){ 20 | return dateStr; 21 | } 22 | } 23 | return $.relatize.timeAgoInWords(dateObj) 24 | } 25 | 26 | // shortcut 27 | var $r = $.relatize 28 | 29 | $.extend($.relatize, { 30 | shortDays: [ 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' ], 31 | days: [ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 32 | 'Friday', 'Saturday' ], 33 | shortMonths: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 34 | 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ], 35 | months: [ 'January', 'February', 'March', 'April', 'May', 'June', 36 | 'July', 'August', 'September', 'October', 'November', 37 | 'December' ], 38 | 39 | /** 40 | * Given a formatted string, replace the necessary items and return. 41 | * Example: Time.now().strftime("%B %d, %Y") => February 11, 2008 42 | * @param {String} format The formatted string used to format the results 43 | */ 44 | strftime: function(date, format) { 45 | var day = date.getDay(), month = date.getMonth(); 46 | var hours = date.getHours(), minutes = date.getMinutes(); 47 | 48 | var pad = function(num) { 49 | var string = num.toString(10); 50 | return new Array((2 - string.length) + 1).join('0') + string 51 | }; 52 | 53 | return format.replace(/\%([aAbBcdHImMpSwyY])/g, function(part) { 54 | switch(part.substr(1, 1)) { 55 | case 'a': return $r.shortDays[day]; break; 56 | case 'A': return $r.days[day]; break; 57 | case 'b': return $r.shortMonths[month]; break; 58 | case 'B': return $r.months[month]; break; 59 | case 'c': return date.toString(); break; 60 | case 'd': return pad(date.getDate()); break; 61 | case 'H': return pad(hours); break; 62 | case 'I': return pad((hours + 12) % 12); break; 63 | case 'm': return pad(month + 1); break; 64 | case 'M': return pad(minutes); break; 65 | case 'p': return hours > 12 ? 'PM' : 'AM'; break; 66 | case 'S': return pad(date.getSeconds()); break; 67 | case 'w': return day; break; 68 | case 'y': return pad(date.getFullYear() % 100); break; 69 | case 'Y': return date.getFullYear().toString(); break; 70 | } 71 | }) 72 | }, 73 | 74 | timeAgoInWords: function(targetDate, includeTime) { 75 | return $r.distanceOfTimeInWords(targetDate, new Date(), includeTime); 76 | }, 77 | 78 | /** 79 | * Return the distance of time in words between two Date's 80 | * Example: '5 days ago', 'about an hour ago' 81 | * @param {Date} fromTime The start date to use in the calculation 82 | * @param {Date} toTime The end date to use in the calculation 83 | * @param {Boolean} Include the time in the output 84 | */ 85 | distanceOfTimeInWords: function(fromTime, toTime, includeTime) { 86 | var delta = parseInt((toTime.getTime() - fromTime.getTime()) / 1000); 87 | if (delta < 60) { 88 | return 'just now'; 89 | } else if (delta < 120) { 90 | return 'about a minute ago'; 91 | } else if (delta < (45*60)) { 92 | return (parseInt(delta / 60)).toString() + ' minutes ago'; 93 | } else if (delta < (120*60)) { 94 | return 'about an hour ago'; 95 | } else if (delta < (24*60*60)) { 96 | return 'about ' + (parseInt(delta / 3600)).toString() + ' hours ago'; 97 | } else if (delta < (48*60*60)) { 98 | return '1 day ago'; 99 | } else { 100 | var days = (parseInt(delta / 86400)).toString(); 101 | if (days > 5) { 102 | var fmt = '%B %d, %Y' 103 | if (includeTime) fmt += ' %I:%M %p' 104 | return $r.strftime(fromTime, fmt); 105 | } else { 106 | return days + " days ago" 107 | } 108 | } 109 | } 110 | }) 111 | })(jQuery); 112 | -------------------------------------------------------------------------------- /lib/janky/repository.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | class Repository < ActiveRecord::Base 3 | has_many :branches, :dependent => :destroy 4 | has_many :commits, :dependent => :destroy 5 | has_many :builds, :through => :branches 6 | 7 | after_commit :delete_hook, :on => :destroy 8 | 9 | replicate_associations :builds, :commits, :branches 10 | 11 | default_scope { order("name") } 12 | 13 | def self.setup(nwo, name = nil, template = nil) 14 | if nwo.nil? 15 | raise ArgumentError, "nwo can't be nil" 16 | end 17 | 18 | if repo = Repository.find_by_name(nwo) 19 | repo.update_attributes!(:job_template => template) 20 | repo.setup 21 | return repo 22 | end 23 | 24 | repo = GitHub.repo_get(nwo) 25 | return if !repo 26 | 27 | uri = repo["private"] ? repo["ssh_url"] : repo["git_url"] 28 | name ||= repo["name"] 29 | uri.gsub!(/\.git$/, "") 30 | 31 | repo = 32 | if repo = Repository.find_by_name(name) 33 | repo.update_attributes!(:uri => uri, :job_template => template) 34 | repo 35 | else 36 | Repository.create!(:name => name, :uri => uri, :job_template => template) 37 | end 38 | 39 | repo.setup 40 | repo 41 | end 42 | 43 | # Find a named repository. 44 | # 45 | # name - The String name of the repository. 46 | # 47 | # Returns a Repository or nil when it doesn't exists. 48 | def self.by_name(name) 49 | find_by_name(name) 50 | end 51 | 52 | # Toggle auto-build feature of this repo. When enabled (default), 53 | # all branches are built automatically. 54 | # 55 | # Returns the new flag status as a Boolean. 56 | def toggle_auto_build 57 | toggle(:enabled) 58 | save! 59 | enabled 60 | end 61 | 62 | # Create or retrieve the named branch. 63 | # 64 | # name - The branch's name as a String. 65 | # 66 | # Returns a Branch record. 67 | def branch_for(name) 68 | branches.find_or_create_by(name: name) 69 | end 70 | 71 | # Create or retrieve the given commit. 72 | # 73 | # commit - The Hash representation of the Commit. 74 | # 75 | # Returns a Commit record. 76 | def commit_for(commit) 77 | commits.find_by_sha1(commit[:sha1]) || 78 | commits.create!(commit) 79 | end 80 | 81 | def commit_for_sha(sha1) 82 | commit_data = GitHub.commit(nwo, sha1) 83 | commit_message = commit_data["commit"]["message"] 84 | commit_url = github_url("commit/#{sha1}") 85 | author_data = commit_data["commit"]["author"] 86 | commit_author = 87 | if email = author_data["email"] 88 | "#{author_data["name"]} <#{email}>" 89 | else 90 | author_data["name"] 91 | end 92 | 93 | commit = commit_for({ 94 | :repository => self, 95 | :sha1 => sha1, 96 | :author => commit_author, 97 | :message => commit_message, 98 | :url => commit_url, 99 | }) 100 | end 101 | 102 | # Create a Janky::Build object given a sha 103 | # 104 | # sha1 - a string of the target sha to build 105 | # user - The login of the GitHub user who pushed. 106 | # room_id - optional Fixnum Campfire room ID. Defaults to the room set on 107 | # compare - optional String GitHub Compare View URL. Defaults to the 108 | # 109 | # Returns the newly created Janky::Build 110 | def build_sha(sha1, user, room_id = nil, compare = nil) 111 | return nil unless sha1 =~ /^[0-9a-fA-F]{7,40}$/ 112 | commit = commit_for_sha(sha1) 113 | commit.build!(user, room_id, compare) 114 | end 115 | 116 | # Jenkins host executing this repo's builds. 117 | # 118 | # Returns a Builder::Client. 119 | def builder 120 | Builder.pick_for(self) 121 | end 122 | 123 | # GitHub user owning this repo. 124 | # 125 | # Returns the user name as a String. 126 | def github_owner 127 | uri[/.*[\/:]([a-zA-Z0-9\-_]+)\//] && $1 128 | end 129 | 130 | # Name of this repository on GitHub. 131 | # 132 | # Returns the name as a String. 133 | def github_name 134 | uri[/.*[\/:]([a-zA-Z0-9\-_]+)\/([a-zA-Z0-9\-_\.]+)/] && $2 135 | end 136 | 137 | # Fully qualified GitHub name for this repository. 138 | # 139 | # Returns the name as a String. Example: github/janky. 140 | def nwo 141 | "#{github_owner}/#{github_name}" 142 | end 143 | 144 | # Append the given path to the GitHub URL of this repository. 145 | # 146 | # path - String path. No slash necessary at the front. 147 | # 148 | # Examples 149 | # 150 | # github_url("issues") 151 | # => "https://github.com/github/janky/issues" 152 | # 153 | # Returns the URL as a String. 154 | def github_url(path) 155 | "#{GitHub.github_url}/#{nwo}/#{path}" 156 | end 157 | 158 | # Name of the Campfire room receiving build notifications. 159 | # 160 | # Returns the name as a String. 161 | def campfire_room 162 | ChatService.room_name(room_id) 163 | end 164 | 165 | # Ditto but returns the String room id. Defaults to the one set 166 | # in Campfire.setup. 167 | def room_id 168 | read_attribute(:room_id) || ChatService.default_room_id 169 | end 170 | 171 | # Setups GitHub and Jenkins for building this repository. 172 | # 173 | # Returns nothing. 174 | def setup 175 | setup_job 176 | setup_hook 177 | end 178 | 179 | # Create a GitHub hook for this Repository and store its URL if needed. 180 | # 181 | # Returns nothing. 182 | def setup_hook 183 | delete_hook 184 | 185 | url = GitHub.hook_create("#{github_owner}/#{github_name}") 186 | update_attributes!(:hook_url => url) 187 | end 188 | 189 | def delete_hook 190 | if self.hook_url? && GitHub.hook_exists?(self.hook_url) 191 | GitHub.hook_delete(self.hook_url) 192 | end 193 | end 194 | 195 | # Creates a job on the Jenkins server for this repository configuration 196 | # unless one already exists. Can safely be run multiple times. 197 | # 198 | # Returns nothing. 199 | def setup_job 200 | builder.setup(job_name, uri, job_config_path) 201 | end 202 | 203 | # The path of the Jenkins configuration template. Try 204 | # ".xml.erb" first, ".xml.erb" second, and then 205 | # fallback to "default.xml.erb" under the root config directory. 206 | # 207 | # Returns the template path as a Pathname. 208 | def job_config_path 209 | user_override = Janky.jobs_config_dir.join("#{job_template.downcase}.xml.erb") if job_template 210 | custom = Janky.jobs_config_dir.join("#{name.downcase}.xml.erb") 211 | default = Janky.jobs_config_dir.join("default.xml.erb") 212 | 213 | if user_override && user_override.readable? 214 | user_override 215 | elsif custom.readable? 216 | custom 217 | elsif default.readable? 218 | default 219 | else 220 | raise Error, "no config.xml.erb template for repo #{id.inspect}" 221 | end 222 | end 223 | 224 | # Construct the URL pointing to this Repository's Jenkins job. 225 | # 226 | # Returns the String URL. 227 | def job_url 228 | builder.url + "job/#{job_name}" 229 | end 230 | 231 | # Calculate the name of the Jenkins job. 232 | # 233 | # Returns a String hash of this Repository name and uri. 234 | def job_name 235 | md5 = Digest::MD5.new 236 | md5 << name 237 | md5 << uri 238 | md5 << job_config_path.read 239 | md5 << builder.callback_url.to_s 240 | "#{name}-#{md5.hexdigest[0,12]}" 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /lib/janky/tasks.rb: -------------------------------------------------------------------------------- 1 | require "rake" 2 | require "rake/tasklib" 3 | 4 | module Janky 5 | module Tasks 6 | extend Rake::DSL 7 | 8 | namespace :db do 9 | desc "Run the migration(s)" 10 | task :migrate do 11 | path = db_dir.join("migrate").to_s 12 | ActiveRecord::Migration.verbose = true 13 | ActiveRecord::Migrator.migrate(path) 14 | 15 | Rake::Task["db:schema:dump"].invoke 16 | end 17 | 18 | namespace :schema do 19 | desc "Dump the database schema into a standard Rails schema.rb file" 20 | task :dump do 21 | require "active_record/schema_dumper" 22 | 23 | path = db_dir.join("schema.rb").to_s 24 | 25 | File.open(path, "w:utf-8") do |fd| 26 | ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, fd) 27 | end 28 | end 29 | end 30 | end 31 | 32 | def self.db_dir 33 | @db_dir ||= Pathname(__FILE__).expand_path.join("../database") 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/janky/templates/console.mustache: -------------------------------------------------------------------------------- 1 |

2 | {{ repo_name }}/{{ branch_name }}/{{ commit_short_sha }}
3 | {{ jenkins_url }} 4 |

5 |
{{ output }}
6 | -------------------------------------------------------------------------------- /lib/janky/templates/index.mustache: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /lib/janky/templates/layout.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{title}} 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 |
16 |
17 | {{{yield}}} 18 |
19 |
20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /lib/janky/version.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | VERSION = "0.13.0.pre1" 3 | end 4 | -------------------------------------------------------------------------------- /lib/janky/views/console.rb: -------------------------------------------------------------------------------- 1 | module Janky 2 | module Views 3 | class Console < Layout 4 | def repo_name 5 | @build.repo_name 6 | end 7 | 8 | def repo_path 9 | "#{root}/#{repo_name}" 10 | end 11 | 12 | def branch_name 13 | @build.branch_name 14 | end 15 | 16 | def branch_path 17 | "#{repo_path}/#{branch_name}" 18 | end 19 | 20 | def commit_url 21 | @build.commit_url 22 | end 23 | 24 | def commit_short_sha 25 | @build.short_sha1 26 | end 27 | 28 | def output 29 | @build.output 30 | end 31 | 32 | def jenkins_url 33 | @build.url 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/janky/views/index.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | module Janky 3 | module Views 4 | class Index < Layout 5 | def jobs 6 | @builds.collect do |build| 7 | { 8 | :console_path => "/#{build.number}/output", 9 | :compare_url => build.compare, 10 | :repo_path => "/#{build.repo_name}", 11 | :branch_path => "/#{build.repo_name}/#{build.branch_name}", 12 | :repo_name => build.repo_name, 13 | :branch_name => build.branch_name, 14 | :status => css_status_for(build), 15 | :last_built_text => last_built_text_for(build), 16 | :message => build.commit_message, 17 | :sha1 => build.short_sha1, 18 | :author => build.commit_author.split("<").first 19 | } 20 | end 21 | end 22 | 23 | def css_status_for(build) 24 | if build.green? 25 | "good" 26 | elsif build.building? 27 | "building" 28 | elsif build.pending? 29 | "pending" 30 | elsif build.red? 31 | "janky" 32 | end 33 | end 34 | 35 | def last_built_text_for(build) 36 | if build.building? 37 | "Build started #{build.started_at}…" 38 | elsif build.completed? 39 | "Built in #{build.duration} seconds" 40 | elsif build.pending? 41 | "Build queued #{build.queued_at}…" 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/janky/views/layout.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | module Janky 3 | module Views 4 | class Layout < Mustache 5 | def title 6 | ENV["JANKY_PAGE_TITLE"] || "Janky Hubot" 7 | end 8 | 9 | def page_class 10 | nil 11 | end 12 | 13 | def root 14 | @request.env['SCRIPT_NAME'] 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | docker-compose build 3 | -------------------------------------------------------------------------------- /script/cibuild: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | script/bootstrap 5 | script/setup 6 | docker-compose run -e RACK_ENV=test --rm app script/test 7 | -------------------------------------------------------------------------------- /script/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | : ${RACK_ENV:="development"} 3 | : ${JANKY_BASE_URL:="http://localhost:9393/"} 4 | export RACK_ENV JANKY_BASE_URL 5 | bin/shotgun -o0.0.0.0 -p9393 -sthin -Ilib config.ru 6 | -------------------------------------------------------------------------------- /script/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | appdir=$(cd "$(dirname "$0")/.." && pwd) 6 | 7 | # Create the databases 8 | docker-compose run --rm app mysql -h db -uroot -e "create database if not exists janky_development" 9 | docker-compose run --rm app mysql -h db -uroot -e "create database if not exists janky_test" 10 | 11 | # Create the tables 12 | docker-compose run --rm app bin/rake db:migrate 13 | docker-compose run --rm -e RACK_ENV=test app bin/rake db:migrate 14 | 15 | docker-compose run --rm app bin/rake db:seed 16 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'test/unit' 3 | exit Test::Unit::AutoRunner.run(true, nil, Dir.glob("test/*_test.rb")) 4 | -------------------------------------------------------------------------------- /test/commit_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../test_helper", __FILE__) 2 | 3 | class CommitTest < Test::Unit::TestCase 4 | def setup 5 | Janky.setup(environment) 6 | Janky.enable_mock! 7 | Janky.reset! 8 | 9 | DatabaseCleaner.clean_with(:truncation) 10 | end 11 | 12 | test "responds to #last_build" do 13 | assert_respond_to Janky::Commit.new, :last_build 14 | end 15 | 16 | test "responds to #build!" do 17 | assert_respond_to Janky::Commit.new, :build! 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/custom.xml.erb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/janky/e2e76cc4c3b1421688b9a4feb77ffc5df761562e/test/custom.xml.erb -------------------------------------------------------------------------------- /test/default.xml.erb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github/janky/e2e76cc4c3b1421688b9a4feb77ffc5df761562e/test/default.xml.erb -------------------------------------------------------------------------------- /test/github_status_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../test_helper", __FILE__) 2 | 3 | class GithubStatusTest < Test::Unit::TestCase 4 | def stub_build 5 | @stub_build = stub(:repo_nwo => "github/janky", 6 | :sha1 => "xxxx", 7 | :green? => true, 8 | :number => 1, 9 | :duration => 1, 10 | :repository => stub(:context => "ci/janky"), 11 | :web_url => "http://example.com/builds/1") 12 | end 13 | 14 | def setup 15 | # never allow any outgoing requests 16 | Net::HTTP.any_instance.stubs(:request) 17 | end 18 | 19 | test "sending successful status uses the right path" do 20 | post = stub_everything 21 | Net::HTTP::Post.expects(:new).with("/repos/github/janky/statuses/xxxx").returns(post) 22 | notifier = Janky::Notifier::GithubStatus.new("token", "http://example.com/") 23 | notifier.completed(stub_build) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/janky_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../test_helper", __FILE__) 2 | 3 | class JankyTest < Test::Unit::TestCase 4 | def setup 5 | Janky.setup(environment) 6 | Janky.enable_mock! 7 | Janky.reset! 8 | 9 | DatabaseCleaner.clean_with(:truncation) 10 | 11 | Janky::ChatService.rooms = {1 => "enterprise", "2" => "builds"} 12 | Janky::ChatService.default_room_name = "builds" 13 | 14 | hubot_setup("github/github") 15 | end 16 | 17 | test "green build" do 18 | Janky::Builder.green! 19 | gh_post_receive("github") 20 | Janky::Builder.start! 21 | Janky::Builder.complete! 22 | 23 | assert Janky::Notifier.success?("github", "master") 24 | end 25 | 26 | test "fail build" do 27 | Janky::Builder.red! 28 | gh_post_receive("github") 29 | Janky::Builder.start! 30 | Janky::Builder.complete! 31 | 32 | assert Janky::Notifier.failure?("github", "master") 33 | end 34 | 35 | test "pending build" do 36 | Janky::Builder.green! 37 | gh_post_receive("github") 38 | 39 | assert Janky::Notifier.empty? 40 | Janky::Builder.start! 41 | Janky::Builder.complete! 42 | assert Janky::Notifier.success?("github", "master") 43 | end 44 | 45 | test "builds multiple repo with the same uri" do 46 | Janky::Builder.green! 47 | hubot_setup("github/github", "fi") 48 | gh_post_receive("github") 49 | Janky::Builder.start! 50 | Janky::Builder.complete! 51 | 52 | assert Janky::Notifier.success?("github", "master") 53 | assert Janky::Notifier.success?("fi", "master") 54 | end 55 | 56 | test "notifies room that triggered the build" do 57 | Janky::Builder.green! 58 | gh_post_receive("github") 59 | Janky::Builder.start! 60 | Janky::Builder.complete! 61 | 62 | assert Janky::Notifier.success?("github", "master", "builds") 63 | 64 | hubot_build("github", "master", "enterprise") 65 | Janky::Builder.start! 66 | Janky::Builder.complete! 67 | 68 | assert Janky::Notifier.success?("github", "master", "enterprise") 69 | end 70 | 71 | test "dup commit same branch" do 72 | Janky::Builder.green! 73 | gh_post_receive("github", "master", "sha1") 74 | Janky::Builder.start! 75 | Janky::Builder.complete! 76 | 77 | assert Janky::Notifier.notifications.shift 78 | 79 | gh_post_receive("github", "master", "sha1") 80 | Janky::Builder.start! 81 | Janky::Builder.complete! 82 | 83 | assert Janky::Notifier.notifications.empty? 84 | end 85 | 86 | test "dup commit different branch" do 87 | Janky::Builder.green! 88 | gh_post_receive("github", "master", "sha1") 89 | Janky::Builder.start! 90 | Janky::Builder.complete! 91 | 92 | assert Janky::Notifier.notifications.shift 93 | 94 | gh_post_receive("github", "issues-dashboard", "sha1") 95 | Janky::Builder.start! 96 | Janky::Builder.complete! 97 | 98 | assert Janky::Notifier.notifications.empty? 99 | end 100 | 101 | test "dup commit currently building" do 102 | Janky::Builder.green! 103 | gh_post_receive("github", "master", "sha1") 104 | Janky::Builder.start! 105 | 106 | gh_post_receive("github", "issues-dashboard", "sha1") 107 | 108 | Janky::Builder.complete! 109 | 110 | assert_equal 1, Janky::Notifier.notifications.size 111 | assert Janky::Notifier.success?("github", "master") 112 | end 113 | 114 | test "dup commit currently red" do 115 | Janky::Builder.red! 116 | gh_post_receive("github", "master", "sha1") 117 | Janky::Builder.start! 118 | Janky::Builder.complete! 119 | 120 | assert Janky::Notifier.notifications.shift 121 | 122 | gh_post_receive("github", "master", "sha1") 123 | 124 | assert Janky::Notifier.notifications.empty? 125 | end 126 | 127 | test "dup commit disabled repo" do 128 | hubot_setup("github/github", "fi") 129 | hubot_toggle("fi") 130 | gh_post_receive("github", "master") 131 | Janky::Builder.start! 132 | Janky::Builder.complete! 133 | Janky::Notifier.reset! 134 | 135 | hubot_build("fi", "master") 136 | Janky::Builder.start! 137 | Janky::Builder.complete! 138 | assert Janky::Notifier.success?("fi", "master") 139 | end 140 | 141 | test "web dashboard" do 142 | assert get("/").ok? 143 | assert get("/janky").not_found? 144 | 145 | gh_post_receive("github") 146 | assert get("/").ok? 147 | assert get("/github").ok? 148 | 149 | Janky::Builder.start! 150 | assert get("/").ok? 151 | 152 | Janky::Builder.complete! 153 | assert get("/").ok? 154 | assert get("/github").ok? 155 | 156 | assert get("/github/master").ok? 157 | assert get("/github/strato").ok? 158 | 159 | assert get("#{Janky::Build.last.id}/output").ok? 160 | end 161 | 162 | test "hubot setup" do 163 | Janky::GitHub.repo_make_private("github/github") 164 | assert hubot_setup("github/github").body. 165 | include?("git@github.com:github/github") 166 | 167 | Janky::GitHub.repo_make_public("github/github") 168 | assert hubot_setup("github/github").body. 169 | include?("git://github.com/github/github") 170 | 171 | assert_equal 1, hubot_status.body.split("\n").size 172 | 173 | hubot_setup("github/janky") 174 | assert_equal 2, hubot_status.body.split("\n").size 175 | 176 | Janky::GitHub.repo_make_unauthorized("github/enterprise") 177 | assert hubot_setup("github/enterprise").body. 178 | include?("Couldn't access github/enterprise") 179 | 180 | assert_equal 201, hubot_setup("janky").status 181 | end 182 | 183 | test "hubot toggle" do 184 | hubot_toggle("github") 185 | gh_post_receive("github", "master", "deadbeef") 186 | Janky::Builder.start! 187 | Janky::Builder.complete! 188 | 189 | assert Janky::Notifier.empty? 190 | 191 | hubot_toggle("github") 192 | gh_post_receive("github", "master", "cream") 193 | Janky::Builder.start! 194 | Janky::Builder.complete! 195 | 196 | assert Janky::Notifier.success?("github", "master") 197 | end 198 | 199 | test "hubot status" do 200 | gh_post_receive("github") 201 | Janky::Builder.start! 202 | Janky::Builder.complete! 203 | 204 | status = hubot_status.body 205 | assert status.include?("github") 206 | assert status.include?("green") 207 | assert status.include?("builds") 208 | 209 | hubot_build("github", "master") 210 | assert hubot_status.body.include?("green") 211 | 212 | Janky::Builder.start! 213 | assert hubot_status.body.include?("building") 214 | 215 | hubot_setup("github/janky") 216 | assert hubot_status.body.include?("no build") 217 | 218 | hubot_setup("github/team") 219 | gh_post_receive("team") 220 | assert hubot_status.ok? 221 | end 222 | 223 | test "build user" do 224 | gh_post_receive("github", "master", "HEAD", "the dude") 225 | Janky::Builder.start! 226 | Janky::Builder.complete! 227 | 228 | response = hubot_status("github", "master") 229 | data = Yajl.load(response.body) 230 | assert_equal 1, data.size 231 | build = data[0] 232 | assert_equal "the dude", build["user"] 233 | 234 | hubot_build("github", "master", nil, "the boyscout") 235 | Janky::Builder.start! 236 | Janky::Builder.complete! 237 | 238 | response = hubot_status("github", "master") 239 | data = Yajl.load(response.body) 240 | assert_equal 2, data.size 241 | build = data[0] 242 | assert_equal "the boyscout", build["user"] 243 | end 244 | 245 | test "hubot status repo" do 246 | gh_post_receive("github") 247 | payload = Yajl.load(hubot_status("github", "master").body) 248 | assert_equal 1, payload.size 249 | build = payload[0] 250 | assert build["queued"] 251 | assert build["pending"] 252 | assert !build["building"] 253 | 254 | Janky::Builder.start! 255 | Janky::Builder.complete! 256 | hubot_build("github", "master") 257 | Janky::Builder.start! 258 | Janky::Builder.complete! 259 | 260 | payload = Yajl.load(hubot_status("github", "master").body) 261 | 262 | assert_equal 2, payload.size 263 | end 264 | 265 | test "hubot last 1 builds" do 266 | 3.times do 267 | gh_post_receive("github", "master") 268 | Janky::Builder.start! 269 | Janky::Builder.complete! 270 | end 271 | 272 | assert_equal 1, Yajl.load(hubot_last(limit: 1).body).size 273 | end 274 | 275 | test "hubot lasts completed" do 276 | gh_post_receive("github", "master") 277 | Janky::Builder.start! 278 | Janky::Builder.complete! 279 | 280 | assert_equal 1, Yajl.load(hubot_last.body).size 281 | end 282 | 283 | test "hubot lasts building" do 284 | gh_post_receive("github", "master") 285 | Janky::Builder.start! 286 | assert_equal 1, Yajl.load(hubot_last(building: true).body).size 287 | end 288 | 289 | test "hubot build" do 290 | gh_post_receive("github", "master") 291 | Janky::Builder.start! 292 | Janky::Builder.complete! 293 | 294 | assert hubot_build("github", "rails3").not_found? 295 | end 296 | 297 | test "hubot build sha" do 298 | gh_post_receive("github", "master", 'deadbeef') 299 | gh_post_receive("github", "master", 'cafebabe') 300 | Janky::Builder.start! 301 | Janky::Builder.complete! 302 | 303 | assert_equal "cafebabe", hubot_latest_build_sha("github", "master") 304 | 305 | hubot_build("github", "deadbeef") 306 | Janky::Builder.start! 307 | Janky::Builder.complete! 308 | assert_equal "deadbeef", hubot_latest_build_sha("github", "master") 309 | end 310 | 311 | test "getting latest commit" do 312 | gh_post_receive("github", "master") 313 | Janky::Builder.start! 314 | Janky::Builder.complete! 315 | 316 | assert_not_equal "deadbeef", hubot_latest_build_sha("github", "master") 317 | 318 | Janky::GitHub.set_branch_head("github/github", "master", "deadbeef") 319 | hubot_build("github", "master") 320 | Janky::Builder.start! 321 | Janky::Builder.complete! 322 | 323 | assert_equal "deadbeef", hubot_latest_build_sha("github", "master") 324 | assert_equal "deadbeef", Janky::Build.last.sha1 325 | assert_equal "Test Author ", Janky::Build.last.commit_author 326 | assert_equal "Test Message", Janky::Build.last.commit_message 327 | assert_equal "https://github.com/github/github/commit/deadbeef", Janky::Build.last.commit_url 328 | end 329 | 330 | test "hubot rooms" do 331 | response = hubot_request("GET", "/_hubot/rooms") 332 | rooms = Yajl.load(response.body) 333 | assert_equal ["builds", "enterprise"], rooms 334 | end 335 | 336 | test "hubot set room" do 337 | gh_post_receive("github", "master") 338 | Janky::Builder.start! 339 | Janky::Builder.complete! 340 | assert Janky::Notifier.success?("github", "master", "builds") 341 | 342 | Janky::Notifier.reset! 343 | 344 | hubot_update_room("github", "enterprise").ok? 345 | hubot_build("github", "master") 346 | Janky::Builder.start! 347 | Janky::Builder.complete! 348 | 349 | assert Janky::Notifier.success?("github", "master", "enterprise") 350 | end 351 | 352 | test "hubot 404s" do 353 | assert hubot_status("janky", "master").not_found? 354 | assert hubot_build("janky", "master").not_found? 355 | assert hubot_build("github", "master").not_found? 356 | end 357 | 358 | test "build janky url" do 359 | gh_post_receive("github") 360 | Janky::Builder.start! 361 | Janky::Builder.complete! 362 | 363 | assert_equal "http://localhost:9393/1/output", Janky::Build.last.web_url 364 | 365 | build_page = Janky::Build.last.repo_job_name + "/" + Janky::Build.last.number + "/" 366 | assert_equal "http://localhost:8080/job/" + build_page, Janky::Build.last.url 367 | end 368 | end 369 | -------------------------------------------------------------------------------- /test/repository_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../test_helper", __FILE__) 2 | 3 | class RepositoryTest < Test::Unit::TestCase 4 | def setup 5 | Janky.setup(environment) 6 | Janky.enable_mock! 7 | Janky.reset! 8 | 9 | DatabaseCleaner.clean_with(:truncation) 10 | end 11 | 12 | test "job name includes repo name" do 13 | repo = Janky::Repository.setup("github/janky") 14 | assert_match /\Ajanky-.+/, repo.job_name 15 | end 16 | 17 | test "job name includes custom name" do 18 | repo = Janky::Repository.setup("github/janky", "janky2") 19 | assert_match /\Ajanky2-.+/, repo.job_name 20 | end 21 | 22 | test "job name includes truncated MD5 digest" do 23 | repo = Janky::Repository.setup("github/janky") 24 | assert_match /-[0-9a-f]{12}$/, repo.job_name 25 | end 26 | 27 | test "github owner is parsed correctly" do 28 | repo = Janky::Repository.setup("github/janky") 29 | assert_equal "github", repo.github_owner 30 | assert_equal "janky", repo.github_name 31 | end 32 | 33 | test "owner with a dash is parsed correctly" do 34 | repo = Janky::Repository.setup("digital-science/central-ftp-manage") 35 | assert_equal "digital-science", repo.github_owner 36 | assert_equal "central-ftp-manage", repo.github_name 37 | end 38 | 39 | test "repository with period is parsed correctly" do 40 | repo = Janky::Repository.setup("github/pygments.rb") 41 | assert_equal "github", repo.github_owner 42 | assert_equal "pygments.rb", repo.github_name 43 | end 44 | 45 | test "raises if there is no job config" do 46 | repo = Janky::Repository.setup("github/pygments.rb") 47 | # ensure we get file not found for job configs 48 | Janky.stubs(:jobs_config_dir).returns(Pathname("/tmp/")) 49 | assert_raise(Janky::Error) do 50 | puts repo.job_config_path 51 | repo.job_config_path 52 | end 53 | end 54 | 55 | test "default job config is selected if none provided" do 56 | repo = Janky::Repository.setup("github/pygments.rb", "pygments") 57 | assert_nil repo.job_template 58 | assert_match /default\.xml\.erb/, repo.job_config_path.to_s 59 | end 60 | 61 | test "custom job config is stored" do 62 | repo = Janky::Repository.setup("github/pygments.rb", "pygments", "custom") 63 | assert_equal "custom", repo.job_template 64 | end 65 | 66 | test "custom job config path is calculated" do 67 | repo = Janky::Repository.setup("github/pygments.rb", "pygments", "custom") 68 | assert_equal "custom", repo.job_template 69 | assert_match /custom\.xml\.erb/, repo.job_config_path.to_s 70 | end 71 | 72 | end 73 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.expand_path("../../lib", __FILE__)) 2 | 3 | require "janky" 4 | require "test/unit" 5 | require "mocha/setup" 6 | require "database_cleaner" 7 | 8 | class Test::Unit::TestCase 9 | def self.test(name, &block) 10 | define_method("test_#{name.gsub(/\s+/,'_')}".to_sym, block) 11 | end 12 | 13 | def default_environment 14 | { "RACK_ENV" => "test", 15 | "JANKY_CONFIG_DIR" => File.dirname(__FILE__), 16 | "JANKY_GITHUB_USER" => "hubot", 17 | "JANKY_GITHUB_OAUTH_TOKEN" => "token", 18 | "JANKY_GITHUB_HOOK_SECRET" => "secret", 19 | "JANKY_HUBOT_USER" => "hubot", 20 | "JANKY_HUBOT_PASSWORD" => "password", 21 | "JANKY_CHAT_CAMPFIRE_ACCOUNT" => "github", 22 | "JANKY_CHAT_CAMPFIRE_TOKEN" => "token", 23 | "JANKY_CHAT_DEFAULT_ROOM" => "Builds", 24 | "JANKY_CHAT" => "campfire" 25 | } 26 | end 27 | 28 | def environment 29 | env = default_environment 30 | ENV.each do |key, value| 31 | if key =~ /^JANKY_/ || key == "DATABASE_URL" 32 | env[key] = value 33 | end 34 | end 35 | env 36 | end 37 | 38 | def gh_commit(sha1 = "HEAD") 39 | Janky::GitHub::Commit.new( 40 | sha1, 41 | "https://github.com/github/github/commit/#{sha1}", 42 | ":octocat:", 43 | "sr", 44 | Time.now 45 | ) 46 | end 47 | 48 | def gh_payload(repo, branch, pusher, commits) 49 | head = commits.first 50 | 51 | Janky::GitHub::Payload.new( 52 | repo.uri, 53 | branch, 54 | head.sha1, 55 | pusher, 56 | commits, 57 | "http://github/compare/#{branch}...master" 58 | ) 59 | end 60 | 61 | def get(path) 62 | Rack::MockRequest.new(Janky.app).get(path) 63 | end 64 | 65 | def gh_post_receive(repo_name, branch = "master", commit = "HEAD", 66 | pusher = "user") 67 | 68 | repo = Janky::Repository.find_by_name!(repo_name) 69 | payload = gh_payload(repo, branch, pusher, [gh_commit(commit)]) 70 | digest = OpenSSL::Digest::SHA1.new 71 | sig = OpenSSL::HMAC.hexdigest(digest, Janky::GitHub.secret, 72 | payload.to_json) 73 | 74 | Janky::GitHub.set_branch_head(repo.nwo, branch, commit) 75 | 76 | Rack::MockRequest.new(Janky.app).post("/_github", 77 | :input => payload.to_json, 78 | "CONTENT_TYPE" => "application/json", 79 | "HTTP_X_HUB_SIGNATURE" => "sha1=#{sig}" 80 | ) 81 | end 82 | 83 | def hubot_setup(nwo, name = nil) 84 | hubot_request("POST", "/_hubot/setup", :params => { 85 | :nwo => nwo, 86 | :name => name 87 | }) 88 | end 89 | 90 | def hubot_build(repo, branch, room_name = nil, user = nil) 91 | params = 92 | if room_id = Janky::ChatService.room_id(room_name) 93 | {"room_id" => room_id.to_s} 94 | else 95 | {} 96 | end 97 | 98 | if user 99 | params["user"] = user 100 | end 101 | 102 | hubot_request("POST", "/_hubot/#{repo}/#{branch}", :params => params) 103 | end 104 | 105 | def hubot_status(repo=nil, branch=nil) 106 | if repo && branch 107 | hubot_request("GET", "/_hubot/#{repo}/#{branch}") 108 | else 109 | hubot_request("GET", "/_hubot") 110 | end 111 | end 112 | 113 | def hubot_last(options = {}) 114 | hubot_request "GET", 115 | "/_hubot/builds?limit=#{options[:limit]}&building=#{options[:building]}" 116 | end 117 | 118 | def hubot_latest_build_sha(repo, branch) 119 | response = hubot_status(repo, branch) 120 | Yajl.load(response.body).first["sha1"] 121 | end 122 | 123 | def hubot_request(method, path, opts={}) 124 | auth = ["#{Janky::Hubot.username}:#{Janky::Hubot.password}"].pack("m*") 125 | env = {"HTTP_AUTHORIZATION" => "Basic #{auth}"} 126 | 127 | Rack::MockRequest.new(Janky.app).request(method, path, env.merge(opts)) 128 | end 129 | 130 | def hubot_toggle(repo) 131 | hubot_request("POST", "/_hubot/toggle/#{repo}") 132 | end 133 | 134 | def hubot_update_room(repo, room_name) 135 | hubot_request("PUT", "/_hubot/#{repo}", :params => { 136 | :room => room_name 137 | }) 138 | end 139 | end 140 | --------------------------------------------------------------------------------