├── .env.example ├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── console ├── gem_dandy └── rspec ├── lib ├── gem_dandy.rb ├── gem_dandy │ ├── bundler.rb │ ├── diff_parser.rb │ ├── env.rb │ ├── gem_change.rb │ ├── git_repo.rb │ ├── github.rb │ ├── github │ │ └── changelog.rb │ └── version.rb └── tasks │ └── heroku.rake └── spec ├── lib └── gem_dandy │ ├── bundler_spec.rb │ ├── diff_parser_spec.rb │ └── gem_change_spec.rb ├── spec_helper.rb └── support ├── Gemfile ├── Gemfile.lock └── helpers.rb /.env.example: -------------------------------------------------------------------------------- 1 | # Personal access token for your GemDandy user. Used to query and submit pull 2 | # requests for updated Gemfiles. This is requried for `bin/gem_dandy` to work. 3 | # 4 | # https://help.github.com/enterprise/2.12/user/articles/creating-a-personal-access-token-for-the-command-line 5 | # 6 | GITHUB_ACCESS_TOKEN= 7 | 8 | # == For Heroku Automatic Updates == 9 | 10 | # Name used in the git commit. Will fall back to local git config if present. 11 | # Required for heroku updates. 12 | # 13 | GIT_USER_NAME= 14 | 15 | 16 | # Email used in the git commit. Will fall back to local git config if present. 17 | # Required for heroku updates. 18 | # 19 | GIT_USER_EMAIL= 20 | 21 | # A comma separated list of GitHub repos and base branches which will 22 | # automatically be updated by `rake heroku:update` 23 | # 24 | REPOS_TO_UPDATE="seeclickfix/gem_dandy:master,jordanbyron/prawn-labels:master" 25 | 26 | # If your Gemfile has private gems using ssh you'll need to include a private 27 | # key for a user which has read access to those gems. Otherwise GemDandy will 28 | # not be able to pull them down to do the update. This private key should also 29 | # be able to clone and commit to the repos you are trying to update. 30 | # 31 | SSH_PRIVATE_KEY= 32 | 33 | # The current ssh rsa from Github used to populate Heroku's known_hosts file 34 | # 35 | GITHUB_SSH_RSA="AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==" 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | tmp 3 | .ruby-version 4 | spec/examples.txt 5 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.5.3 4 | script: bin/rspec 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | ruby '2.5.3' 6 | 7 | gem 'addressable' 8 | gem 'dotenv' 9 | gem 'git' 10 | gem 'httparty' 11 | gem 'octokit' 12 | gem 'rake' 13 | 14 | group :test do 15 | gem 'diffy' 16 | gem 'rspec' 17 | gem 'webmock' 18 | end 19 | 20 | group :production do 21 | gem 'sentry-raven' 22 | end 23 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.8.0) 5 | public_suffix (>= 2.0.2, < 5.0) 6 | crack (0.4.3) 7 | safe_yaml (~> 1.0.0) 8 | diff-lcs (1.3) 9 | diffy (3.3.0) 10 | dotenv (2.6.0) 11 | faraday (0.17.4) 12 | multipart-post (>= 1.2, < 3) 13 | git (1.11.0) 14 | rchardet (~> 1.8) 15 | hashdiff (0.3.8) 16 | httparty (0.16.3) 17 | mime-types (~> 3.0) 18 | multi_xml (>= 0.5.2) 19 | mime-types (3.2.2) 20 | mime-types-data (~> 3.2015) 21 | mime-types-data (3.2018.0812) 22 | multi_xml (0.6.0) 23 | multipart-post (2.1.1) 24 | octokit (4.13.0) 25 | sawyer (~> 0.8.0, >= 0.5.3) 26 | public_suffix (4.0.6) 27 | rake (12.3.3) 28 | rchardet (1.8.0) 29 | rspec (3.8.0) 30 | rspec-core (~> 3.8.0) 31 | rspec-expectations (~> 3.8.0) 32 | rspec-mocks (~> 3.8.0) 33 | rspec-core (3.8.0) 34 | rspec-support (~> 3.8.0) 35 | rspec-expectations (3.8.2) 36 | diff-lcs (>= 1.2.0, < 2.0) 37 | rspec-support (~> 3.8.0) 38 | rspec-mocks (3.8.0) 39 | diff-lcs (>= 1.2.0, < 2.0) 40 | rspec-support (~> 3.8.0) 41 | rspec-support (3.8.0) 42 | safe_yaml (1.0.4) 43 | sawyer (0.8.2) 44 | addressable (>= 2.3.5) 45 | faraday (> 0.8, < 2.0) 46 | sentry-raven (2.8.0) 47 | faraday (>= 0.7.6, < 1.0) 48 | webmock (3.5.1) 49 | addressable (>= 2.3.6) 50 | crack (>= 0.3.2) 51 | hashdiff 52 | 53 | PLATFORMS 54 | ruby 55 | 56 | DEPENDENCIES 57 | addressable 58 | diffy 59 | dotenv 60 | git 61 | httparty 62 | octokit 63 | rake 64 | rspec 65 | sentry-raven 66 | webmock 67 | 68 | RUBY VERSION 69 | ruby 2.5.3p105 70 | 71 | BUNDLED WITH 72 | 1.17.3 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 SeeClickFix 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | GemDandy 2 | ======== 3 | 4 | [![Build Status](https://travis-ci.org/SeeClickFix/gem_dandy.svg?branch=master)](https://travis-ci.org/SeeClickFix/gem_dandy) 5 | 6 | > "It's a bot for your Gemfile. What could go wrong" -- @tneems 7 | 8 | ![bots making Gemfiles](https://media3.giphy.com/media/bzNZW2FTwsNQA/giphy.gif) 9 | 10 | GemDandy automates your `bundle update` workflow ensuring your project always has the latest and greatest gems 11 | installed. 12 | 13 | ![Pull Request](https://i.imgur.com/Hwn3KiU.png) 14 | 15 | If your Gemfile needs updates, GemDandy will submit a beautifully formated pull request and show you the changes that 16 | are being made, including git diffs and change logs for gems that have them! 17 | 18 | ## Install & Setup 19 | 20 | 1. Clone the repo to your computer. 21 | 2. Copy and update your `.env` file. 22 | 3. Run `bundle` to install all of those dependencies. 23 | 24 | ## Local Usage 25 | 26 | ```bash 27 | $ bin/gem_dandy / [options] 28 | ``` 29 | 30 | What is happening: 31 | 32 | - The repo is cloned into `tmp` 33 | - A `bundle lock --update` command is run 34 | - The diff is parsed to determine what, if anything changed 35 | - The changes are commited 36 | - A pull request is opened with a nice formatted message including changelogs (if found) for the updated gems 37 | 38 | You can do the clone, update, and generate the pull-request text without committing and pushing to github by adding the 39 | `--dry-run` option when calling `bin/gem_dandy` 40 | 41 | You can also change the base branch from `master` to something else using the `-b ` flag. 42 | 43 | ## Automating updates with Heroku 44 | 45 | GemDandy can be setup to run automatically on a free Heroku dyno :metal: 46 | 47 | - First, setup a new Heroku project 48 | - Add the environment variables in `.env.example` using `heroku config:set` 49 | - Push the code to Heroku 50 | - Add the free [Heroku Scheduler](https://elements.heroku.com/addons/scheduler) to your project 51 | - Add a new job to run `rake heroku:update` every day 52 | 53 | GemDandy will check all repos listed in `REPOS_TO_UPDATE` and create a new pull request if there are any updates. 54 | 55 | Review the comments in `.env.example` for caveats about updating private gems and adding GitHub's servers to the 56 | `known_hosts` file. 57 | 58 | You can check that the update command is working as expected by running `$ heroku run rake heroku:update` locally. 59 | 60 | ## License 61 | 62 | Copyright 2018 SeeClickFix 63 | 64 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 65 | 66 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 67 | 68 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 69 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rake' 4 | require 'sentry-raven' 5 | 6 | Dir.glob('lib/tasks/*.rake').each { |r| load r } 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | 6 | require 'irb' 7 | require 'dotenv' 8 | 9 | Dotenv.load 10 | 11 | require_relative '../lib/gem_dandy' 12 | 13 | ARGV.clear 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/gem_dandy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | 6 | require 'optparse' 7 | require 'dotenv' 8 | require 'fileutils' 9 | require 'date' 10 | 11 | Dotenv.load 12 | 13 | require_relative '../lib/gem_dandy' 14 | 15 | options = {} 16 | 17 | optparse = OptionParser.new do |opts| 18 | opts.banner = 'Usage: bin/gem_dandy [options] github_org/github_repo' 19 | 20 | opts.separator '' 21 | opts.separator 'Specific options:' 22 | 23 | opts.banner = 'Usage: bin/gem_dandy / [options]' 24 | 25 | opts.on('-b BRANCH_NAME', String, '--branch', 'Base branch') do |branch| 26 | options[:branch] = branch 27 | end 28 | 29 | opts.on('-d', '--dry-run', 'Dry run (Do not push or create a pull request') do |d| 30 | options[:dry_run] = d 31 | end 32 | end 33 | 34 | optparse.parse! 35 | 36 | BASE_BRANCH = options[:branch] || 'master' 37 | REPO = ARGV[0] 38 | LOCKFILE = 'Gemfile.lock' 39 | 40 | unless REPO 41 | puts optparse 42 | abort 43 | end 44 | 45 | client = GemDandy::Github.client 46 | git_repo = GemDandy::GitRepo.new(REPO, BASE_BRANCH) 47 | 48 | git_repo.checkout_update_branch 49 | 50 | bundler = GemDandy::Bundler.new(git_repo.path) 51 | bundler.update 52 | 53 | diff_parser = GemDandy::DiffParser.new(git_repo.diff_for(LOCKFILE)) 54 | 55 | abort("No updates for '#{REPO}' today") if diff_parser.changes.empty? 56 | 57 | ### Commit ### 58 | 59 | commit_message = "Bundle Update on #{Date.today.strftime('%Y-%m-%d')}" 60 | 61 | git_repo.commit_and_push(commit_message) unless options[:dry_run] 62 | 63 | ### Pull Request ### 64 | 65 | # FIXME: Move out of here... 66 | # 67 | pull_request_message = "**Updated RubyGems:**\n\n" 68 | pull_request_message += diff_parser.changes.map do |gem| 69 | "- #{gem.to_markdown}" 70 | end.join("\n") 71 | pull_request_message += GemDandy::PULL_REQUEST_FOOTER 72 | 73 | if options[:dry_run] 74 | puts 'Pull request would have been submitted with the following message:' 75 | puts pull_request_message 76 | else 77 | pull_request = client.create_pull_request( 78 | REPO, BASE_BRANCH, git_repo.update_branch, 79 | commit_message, pull_request_message 80 | ) 81 | 82 | puts "Pull Request Created: #{pull_request[:html_url]}" 83 | end 84 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | bundle_binstub = File.expand_path("../bundle", __FILE__) 12 | load(bundle_binstub) if File.file?(bundle_binstub) 13 | 14 | require "pathname" 15 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 16 | Pathname.new(__FILE__).realpath) 17 | 18 | require "rubygems" 19 | require "bundler/setup" 20 | 21 | load Gem.bin_path("rspec-core", "rspec") 22 | -------------------------------------------------------------------------------- /lib/gem_dandy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative './gem_dandy/version' 4 | require_relative './gem_dandy/github' 5 | require_relative './gem_dandy/github/changelog' 6 | require_relative './gem_dandy/git_repo' 7 | require_relative './gem_dandy/gem_change' 8 | require_relative './gem_dandy/diff_parser' 9 | require_relative './gem_dandy/env' 10 | require_relative './gem_dandy/bundler' 11 | 12 | module GemDandy 13 | GITHUB_URL = 'https://github.com/SeeClickFix/gem_dandy' 14 | PULL_REQUEST_FOOTER = <<~HEREDOC 15 | 16 | 17 | -- 18 | 19 | 20 | Brought to you by [gem_dandy](#{GITHUB_URL}) - Automated Gemfile Updates 21 | Feedback or Bug Reports? File a [ticket](#{GITHUB_URL}/issues). 22 | HEREDOC 23 | end 24 | -------------------------------------------------------------------------------- /lib/gem_dandy/bundler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative './env' 4 | require 'fileutils' 5 | 6 | module GemDandy 7 | class Bundler 8 | GEMFILE = 'Gemfile' 9 | LOCKFILE = 'Gemfile.lock' 10 | 11 | def initialize(base_dir) 12 | @base_dir = base_dir 13 | @gemfile = File.join(base_dir, GEMFILE) 14 | @lockfile = File.join(base_dir, LOCKFILE) 15 | end 16 | 17 | attr_reader :base_dir, :gemfile, :lockfile 18 | 19 | def update 20 | GemDandy::Env.temporarily_set('RUBYOPT', '') do 21 | FileUtils.chdir base_dir do 22 | GemDandy::Env.temporarily_set('BUNDLE_GEMFILE', gemfile) do 23 | system('bundle config --local frozen false') 24 | system('bundle lock --update') 25 | end 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/gem_dandy/diff_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative './gem_change' 4 | 5 | module GemDandy 6 | class DiffParser 7 | NAME_VERSION = /^(?[-+])\s{4}(?[\w-]+)\s\([^\d.]*(?[\d.]+)\)/ 8 | 9 | def initialize(diff) 10 | @diff = diff || '' 11 | end 12 | 13 | def changes 14 | @changes ||= additions.map do |g| 15 | name = g['name'] 16 | current_version = g['version'] 17 | previous_version = previous_version_for(name) 18 | 19 | GemChange.new(name, previous_version, current_version) 20 | end 21 | end 22 | 23 | private 24 | 25 | attr_reader :diff 26 | 27 | def raw_changes 28 | @raw_changes ||= diff 29 | .split("\n") 30 | .map { |l| NAME_VERSION.match(l)&.named_captures } 31 | .compact 32 | end 33 | 34 | def removals 35 | @removals ||= raw_changes.select { |c| c['operation'] == '-' }.uniq 36 | end 37 | 38 | def additions 39 | @additions ||= raw_changes.select { |c| c['operation'] == '+' }.uniq 40 | end 41 | 42 | def previous_version_for(name) 43 | previous_gem = removals.find { |old_gem| old_gem['name'] == name } 44 | previous_gem && previous_gem['version'] 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/gem_dandy/env.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GemDandy 4 | # Helper methods for dealing with shell environment variables 5 | # 6 | module Env 7 | def self.temporarily_set(name, value) 8 | original_value = ENV[name] 9 | ENV[name] = value 10 | 11 | yield 12 | ensure 13 | ENV[name] = original_value 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/gem_dandy/gem_change.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'httparty' 4 | require_relative './github/changelog' 5 | 6 | module GemDandy 7 | class GemChange 8 | RUBYGEMS_API_URL_TEMPLATE = Addressable::Template.new("https://rubygems.org/api/v1/gems/{name}.json").freeze 9 | 10 | def initialize(name, previous_version, current_version) 11 | @name = name 12 | @previous_version = previous_version 13 | @current_version = current_version 14 | @previous_tag = "v#{previous_version}" 15 | @current_tag = "v#{current_version}" 16 | end 17 | 18 | attr_reader :name, :previous_version, :current_version 19 | 20 | def homepage_url 21 | @homepage_url ||= url_for('homepage_uri') 22 | end 23 | 24 | def source_code_url 25 | @source_code_url ||= url_for('source_code_uri') 26 | end 27 | 28 | def github_url 29 | [source_code_url, homepage_url].find { |url| url && url[/github.com/] } 30 | end 31 | 32 | def changelog_url 33 | @changelog_url ||= begin 34 | url_for('changelog_uri') || 35 | Github::Changelog.for(github_repo, current_tag) 36 | end 37 | end 38 | 39 | def compare_url 40 | return unless github_url 41 | 42 | "#{github_url}/compare/#{previous_tag}...#{current_tag}" 43 | end 44 | 45 | def to_markdown 46 | link = ->(text, url) { "[#{text}](#{url})" } 47 | 48 | if github_url 49 | [ 50 | link.call(name, github_url), 51 | ', ', 52 | link.call([previous_version, current_version].join('...'), 53 | compare_url), 54 | (" (#{link.call('CHANGELOG', changelog_url)})" if changelog_url) 55 | ].join 56 | else 57 | "#{name}, #{previous_version}...#{current_version}" 58 | end 59 | end 60 | 61 | private 62 | 63 | attr_reader :previous_tag, :current_tag 64 | 65 | def rubygems_info 66 | @rubygems_info ||= HTTParty.get(RUBYGEMS_API_URL_TEMPLATE.expand(name: name)).tap do |response| 67 | response['anything'] # force json parsing, which may fail 68 | end 69 | rescue StandardError 70 | @rubygems_info = Hash.new 71 | end 72 | 73 | def url_for(key) 74 | url = rubygems_info[key] 75 | 76 | url && url != '' ? url : nil 77 | end 78 | 79 | def github_repo 80 | return unless github_url 81 | 82 | %r{github.com\/([\w-]+\/[\w-]+)}.match(github_url)&.captures&.first 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/gem_dandy/git_repo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'time' 4 | require 'git' 5 | 6 | module GemDandy 7 | class GitRepo 8 | TMP_PATH = File.expand_path(File.join(__dir__, '..', '..', 'tmp')).freeze 9 | 10 | def initialize(repo, base_branch) 11 | @repo = repo 12 | @base_branch = base_branch 13 | @update_branch = "bundle-update-#{Time.now.strftime('%Y-%m-%d-%H%M%S')}" 14 | @path = File.join(TMP_PATH, repo) 15 | 16 | reset_remote 17 | end 18 | 19 | attr_reader :repo, :base_branch, :update_branch, :path 20 | 21 | def url 22 | @url ||= GemDandy::Github.clone_url(repo) 23 | end 24 | 25 | def checkout_update_branch 26 | git.branch(update_branch).checkout 27 | end 28 | 29 | def delete_update_branch 30 | git.checkout(base_branch) 31 | git.branches[update_branch]&.delete 32 | end 33 | 34 | def commit_and_push(message) 35 | git.commit_all(message) 36 | 37 | git.push(git.remote.name, update_branch) 38 | end 39 | 40 | def diff_for(path) 41 | git.diff.find { |f| f.path == path }&.patch 42 | end 43 | 44 | private 45 | 46 | def reset_remote 47 | git.reset_hard 48 | git.checkout(base_branch) 49 | delete_update_branch 50 | git.fetch 51 | git.reset_hard('@{u}') 52 | end 53 | 54 | def git 55 | @git ||= begin 56 | if Dir.exist?(path) 57 | Git.open(path) 58 | else 59 | Git.clone(url, repo, path: TMP_PATH) 60 | end.tap do |g| 61 | if (name = ENV['GIT_USER_NAME']) 62 | g.config('user.name', name) 63 | end 64 | 65 | if (email = ENV['GIT_USER_EMAIL']) 66 | g.config('user.email', email) 67 | end 68 | end 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/gem_dandy/github.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'octokit' 4 | 5 | module GemDandy 6 | module Github 7 | URL = 'https://github.com' 8 | GITHUB_ACCESS_TOKEN = ENV['GITHUB_ACCESS_TOKEN'] 9 | 10 | def self.client 11 | @client ||= Octokit::Client.new(access_token: ENV['GITHUB_ACCESS_TOKEN']) 12 | end 13 | 14 | def self.clone_url(repo) 15 | "https://#{GITHUB_ACCESS_TOKEN}:x-oauth-basic@github.com/#{repo}.git" 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/gem_dandy/github/changelog.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../github' 4 | 5 | module GemDandy 6 | module Github 7 | module Changelog 8 | CHANGELOG_NAMES = /changelog|changes|history|news|releases/i 9 | 10 | def self.for(repo, tag) 11 | return unless repo 12 | 13 | begin 14 | files = GemDandy::Github.client.contents(repo, ref: tag) 15 | rescue Octokit::NotFound 16 | # Repo doesn't use version tags 17 | # 18 | files = GemDandy::Github.client.contents(repo) 19 | end 20 | 21 | change_log = files.find { |file| file[:name][CHANGELOG_NAMES] } 22 | change_log && change_log[:html_url] 23 | rescue Octokit::NotFound 24 | # Repo just flat out doesn't exist. No CHANGELOG for you 25 | return nil 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/gem_dandy/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GemDandy 4 | VERSION = '0.0.1' 5 | end 6 | -------------------------------------------------------------------------------- /lib/tasks/heroku.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | require 'ipaddr' 5 | require 'dotenv/tasks' 6 | 7 | require_relative '../gem_dandy/github' 8 | 9 | namespace :heroku do 10 | SSH_DIR = File.join(ENV['HOME'], '.ssh').to_s 11 | 12 | desc 'Write the SSH_PRIVATE_KEY on the remote server' 13 | task :write_private_key do 14 | puts 'Writing ssh_private_key into ~/.ssh/' 15 | unless ENV['SSH_PRIVATE_KEY'] 16 | puts 'Aborting because SSH_PRIVATE_KEY is missing' 17 | abort 18 | end 19 | 20 | private_key_path = File.join(SSH_DIR, 'id_rsa') 21 | 22 | FileUtils.mkdir_p(SSH_DIR) 23 | File.write(private_key_path, ENV['SSH_PRIVATE_KEY']) 24 | puts 'Updating file permissions on ssh_private_key' 25 | FileUtils.chmod(0o600, private_key_path) 26 | puts 'ssh_private_key successfully written' 27 | end 28 | 29 | # Copied from https://github.com/siassaj/heroku-buildpack-git-deploy-keys/blob/develop/bin/compile#L35 30 | # with some modifications 31 | # 32 | desc 'Write github.com to the known_hosts file on the remote server' 33 | task :write_known_hosts do 34 | puts 'Writing known_hosts file' 35 | unless ENV['SSH_PRIVATE_KEY'] 36 | puts 'Aborting because SSH_PRIVATE_KEY env var is missing' 37 | abort 38 | end 39 | unless ENV['GITHUB_SSH_RSA'] 40 | puts 'Aborting because GITHUB_SSH_RSA env var is missing' 41 | abort 42 | end 43 | 44 | known_hosts_path = File.join(SSH_DIR, 'known_hosts') 45 | 46 | # Found here: https://github.com/openssh/openssh-portable/blob/0235a5fa67fcac51adb564cba69011a535f86f6b/hostfile.c#L674 47 | ssh_max_line_length = 8192 48 | template = %(github.com,%s ssh-rsa #{ENV['GITHUB_SSH_RSA']}) 49 | ips_per_line = begin 50 | (ssh_max_line_length - template.bytesize) / '255.255.255.255,'.bytesize 51 | end 52 | lines = [] 53 | 54 | puts 'Writing Github ip addresses into known_hosts file' 55 | GemDandy::Github.client.meta.git.each do |ip_range| 56 | github_ips = IPAddr.new(ip_range).to_range.to_a 57 | 58 | until github_ips.empty? 59 | lines << template % github_ips.pop(ips_per_line).join(',') 60 | end 61 | end 62 | 63 | host_hash = lines.join('\n') 64 | 65 | puts 'Saving known_hosts file' 66 | FileUtils.mkdir_p(SSH_DIR) 67 | File.write(known_hosts_path, host_hash) 68 | puts 'Updating permissions on known_hosts file' 69 | FileUtils.chmod(0o600, known_hosts_path) 70 | puts 'known_hosts file successfully saved' 71 | end 72 | 73 | desc 'Update repos unless there are already open bundle update prs' 74 | task update: %I[dotenv write_private_key write_known_hosts] do 75 | puts 'Updating repos' 76 | REPOS_TO_UPDATE = Hash[ENV['REPOS_TO_UPDATE'].split(',') 77 | .map { |s| s.split(':') }] 78 | GITHUB_USER = GemDandy::Github.client.user.login 79 | puts 'Checking for existing update PRs' 80 | open_bundle_update_prs = ->(repo, user, title = 'Bundle Update') { 81 | if user 82 | open_prs = GemDandy::Github.client.pull_requests(repo, state: 'open') 83 | 84 | open_prs.any? do |pr| 85 | pr.user.login == GITHUB_USER && pr.title[/#{title}/] 86 | end 87 | end 88 | } 89 | 90 | REPOS_TO_UPDATE.each do |repo_name, branch| 91 | puts "Updating repo #{repo_name} based on the #{branch} branch" 92 | if open_bundle_update_prs.call(repo_name, GITHUB_USER) 93 | puts "Open PR for '#{GITHUB_USER}' found. " \ 94 | "Skipping updates on '#{repo_name}' ..." 95 | next 96 | end 97 | 98 | puts "Running system command: bin/gem_dandy #{repo_name} -b #{branch}" 99 | system("bin/gem_dandy #{repo_name} -b #{branch}") 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /spec/lib/gem_dandy/bundler_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'gem_dandy/bundler' 4 | require 'tmpdir' 5 | require 'diffy' 6 | 7 | RSpec.describe GemDandy::Bundler do 8 | include GemDandySpecHelpers 9 | 10 | let(:base_dir) { @dir } 11 | let(:original_lockfile) { File.read('spec/support/Gemfile.lock') } 12 | let(:updated_lockfile) { File.read(File.join(base_dir, GemDandy::Bundler::LOCKFILE)) } 13 | let(:diff) { Diffy::Diff.new(original_lockfile, updated_lockfile).diff } 14 | 15 | subject { described_class.new(base_dir) } 16 | 17 | around do |example| 18 | Dir.mktmpdir do |dir| 19 | @dir = dir 20 | 21 | [GemDandy::Bundler::GEMFILE, GemDandy::Bundler::LOCKFILE].each do |file| 22 | FileUtils.cp(File.join('spec', 'support', file), File.join(dir, file)) 23 | end 24 | 25 | example.run 26 | end 27 | end 28 | 29 | it 'updates the Gemfile.lock in place' do 30 | silence_output do 31 | subject.update 32 | end 33 | 34 | expect(diff).to include('- prawn (2.2.1)') 35 | expect(diff).to include('+ prawn (') # Super relaxed check here 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/lib/gem_dandy/diff_parser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'gem_dandy/diff_parser' 4 | 5 | RSpec.describe GemDandy::DiffParser do 6 | subject { described_class.new(@diff) } 7 | 8 | describe '#changes' do 9 | it 'currently ignores git revision changes' do 10 | @diff = '+ revision: 1ea84f2ab40181616944983c4ca5e0d98670af76' 11 | 12 | expect(subject.changes).to be_empty 13 | end 14 | 15 | it 'ignores lines that are not gems' do 16 | @diff = "this is bogus\ndiff\nindex\n@@" 17 | 18 | expect(subject.changes).to be_empty 19 | end 20 | 21 | it 'includes any new gems added' do 22 | @diff = '+ new_gem (0.1.0)' 23 | 24 | new_gem = subject.changes.first 25 | 26 | expect(new_gem.name).to eq('new_gem') 27 | expect(new_gem.current_version).to eq('0.1.0') 28 | expect(new_gem.previous_version).to eq(nil) 29 | end 30 | 31 | it 'only returns unique gems' do 32 | @diff = "+ new_gem (0.1.0)\n+ new_gem (0.1.0)" 33 | 34 | expect(subject.changes.count).to eq(1) 35 | end 36 | 37 | it 'includes the previous gem version if found' do 38 | @diff = "+ new_gem (0.1.0)\n- new_gem (0.0.9)" 39 | 40 | gem_change = subject.changes.first 41 | 42 | expect(gem_change.previous_version).to eq('0.0.9') 43 | end 44 | 45 | it 'ignores dependency changes' do 46 | @diff = '+ dep_gem (0.1.0)' 47 | 48 | expect(subject.changes).to be_empty 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/lib/gem_dandy/gem_change_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'gem_dandy/gem_change' 4 | require 'webmock/rspec' 5 | 6 | RSpec.describe GemDandy::GemChange do 7 | let(:name) { 'gem_dandy' } 8 | let(:previous_version) { '0.0.0' } 9 | let(:current_version) { '0.0.1' } 10 | 11 | let(:github_url) { 'https://github.com/seeclickfix/gem_dandy' } 12 | let(:changelog_url) { "#{github_url}/CHANGELOG.md" } 13 | 14 | subject { described_class.new(name, previous_version, current_version) } 15 | 16 | context 'rubygems info' do 17 | context 'when rubygems.org is unreachable or errors' do 18 | it 'fails gracefully', :aggregate_failures do 19 | stub_request(:get, GemDandy::GemChange::RUBYGEMS_API_URL_TEMPLATE).to_timeout 20 | 21 | expect(subject.homepage_url).to be_nil 22 | expect(subject.source_code_url).to be_nil 23 | expect(subject.github_url).to be_nil 24 | expect(subject.changelog_url).to be_nil 25 | expect(subject.compare_url).to be_nil 26 | end 27 | end 28 | 29 | context 'when rubygems.org returns non-json content' do 30 | it 'fails gracefully', :aggregate_failures do 31 | stub_request(:get, GemDandy::GemChange::RUBYGEMS_API_URL_TEMPLATE).to_return( 32 | body: 'This rubygem could not be found.', 33 | headers: { "content-type"=>"application/json" } 34 | ) 35 | 36 | expect(subject.homepage_url).to be_nil 37 | expect(subject.source_code_url).to be_nil 38 | expect(subject.github_url).to be_nil 39 | expect(subject.changelog_url).to be_nil 40 | expect(subject.compare_url).to be_nil 41 | end 42 | end 43 | 44 | describe '#homepage_url' do 45 | let(:info_from_rubygems) { { 'homepage_uri' => homepage_uri } } 46 | 47 | context 'when homepage_uri is present' do 48 | let(:homepage_uri) { github_url } 49 | 50 | it 'returns the result' do 51 | allow(subject).to receive(:rubygems_info) 52 | .and_return(info_from_rubygems) 53 | 54 | expect(subject.homepage_url).to eq(homepage_uri) 55 | end 56 | end 57 | 58 | context 'when the homepage_uri is an empty string' do 59 | let(:homepage_uri) { '' } 60 | 61 | it 'returns nil' do 62 | allow(subject).to receive(:rubygems_info) 63 | .and_return(info_from_rubygems) 64 | 65 | expect(subject.homepage_url).to eq(nil) 66 | end 67 | end 68 | end 69 | 70 | describe '#source_code_url' do 71 | let(:info_from_rubygems) { { 'source_code_uri' => source_code_uri } } 72 | 73 | context 'when source_code_uri is present' do 74 | let(:source_code_uri) { github_url } 75 | 76 | it 'returns the result' do 77 | allow(subject).to receive(:rubygems_info) 78 | .and_return(info_from_rubygems) 79 | 80 | expect(subject.source_code_url).to eq(source_code_uri) 81 | end 82 | end 83 | 84 | context 'when the source_code_uri is an empty string' do 85 | let(:source_code_uri) { '' } 86 | 87 | it 'returns nil' do 88 | allow(subject).to receive(:rubygems_info) 89 | .and_return(info_from_rubygems) 90 | 91 | expect(subject.source_code_url).to eq(nil) 92 | end 93 | end 94 | end 95 | end 96 | 97 | describe '#github_url' do 98 | context 'with no homepage_url or source_code_url' do 99 | it 'returns nil' do 100 | allow(subject).to receive(:homepage_url).and_return(nil) 101 | allow(subject).to receive(:source_code_url).and_return(nil) 102 | 103 | expect(subject.github_url).to eq(nil) 104 | end 105 | end 106 | 107 | context 'with a homepage_url and source_code_url not on github' do 108 | let(:non_github_url) { 'http://bitbucket.com/seeclickfix/gem_dandy' } 109 | 110 | it 'returns nil' do 111 | allow(subject).to receive(:homepage_url).and_return(non_github_url) 112 | allow(subject).to receive(:source_code_url).and_return(non_github_url) 113 | 114 | expect(subject.github_url).to eq(nil) 115 | end 116 | end 117 | 118 | context 'with a homepage_url or source_code_url on github' do 119 | it 'returns the first matched github url' do 120 | allow(subject).to receive(:homepage_url).and_return(github_url) 121 | allow(subject).to receive(:source_code_url).and_return(github_url) 122 | 123 | expect(subject.github_url).to eq(github_url) 124 | end 125 | end 126 | end 127 | 128 | describe '#changelog_url' do 129 | it 'first attempts to load the changelog from rubygems' do 130 | allow(subject).to receive(:rubygems_info) 131 | .and_return('changelog_uri' => changelog_url) 132 | 133 | expect(subject.changelog_url).to eq(changelog_url) 134 | end 135 | 136 | it 'otherwise delegates to Github::Changelog.for' do 137 | allow(subject).to receive(:rubygems_info).and_return({}) 138 | allow(subject).to receive(:github_url).and_return(github_url) 139 | allow(GemDandy::Github::Changelog).to receive(:for) 140 | .and_return(changelog_url) 141 | 142 | expect(subject.changelog_url).to eq(changelog_url) 143 | expect(GemDandy::Github::Changelog).to have_received(:for) 144 | .with('seeclickfix/gem_dandy', 'v0.0.1') 145 | end 146 | end 147 | 148 | describe '#compare_url' do 149 | context 'without a github_url' do 150 | it 'returns nil' do 151 | allow(subject).to receive(:github_url).and_return(nil) 152 | 153 | expect(subject.compare_url).to eq(nil) 154 | end 155 | end 156 | 157 | context 'with a github_url' do 158 | it 'returns a compare url for the two versions' do 159 | allow(subject).to receive(:github_url).and_return(github_url) 160 | 161 | expect(subject.compare_url).to include(github_url) 162 | .and include('compare') 163 | .and include(previous_version) 164 | .and include(current_version) 165 | end 166 | end 167 | end 168 | 169 | describe '#to_markdown' do 170 | context 'with no github_url' do 171 | it 'returns the gem name and version change information' do 172 | allow(subject).to receive(:github_url).and_return(nil) 173 | 174 | expect(subject.to_markdown).to include(name) 175 | .and include(previous_version) 176 | .and include(current_version) 177 | end 178 | end 179 | 180 | context 'with a github_url' do 181 | before do 182 | allow(subject).to receive(:github_url).and_return(github_url) 183 | allow(subject).to receive(:changelog_url).and_return(nil) 184 | end 185 | 186 | it 'returns valid markdown markup' 187 | 188 | it 'returns the gem name and version change information' do 189 | expect(subject.to_markdown).to include(name) 190 | .and include(previous_version) 191 | .and include(current_version) 192 | end 193 | 194 | it 'returns a link to github' do 195 | expect(subject.to_markdown).to include(github_url) 196 | end 197 | 198 | it 'returns a link to the version comparison' do 199 | expect(subject.to_markdown).to include(subject.compare_url) 200 | end 201 | 202 | context 'with a changelog' do 203 | it 'returns a link to the changelog' do 204 | allow(subject).to receive(:changelog_url).and_return(changelog_url) 205 | 206 | expect(subject.to_markdown).to include(changelog_url) 207 | end 208 | end 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'irb' 4 | require_relative 'support/helpers' 5 | 6 | $LOAD_PATH.unshift File.expand_path(File.join(__dir__, '..', 'lib')) 7 | 8 | # This file was generated by the `rspec --init` command. Conventionally, all 9 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 10 | # The generated `.rspec` file contains `--require spec_helper` which will cause 11 | # this file to always be loaded, without a need to explicitly require it in any 12 | # files. 13 | # 14 | # Given that it is always loaded, you are encouraged to keep this file as 15 | # light-weight as possible. Requiring heavyweight dependencies from this file 16 | # will add to the boot time of your test suite on EVERY test run, even for an 17 | # individual file that may not need all of that loaded. Instead, consider making 18 | # a separate helper file that requires the additional dependencies and performs 19 | # the additional setup, and require it from the spec files that actually need 20 | # it. 21 | # 22 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 23 | RSpec.configure do |config| 24 | # rspec-expectations config goes here. You can use an alternate 25 | # assertion/expectation library such as wrong or the stdlib/minitest 26 | # assertions if you prefer. 27 | config.expect_with :rspec do |expectations| 28 | # This option will default to `true` in RSpec 4. It makes the `description` 29 | # and `failure_message` of custom matchers include text for helper methods 30 | # defined using `chain`, e.g.: 31 | # be_bigger_than(2).and_smaller_than(4).description 32 | # # => "be bigger than 2 and smaller than 4" 33 | # ...rather than: 34 | # # => "be bigger than 2" 35 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 36 | end 37 | 38 | # rspec-mocks config goes here. You can use an alternate test double 39 | # library (such as bogus or mocha) by changing the `mock_with` option here. 40 | config.mock_with :rspec do |mocks| 41 | # Prevents you from mocking or stubbing a method that does not exist on 42 | # a real object. This is generally recommended, and will default to 43 | # `true` in RSpec 4. 44 | mocks.verify_partial_doubles = true 45 | end 46 | 47 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 48 | # have no way to turn it off -- the option exists only for backwards 49 | # compatibility in RSpec 3). It causes shared context metadata to be 50 | # inherited by the metadata hash of host groups and examples, rather than 51 | # triggering implicit auto-inclusion in groups with matching metadata. 52 | config.shared_context_metadata_behavior = :apply_to_host_groups 53 | 54 | # This allows you to limit a spec run to individual examples or groups 55 | # you care about by tagging them with `:focus` metadata. When nothing 56 | # is tagged with `:focus`, all examples get run. RSpec also provides 57 | # aliases for `it`, `describe`, and `context` that include `:focus` 58 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 59 | config.filter_run_when_matching :focus 60 | 61 | # Allows RSpec to persist some state between runs in order to support 62 | # the `--only-failures` and `--next-failure` CLI options. We recommend 63 | # you configure your source control system to ignore this file. 64 | config.example_status_persistence_file_path = "spec/examples.txt" 65 | 66 | # Limits the available syntax to the non-monkey patched syntax that is 67 | # recommended. For more details, see: 68 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 69 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 70 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 71 | config.disable_monkey_patching! 72 | 73 | # This setting enables warnings. It's recommended, but in some cases may 74 | # be too noisy due to issues in dependencies. 75 | config.warnings = true 76 | 77 | # Many RSpec users commonly either run the entire suite or an individual 78 | # file, and it's useful to allow more verbose output when running an 79 | # individual spec file. 80 | if config.files_to_run.one? 81 | # Use the documentation formatter for detailed output, 82 | # unless a formatter has already been configured 83 | # (e.g. via a command-line flag). 84 | config.default_formatter = "doc" 85 | end 86 | 87 | # Print the 10 slowest examples and example groups at the 88 | # end of the spec run, to help surface which specs are running 89 | # particularly slow. 90 | config.profile_examples = 10 91 | 92 | # Run specs in random order to surface order dependencies. If you find an 93 | # order dependency and want to debug it, you can fix the order by providing 94 | # the seed, which is printed after each run. 95 | # --seed 1234 96 | config.order = :random 97 | 98 | # Seed global randomization in this process using the `--seed` CLI option. 99 | # Setting this allows you to use `--seed` to deterministically reproduce 100 | # test failures related to randomization by passing the same `--seed` value 101 | # as the one that triggered the failure. 102 | Kernel.srand config.seed 103 | end 104 | -------------------------------------------------------------------------------- /spec/support/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'prawn-labels' 4 | -------------------------------------------------------------------------------- /spec/support/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | pdf-core (0.7.0) 5 | prawn (2.2.1) 6 | pdf-core (~> 0.7.0) 7 | ttfunk (~> 1.5) 8 | prawn-labels (1.2.6) 9 | prawn (>= 1.0.0, < 3.0.0) 10 | ttfunk (1.5.1) 11 | 12 | PLATFORMS 13 | ruby 14 | 15 | DEPENDENCIES 16 | prawn-labels 17 | 18 | BUNDLED WITH 19 | 1.16.0 20 | -------------------------------------------------------------------------------- /spec/support/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'stringio' 4 | 5 | module GemDandySpecHelpers 6 | def silence_output 7 | previous_stderr = $stderr.clone 8 | previous_stdout = $stdout.clone 9 | $stdout.reopen(File.new('/dev/null', 'w')) 10 | $stderr.reopen(File.new('/dev/null', 'w')) 11 | 12 | yield 13 | ensure 14 | $stderr.reopen(previous_stderr) 15 | $stdout.reopen(previous_stdout) 16 | end 17 | end 18 | --------------------------------------------------------------------------------