├── .rspec
├── .gitignore
├── .travis.yml
├── spec
├── support
│ ├── Gemfile
│ ├── Gemfile.lock
│ └── helpers.rb
├── lib
│ └── gem_dandy
│ │ ├── bundler_spec.rb
│ │ ├── diff_parser_spec.rb
│ │ └── gem_change_spec.rb
└── spec_helper.rb
├── lib
├── gem_dandy
│ ├── version.rb
│ ├── env.rb
│ ├── github.rb
│ ├── bundler.rb
│ ├── github
│ │ └── changelog.rb
│ ├── diff_parser.rb
│ ├── git_repo.rb
│ └── gem_change.rb
├── gem_dandy.rb
└── tasks
│ └── heroku.rake
├── Rakefile
├── bin
├── console
├── rspec
└── gem_dandy
├── Gemfile
├── LICENSE
├── .env.example
├── Gemfile.lock
└── README.md
/.rspec:
--------------------------------------------------------------------------------
1 | --require spec_helper
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | tmp
3 | .ruby-version
4 | spec/examples.txt
5 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: ruby
2 | rvm:
3 | - 2.5.3
4 | script: bin/rspec
5 |
--------------------------------------------------------------------------------
/spec/support/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gem 'prawn-labels'
4 |
--------------------------------------------------------------------------------
/lib/gem_dandy/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module GemDandy
4 | VERSION = '0.0.1'
5 | end
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | GemDandy
2 | ========
3 |
4 | [](https://travis-ci.org/SeeClickFix/gem_dandy)
5 |
6 | > "It's a bot for your Gemfile. What could go wrong" -- @tneems
7 |
8 | 
9 |
10 | GemDandy automates your `bundle update` workflow ensuring your project always has the latest and greatest gems
11 | installed.
12 |
13 | 
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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------