├── .rspec ├── lib ├── unwrappr │ ├── version.rb │ ├── bundler_command_runner.rb │ ├── researchers │ │ ├── ruby_gems_info.rb │ │ ├── composite.rb │ │ ├── github_repo.rb │ │ ├── security_vulnerabilities.rb │ │ └── github_comparison.rb │ ├── writers │ │ ├── composite.rb │ │ ├── title.rb │ │ ├── project_links.rb │ │ ├── version_change.rb │ │ ├── github_commit_log.rb │ │ └── security_vulnerabilities.rb │ ├── octokit.rb │ ├── spec_version_comparator.rb │ ├── github │ │ ├── pr_sink.rb │ │ ├── pr_source.rb │ │ └── client.rb │ ├── ruby_gems.rb │ ├── lock_file_comparator.rb │ ├── gem_change.rb │ ├── gem_version.rb │ ├── lock_file_diff.rb │ ├── lock_file_annotator.rb │ ├── git_command_runner.rb │ └── cli.rb └── unwrappr.rb ├── bin ├── setup └── console ├── spec ├── unwrapper_spec.rb ├── spec_helper.rb └── lib │ └── unwrappr │ ├── writers │ ├── title_spec.rb │ ├── version_change_spec.rb │ ├── project_links_spec.rb │ ├── github_commit_log_spec.rb │ └── security_vulnerabilities_spec.rb │ ├── github │ ├── pr_sink_spec.rb │ ├── client_spec.rb │ └── pr_source_spec.rb │ ├── bundler_command_runner_spec.rb │ ├── researchers │ ├── ruby_gems_info_spec.rb │ ├── github_repo_spec.rb │ ├── github_comparison_spec.rb │ └── security_vulnerabilities_spec.rb │ ├── ruby_gems_spec.rb │ ├── spec_version_comparator_spec.rb │ ├── lock_file_comparator_spec.rb │ ├── lock_file_annotator_spec.rb │ ├── gem_version_spec.rb │ ├── git_command_runner_spec.rb │ ├── gem_change_spec.rb │ └── lock_file_diff_spec.rb ├── .gitignore ├── exe └── unwrappr ├── Rakefile ├── Gemfile ├── Guardfile ├── .github └── workflows │ └── ci.yml ├── .rubocop.yml ├── LICENSE.txt ├── unwrappr.gemspec ├── CODE_OF_CONDUCT.md ├── README.md └── CHANGELOG.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/unwrappr/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | VERSION = '0.8.2' 5 | end 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /spec/unwrapper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Unwrappr do 4 | it 'has a version number' do 5 | expect(Unwrappr::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .rspec_status 3 | /.bundle/ 4 | /.yardoc 5 | /Gemfile.lock 6 | /_yardoc/ 7 | /coverage/ 8 | /doc/ 9 | /error.txt 10 | /pkg/ 11 | /spec/reports/ 12 | /stdout.txt 13 | /tmp/ 14 | -------------------------------------------------------------------------------- /exe/unwrappr: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $LOAD_PATH << File.expand_path('../lib', __dir__) 5 | 6 | require 'unwrappr' 7 | 8 | $stdout.sync = true 9 | $stderr.sync = true 10 | 11 | Unwrappr::CLI.run 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | require 'rubocop/rake_task' 6 | 7 | RuboCop::RakeTask.new 8 | RSpec::Core::RakeTask.new(:spec) 9 | 10 | task default: %i[rubocop spec] 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'unwrappr' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require 'pry' 11 | Pry.start 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | group :development do 8 | gem 'guard', '~> 2' 9 | gem 'guard-rspec', '~> 4' 10 | gem 'pry', '~> 0' 11 | gem 'rake', '>= 12.3.3' 12 | gem 'rspec', '~> 3.0' 13 | gem 'rspec-its', '~> 1' 14 | gem 'rubocop', '>= 0.49.0' 15 | end 16 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | guard :rspec, cmd: 'bundle exec rspec' do 4 | require 'guard/rspec/dsl' 5 | dsl = Guard::RSpec::Dsl.new(self) 6 | 7 | # RSpec files 8 | rspec = dsl.rspec 9 | watch(rspec.spec_helper) { rspec.spec_dir } 10 | watch(rspec.spec_support) { rspec.spec_dir } 11 | watch(rspec.spec_files) 12 | 13 | # Ruby files 14 | ruby = dsl.ruby 15 | dsl.watch_spec_files_for(ruby.lib_files) 16 | end 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | ruby: ['2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4'] 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: ruby/setup-ruby@v1 15 | with: 16 | ruby-version: ${{ matrix.ruby }} 17 | bundler-cache: true 18 | - run: bundle exec rake --trace 19 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'unwrappr' 5 | require 'pry' 6 | require 'rspec/its' 7 | 8 | RSpec.configure do |config| 9 | # Enable flags like --only-failures and --next-failure 10 | config.example_status_persistence_file_path = '.rspec_status' 11 | 12 | # Disable RSpec exposing methods globally on `Module` and `main` 13 | config.disable_monkey_patching! 14 | 15 | config.expect_with :rspec do |c| 16 | c.syntax = :expect 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | AllCops: 3 | Exclude: 4 | - 'spike/*.rb' 5 | - 'vendor/**/*' 6 | NewCops: enable 7 | SuggestExtensions: false 8 | TargetRubyVersion: 2.5 9 | 10 | Gemspec/RequiredRubyVersion: 11 | Enabled: false 12 | 13 | Layout/LineLength: 14 | Exclude: 15 | - 'spec/**/*' 16 | - 'test/**/*' 17 | 18 | Metrics/BlockLength: 19 | Exclude: 20 | - 'spec/**/*' 21 | - 'test/**/*' 22 | 23 | Metrics/ModuleLength: 24 | Exclude: 25 | - 'spec/**/*' 26 | - 'test/**/*' 27 | 28 | Style/Documentation: 29 | Exclude: 30 | - 'spec/**/*' 31 | - 'test/**/*' 32 | -------------------------------------------------------------------------------- /lib/unwrappr/bundler_command_runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'safe_shell' 4 | 5 | module Unwrappr 6 | # Runs the bundle command. No surprises. 7 | module BundlerCommandRunner 8 | class << self 9 | def bundle_update! 10 | raise 'bundle update failed' unless updated_gems? 11 | end 12 | 13 | private 14 | 15 | def updated_gems? 16 | SafeShell.execute?( 17 | 'bundle', 18 | 'update', 19 | stdout: 'stdout.txt', 20 | stderr: 'error.txt' 21 | ) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/unwrappr/researchers/ruby_gems_info.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Researchers 5 | # Obtains information about the gem from https://rubygems.org/ 6 | # 7 | # Implements the `gem_researcher` interface required by the 8 | # LockFileAnnotator. 9 | class RubyGemsInfo 10 | def research(gem_change, gem_change_info) 11 | gem_change_info.merge( 12 | ruby_gems: ::Unwrappr::RubyGems.gem_info( 13 | gem_change.name, gem_change.head_version 14 | ) 15 | ) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/unwrappr/writers/composite.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Writers 5 | # Delegate to many writers and combine their produced annotations into one. 6 | # 7 | # Implements the `annotation_writer` interface required by the 8 | # LockFileAnnotator. 9 | class Composite 10 | def initialize(*writers) 11 | @writers = writers 12 | end 13 | 14 | def write(gem_change, gem_change_info) 15 | @writers.map do |writer| 16 | writer.write(gem_change, gem_change_info) 17 | end.compact.join("\n") 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/unwrappr/octokit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Wrapper around octokit 4 | module Octokit 5 | def self.client 6 | @client ||= Client.new(access_token: access_token_from_environment) 7 | end 8 | 9 | def self.access_token_from_environment 10 | ENV.fetch('GITHUB_TOKEN') do 11 | raise <<~MESSAGE 12 | Missing environment variable GITHUB_TOKEN. 13 | See https://github.com/settings/tokens to set up personal access tokens. 14 | Add to the environment: 15 | 16 | export GITHUB_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 17 | 18 | MESSAGE 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/unwrappr/spec_version_comparator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | # specs_versions is a hash like { name: 'version' } 5 | class SpecVersionComparator 6 | def self.perform(specs_versions_before, specs_versions_after) 7 | keys = (specs_versions_before.keys + specs_versions_after.keys).uniq 8 | changes = keys.sort.map do |key| 9 | { 10 | dependency: key, 11 | before: specs_versions_before[key], 12 | after: specs_versions_after[key] 13 | } 14 | end 15 | 16 | changes.reject { |rec| rec[:before] == rec[:after] } 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/unwrappr/researchers/composite.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Researchers 5 | # Delegate to many researchers, collecting and returning their findings. 6 | # 7 | # Implements the `gem_researcher` interface required by the 8 | # LockFileAnnotator. 9 | class Composite 10 | def initialize(*researchers) 11 | @researchers = researchers 12 | end 13 | 14 | def research(gem_change, gem_change_info) 15 | @researchers.reduce(gem_change_info) do |info, researcher| 16 | researcher.research(gem_change, info) 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/writers/title_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Unwrappr::Writers::Title do 4 | describe '.write' do 5 | subject(:write) { described_class.write(gem_change, gem_change_info) } 6 | 7 | let(:gem_change) { instance_spy(Unwrappr::GemChange, name: 'test-gem') } 8 | 9 | context 'given a gem homepage URI' do 10 | let(:gem_change_info) { { ruby_gems: ruby_gems } } 11 | let(:ruby_gems) { { 'homepage_uri' => 'home-uri' } } 12 | 13 | it { should eq "### [test-gem](home-uri)\n" } 14 | end 15 | 16 | context 'given no gem homepage URI' do 17 | let(:gem_change_info) { {} } 18 | 19 | it { should eq "### test-gem\n" } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/unwrappr/github/pr_sink.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Github 5 | # Saves Gemfile.lock annotations as Github pull request comments. 6 | # 7 | # Implements the `annotation_sink` interface as defined by the 8 | # LockFileAnnotator. 9 | class PrSink 10 | def initialize(repo, pr_number, client) 11 | @repo = repo 12 | @pr_number = pr_number 13 | @client = client 14 | end 15 | 16 | def annotate_change(gem_change, message) 17 | @client.create_pull_request_comment( 18 | @repo, 19 | @pr_number, 20 | message, 21 | gem_change.sha, 22 | gem_change.filename, 23 | gem_change.line_number 24 | ) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/unwrappr/ruby_gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | 5 | module Unwrappr 6 | # A wrapper around RubyGems' API 7 | module RubyGems 8 | SERVER = 'https://rubygems.org' 9 | GET_GEM = '/api/v2/rubygems/%s/versions/%s.json' 10 | 11 | class << self 12 | def gem_info(name, version) 13 | parse(Faraday.get(SERVER + format(GET_GEM, name, version)), name) 14 | end 15 | 16 | private 17 | 18 | def parse(response, name) 19 | case response.status 20 | when 200 21 | JSON.parse(response.body) 22 | when 404 23 | nil 24 | else 25 | warn(error_message(response: response, name: name)) 26 | end 27 | end 28 | 29 | def error_message(response:, name:) 30 | "Rubygems response for #{name}: HTTP #{response.status}: #{response.body}" 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/unwrappr/writers/title.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Writers 5 | # Add the gem name to the annotation as a heading. If a homepage 6 | # URI has been determined this heading will link to that page. 7 | # 8 | # Implements the `annotation_writer` interface required by the 9 | # LockFileAnnotator. 10 | module Title 11 | class << self 12 | def write(gem_change, gem_change_info) 13 | embellished_gem_name = maybe_link( 14 | gem_change.name, 15 | gem_change_info.dig(:ruby_gems, 'homepage_uri') 16 | ) 17 | "### #{embellished_gem_name}\n" 18 | end 19 | 20 | private 21 | 22 | def maybe_link(text, url) 23 | if url.nil? 24 | text 25 | else 26 | "[#{text}](#{url})" 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/unwrappr/lock_file_comparator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler' 4 | 5 | module Unwrappr 6 | # Compares two lock files and emits a diff of versions 7 | module LockFileComparator 8 | class << self 9 | def perform(lock_file_content_before, lock_file_content_after) 10 | lock_file_before = Bundler::LockfileParser.new(lock_file_content_before) 11 | lock_file_after = Bundler::LockfileParser.new(lock_file_content_after) 12 | 13 | versions_diff = SpecVersionComparator.perform( 14 | specs_versions(lock_file_before), 15 | specs_versions(lock_file_after) 16 | ) 17 | 18 | { versions: versions_diff } 19 | end 20 | 21 | private 22 | 23 | def specs_versions(lock_file) 24 | lock_file.specs.each_with_object({}) do |s, memo| 25 | memo[s.name.to_sym] = s.version.to_s 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/unwrappr/researchers/github_repo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Researchers 5 | # Checks the gem metadata to obtain a Github source repository if available. 6 | # 7 | # Implements the `gem_researcher` interface required by the 8 | # LockFileAnnotator. 9 | class GithubRepo 10 | GITHUB_URI_PATTERN = %r{^https?:// 11 | github.com/ 12 | (?[^/]+/[[:alnum:]_.-]+) 13 | }ix.freeze 14 | 15 | def research(_gem_change, gem_change_info) 16 | repo = match_repo(gem_change_info, 'source_code_uri') || 17 | match_repo(gem_change_info, 'homepage_uri') 18 | gem_change_info.merge(github_repo: repo) 19 | end 20 | 21 | def match_repo(gem_change_info, uri_name) 22 | uri = gem_change_info.dig(:ruby_gems, uri_name) 23 | match = GITHUB_URI_PATTERN.match(uri) 24 | match[:repo] if match 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/github/pr_sink_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | RSpec.describe Github::PrSink do 5 | subject(:pr_sink) { Github::PrSink.new(repo, pr_number, client) } 6 | 7 | let(:repo) { 'envato/unwrappr' } 8 | let(:pr_number) { 223 } 9 | let(:client) { instance_spy(Octokit::Client) } 10 | 11 | describe '#annotate_change' do 12 | subject(:annotate_change) { pr_sink.annotate_change(gem_change, message) } 13 | 14 | let(:message) { 'the-message' } 15 | let(:gem_change) do 16 | double( 17 | GemChange, 18 | sha: 'ba046f5', 19 | filename: 'the/Gemfile.lock', 20 | line_number: 98 21 | ) 22 | end 23 | 24 | it 'sends the annotation to GitHub' do 25 | annotate_change 26 | expect(client).to have_received(:create_pull_request_comment) 27 | .with( 28 | repo, 29 | pr_number, 30 | message, 31 | 'ba046f5', 32 | 'the/Gemfile.lock', 33 | 98 34 | ) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Pete Johns 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/unwrappr.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'unwrappr/bundler_command_runner' 4 | require 'unwrappr/cli' 5 | require 'unwrappr/gem_change' 6 | require 'unwrappr/gem_version' 7 | require 'unwrappr/git_command_runner' 8 | require 'unwrappr/github/client' 9 | require 'unwrappr/github/pr_sink' 10 | require 'unwrappr/github/pr_source' 11 | require 'unwrappr/lock_file_annotator' 12 | require 'unwrappr/lock_file_comparator' 13 | require 'unwrappr/lock_file_diff' 14 | require 'unwrappr/octokit' 15 | require 'unwrappr/researchers/composite' 16 | require 'unwrappr/researchers/github_comparison' 17 | require 'unwrappr/researchers/github_repo' 18 | require 'unwrappr/researchers/ruby_gems_info' 19 | require 'unwrappr/researchers/security_vulnerabilities' 20 | require 'unwrappr/ruby_gems' 21 | require 'unwrappr/spec_version_comparator' 22 | require 'unwrappr/version' 23 | require 'unwrappr/writers/composite' 24 | require 'unwrappr/writers/github_commit_log' 25 | require 'unwrappr/writers/project_links' 26 | require 'unwrappr/writers/security_vulnerabilities' 27 | require 'unwrappr/writers/title' 28 | require 'unwrappr/writers/version_change' 29 | 30 | # Define our namespace 31 | module Unwrappr 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/bundler_command_runner_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Unwrappr::BundlerCommandRunner do 6 | describe '#bundle_update!' do 7 | context 'Given bundle update fails' do 8 | before do 9 | allow(SafeShell).to receive(:execute?) 10 | .with( 11 | 'bundle', 12 | 'update', 13 | stdout: 'stdout.txt', 14 | stderr: 'error.txt' 15 | ).and_return false 16 | end 17 | 18 | it 'raises' do 19 | expect { Unwrappr::BundlerCommandRunner.bundle_update! } 20 | .to raise_error 'bundle update failed' 21 | end 22 | end 23 | 24 | context 'Given bundle update succeeds' do 25 | before do 26 | allow(SafeShell).to receive(:execute?) 27 | .with( 28 | 'bundle', 29 | 'update', 30 | stdout: 'stdout.txt', 31 | stderr: 'error.txt' 32 | ).and_return true 33 | end 34 | 35 | it 'does not raise' do 36 | expect { Unwrappr::BundlerCommandRunner.bundle_update! } 37 | .not_to raise_error 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/researchers/ruby_gems_info_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | RSpec.describe Researchers::RubyGemsInfo do 5 | subject(:ruby_gems_info) { Researchers::RubyGemsInfo.new } 6 | 7 | describe 'research' do 8 | subject(:research) do 9 | ruby_gems_info.research(gem_change, gem_change_info) 10 | end 11 | 12 | let(:gem_change) { instance_double(GemChange, name: gem_name, head_version: gem_version) } 13 | let(:gem_change_info) { { something_existing: 'random' } } 14 | let(:gem_name) { 'test-name' } 15 | let(:gem_version) { GemVersion.new('7.3.15') } 16 | let(:info) { 'this-is-the-info-from-rubygems' } 17 | 18 | before do 19 | allow(::Unwrappr::RubyGems).to receive(:gem_info).and_return(info) 20 | end 21 | 22 | it 'queries RubyGems using the gem name' do 23 | research 24 | expect(::Unwrappr::RubyGems).to have_received(:gem_info).with(gem_name, gem_version) 25 | end 26 | 27 | it 'returns the data from RubyGems' do 28 | expect(research).to include(ruby_gems: info) 29 | end 30 | 31 | it 'returns the data provided in gem_change_info' do 32 | expect(research).to include(gem_change_info) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/ruby_gems_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | RSpec.describe RubyGems do 5 | subject(:gem_info) { described_class.gem_info(gem_name, gem_version) } 6 | let(:gem_name) { 'gem_name' } 7 | let(:gem_version) { GemVersion.new('17.53.125') } 8 | 9 | let(:response) { double('faraday_response', status: response_status, body: response_body) } 10 | let(:response_status) { 200 } 11 | let(:response_body) { '{}' } 12 | 13 | before do 14 | allow(Faraday).to receive(:get).and_return(response) 15 | end 16 | 17 | context 'connectivity' do 18 | it 'requests rubygems.org API' do 19 | expect(Faraday).to receive(:get) 20 | .with('https://rubygems.org/api/v2/rubygems/gem_name/versions/17.53.125.json') 21 | 22 | gem_info 23 | end 24 | end 25 | 26 | context 'existing gem' do 27 | let(:response_body) { '{"key": "value" }' } 28 | 29 | it 'returns provided details' do 30 | expect(subject['key']).to eql('value') 31 | end 32 | end 33 | 34 | context 'unknown gem' do 35 | let(:response_status) { 404 } 36 | let(:response_body) { 'Not found' } 37 | 38 | it 'returns nil' do 39 | expect(subject).to be_nil 40 | end 41 | end 42 | 43 | context 'runtime error' do 44 | before do 45 | allow(Faraday).to receive(:get).and_raise(StandardError) 46 | end 47 | 48 | it 'is re-raised' do 49 | expect { subject }.to raise_error(instance_of(StandardError)) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/unwrappr/gem_change.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | 5 | module Unwrappr 6 | # Represents a gem change in a Gemfile.lock diff. 7 | class GemChange 8 | extend Forwardable 9 | 10 | def initialize( 11 | name:, head_version:, base_version:, line_number:, lock_file_diff: 12 | ) 13 | @name = name 14 | @head_version = head_version 15 | @base_version = base_version 16 | @line_number = line_number 17 | @lock_file_diff = lock_file_diff 18 | end 19 | 20 | attr_reader :name, :head_version, :base_version, :line_number 21 | 22 | def_delegators :@lock_file_diff, :filename, :sha 23 | 24 | def added? 25 | head_version && base_version.nil? 26 | end 27 | 28 | def removed? 29 | base_version && head_version.nil? 30 | end 31 | 32 | def major? 33 | head_version && base_version && 34 | head_version.major_difference?(base_version) 35 | end 36 | 37 | def minor? 38 | head_version && base_version && 39 | head_version.minor_difference?(base_version) 40 | end 41 | 42 | def patch? 43 | head_version && base_version && 44 | head_version.patch_difference?(base_version) 45 | end 46 | 47 | def hotfix? 48 | head_version && base_version && 49 | head_version.hotfix_difference?(base_version) 50 | end 51 | 52 | def upgrade? 53 | head_version && base_version && (head_version > base_version) 54 | end 55 | 56 | def downgrade? 57 | head_version && base_version && (head_version < base_version) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/unwrappr/gem_version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | # Represents the version of a gem. Helps in comparing two versions to 5 | # identify differences and extracting the major, minor and patch components 6 | # that make up semantic versioning. https://semver.org/ 7 | class GemVersion 8 | include Comparable 9 | 10 | def initialize(version_string) 11 | @version_string = version_string 12 | @version = Gem::Version.create(version_string) 13 | @major = segment(0) 14 | @minor = segment(1) 15 | @patch = segment(2) 16 | @hotfix = segment(3) 17 | end 18 | 19 | attr_reader :major, :minor, :patch, :hotfix, :version 20 | 21 | def major_difference?(other) 22 | (major != other.major) 23 | end 24 | 25 | def minor_difference?(other) 26 | (major == other.major) && 27 | (minor != other.minor) 28 | end 29 | 30 | def patch_difference?(other) 31 | (major == other.major) && 32 | (minor == other.minor) && 33 | (patch != other.patch) 34 | end 35 | 36 | def hotfix_difference?(other) 37 | (major == other.major) && 38 | (minor == other.minor) && 39 | (patch == other.patch) && 40 | (hotfix != other.hotfix) 41 | end 42 | 43 | def <=>(other) 44 | @version <=> other.version 45 | end 46 | 47 | def to_s 48 | @version_string 49 | end 50 | 51 | private 52 | 53 | def segment(index) 54 | segment = @version.canonical_segments[index] || 0 55 | (segment.is_a?(Numeric) ? segment : nil) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/unwrappr/researchers/security_vulnerabilities.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/audit' 4 | 5 | module Unwrappr 6 | module Researchers 7 | # Checks for security vulnerabilities using the Advisory DB 8 | # https://github.com/rubysec/ruby-advisory-db 9 | # 10 | # Implements the `gem_researcher` interface required by the 11 | # LockFileAnnotator. 12 | class SecurityVulnerabilities 13 | Vulnerabilites = Struct.new(:patched, :introduced, :remaining) 14 | 15 | def research(gem_change, gem_change_info) 16 | gem_change_info.merge( 17 | security_vulnerabilities: vulnerabilities(gem_change) 18 | ) 19 | end 20 | 21 | private 22 | 23 | def vulnerabilities(gem) 24 | advisories = database.advisories_for(gem.name) 25 | base_advisories = vulnerable_advisories(gem.base_version, advisories) 26 | head_advisories = vulnerable_advisories(gem.head_version, advisories) 27 | Vulnerabilites.new( 28 | base_advisories - head_advisories, 29 | head_advisories - base_advisories, 30 | base_advisories & head_advisories 31 | ) 32 | end 33 | 34 | def database 35 | return @database if defined?(@database) 36 | 37 | Bundler::Audit::Database.update!(quiet: true) 38 | @database = Bundler::Audit::Database.new 39 | end 40 | 41 | def vulnerable_advisories(gem_version, advisories) 42 | return [] if gem_version.nil? 43 | 44 | advisories.select do |advisory| 45 | advisory.vulnerable?(gem_version.version) 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/spec_version_comparator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Unwrappr::SpecVersionComparator do 6 | subject(:perform) { described_class.perform(specs_before, specs_after) } 7 | let(:specs_before) { {} } 8 | let(:specs_after) { {} } 9 | 10 | context 'empty specs lists' do 11 | it 'returns empty change set' do 12 | expect(subject).to be_empty 13 | end 14 | end 15 | 16 | context 'version upgrade' do 17 | let(:specs_before) { { cloudinary: '1.9.1' } } 18 | let(:specs_after) { { cloudinary: '1.10.0' } } 19 | it 'returns versions' do 20 | expect(subject).to eq([{ dependency: :cloudinary, before: '1.9.1', after: '1.10.0' }]) 21 | end 22 | end 23 | 24 | context 'added dependency' do 25 | let(:specs_before) { {} } 26 | let(:specs_after) { { cloudinary: '1.10.0' } } 27 | it 'returns versions' do 28 | expect(subject).to eq([{ dependency: :cloudinary, before: nil, after: '1.10.0' }]) 29 | end 30 | end 31 | 32 | context 'removed dependency' do 33 | let(:specs_before) { { cloudinary: '1.9.1' } } 34 | let(:specs_after) { {} } 35 | it 'returns versions' do 36 | expect(subject).to eq([{ dependency: :cloudinary, before: '1.9.1', after: nil }]) 37 | end 38 | end 39 | 40 | context 'multiple dependencies' do 41 | let(:specs_before) { { cloudinary: '1.9.1', apiary: '2.3.1' } } 42 | let(:specs_after) { { cloudinary: '1.9.1', apiary: '3.1.1' } } 43 | 44 | it 'returns only updated dependencies', :aggregate_failures do 45 | expect(subject.size).to eq 1 46 | expect(subject.first[:dependency]).to eq :apiary 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/lock_file_comparator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | RSpec.describe LockFileComparator do 5 | subject(:perform) { described_class.perform(lock_file_content_before, lock_file_content_after) } 6 | let(:lock_file_content_before) { double('lock_file_content_before') } 7 | let(:lock_file_content_after) { double('lock_file_content_after') } 8 | 9 | let(:lock_file_before) { double('lock_file_before', specs: specs_before) } 10 | let(:lock_file_after) { double('lock_file_after', specs: specs_after) } 11 | 12 | let(:specs_before) { [double(name: 'name1', version: 'version1')] } 13 | let(:specs_after) { [double(name: 'name2', version: 'version2')] } 14 | 15 | let(:specs_versions_comparation_results) { double('specs_versions_comparation_results') } 16 | 17 | before do 18 | allow(SpecVersionComparator).to receive(:perform).and_return(specs_versions_comparation_results) 19 | 20 | allow(Bundler::LockfileParser).to receive(:new) 21 | .with(lock_file_content_before) 22 | .and_return(lock_file_before) 23 | allow(Bundler::LockfileParser).to receive(:new) 24 | .with(lock_file_content_after) 25 | .and_return(lock_file_after) 26 | end 27 | 28 | it 'calls the comparator with indexed specs versions' do 29 | expect(SpecVersionComparator).to receive(:perform) 30 | .with({ name1: 'version1' }, { name2: 'version2' }) 31 | 32 | perform 33 | end 34 | 35 | it 'returns the difference in specs versions' do 36 | expected_result = { 37 | versions: specs_versions_comparation_results 38 | } 39 | 40 | expect(subject).to eql(expected_result) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/unwrappr/researchers/github_comparison.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Researchers 5 | # Compares the old version to the new via the Github API: 6 | # https://developer.github.com/v3/repos/commits/#compare-two-commits 7 | # 8 | # Implements the `gem_researcher` interface required by the 9 | # LockFileAnnotator. 10 | class GithubComparison 11 | def initialize(client) 12 | @client = client 13 | end 14 | 15 | def research(gem_change, change_info) 16 | return change_info if github_repo_not_identified?(change_info) || 17 | gem_added_or_removed?(gem_change) 18 | 19 | change_info.merge( 20 | github_comparison: try_comparing( 21 | repo: github_repo(change_info), 22 | base: gem_change.base_version, 23 | head: gem_change.head_version 24 | ) 25 | ) 26 | end 27 | 28 | private 29 | 30 | def try_comparing(repo:, base:, head:) 31 | comparison = compare(repo, "v#{base}", "v#{head}") 32 | comparison ||= compare(repo, base.to_s, head.to_s) 33 | comparison 34 | end 35 | 36 | def compare(repo, base, head) 37 | @client.compare(repo, base, head) 38 | rescue Octokit::NotFound 39 | nil 40 | end 41 | 42 | def github_repo_not_identified?(gem_change_info) 43 | github_repo(gem_change_info).nil? 44 | end 45 | 46 | def github_repo(gem_change_info) 47 | gem_change_info[:github_repo] 48 | end 49 | 50 | def gem_added_or_removed?(gem_change) 51 | gem_change.base_version.nil? || gem_change.head_version.nil? 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/unwrappr/github/pr_source.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'base64' 4 | 5 | module Unwrappr 6 | module Github 7 | # Obtains Gemfile.lock changes from a Github Pull Request 8 | # 9 | # Implements the `lock_file_diff_source` interface as defined by the 10 | # LockFileAnnotator. 11 | class PrSource 12 | def initialize(repo, pr_number, lock_files, client) 13 | @repo = repo 14 | @pr_number = pr_number 15 | @lock_files = lock_files 16 | @client = client 17 | end 18 | 19 | def each_file 20 | lock_file_diffs.each do |lock_file_diff| 21 | yield LockFileDiff.new( 22 | filename: lock_file_diff.filename, 23 | base_file: file_contents(lock_file_diff.filename, base_sha), 24 | head_file: file_contents(lock_file_diff.filename, head_sha), 25 | patch: lock_file_diff.patch, 26 | sha: head_sha 27 | ) 28 | end 29 | end 30 | 31 | private 32 | 33 | def lock_file_diffs 34 | @lock_file_diffs ||= @client 35 | .pull_request_files(@repo, @pr_number) 36 | .select do |file| 37 | @lock_files.include?(File.basename(file.filename)) 38 | end 39 | end 40 | 41 | def file_contents(filename, ref) 42 | Base64.decode64( 43 | @client.contents(@repo, path: filename, ref: ref).content 44 | ) 45 | end 46 | 47 | def head_sha 48 | @head_sha ||= pull_request.head.sha 49 | end 50 | 51 | def base_sha 52 | @base_sha ||= pull_request.base.sha 53 | end 54 | 55 | def pull_request 56 | @pull_request ||= @client.pull_request(@repo, @pr_number) 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/unwrappr/writers/project_links.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Writers 5 | # Add links to project documentation as obtained from Rubygems.org. 6 | # Specifically, the changelog and sourcecode. 7 | # 8 | # Implements the `annotation_writer` interface required by the 9 | # LockFileAnnotator. 10 | class ProjectLinks 11 | def self.write(gem_change, gem_change_info) 12 | new(gem_change, gem_change_info).write 13 | end 14 | 15 | def initialize(gem_change, gem_change_info) 16 | @gem_change = gem_change 17 | @gem_change_info = gem_change_info 18 | end 19 | 20 | def write 21 | "[_#{change_log}, #{source_code}, #{gem_diff}_]\n" 22 | end 23 | 24 | private 25 | 26 | def change_log 27 | link_or_strikethrough('change-log', ruby_gems_info('changelog_uri')) 28 | end 29 | 30 | def source_code 31 | link_or_strikethrough('source-code', ruby_gems_info('source_code_uri')) 32 | end 33 | 34 | GEM_DIFF_URL_TEMPLATE = 'https://my.diffend.io/gems/%s/%s/%s' 35 | private_constant :GEM_DIFF_URL_TEMPLATE 36 | 37 | def gem_diff 38 | if !ruby_gems_info.nil? && !@gem_change.added? && !@gem_change.removed? 39 | gem_diff_url = format(GEM_DIFF_URL_TEMPLATE, 40 | @gem_change.name, 41 | @gem_change.base_version.to_s, 42 | @gem_change.head_version.to_s) 43 | end 44 | link_or_strikethrough('gem-diff', gem_diff_url) 45 | end 46 | 47 | def ruby_gems_info(*args) 48 | @gem_change_info.dig(:ruby_gems, *args) 49 | end 50 | 51 | def link_or_strikethrough(text, url) 52 | if url.nil? || url.empty? 53 | "~~#{text}~~" 54 | else 55 | "[#{text}](#{url})" 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/unwrappr/writers/version_change.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Writers 5 | # Describe the version change. Is it an upgrade to a later version, or a 6 | # downgrade to an older version? Is it a major, minor or patch version 7 | # change? 8 | # 9 | # Implements the `annotation_writer` interface required by the 10 | # LockFileAnnotator. 11 | class VersionChange 12 | extend Forwardable 13 | 14 | def self.write(gem_change, gem_change_info) 15 | new(gem_change, gem_change_info).write 16 | end 17 | 18 | def initialize(gem_change, gem_change_info) 19 | @gem_change = gem_change 20 | @gem_change_info = gem_change_info 21 | end 22 | 23 | def write 24 | "#{change_description}\n" 25 | end 26 | 27 | private 28 | 29 | def_delegators(:@gem_change, 30 | :added?, :removed?, :major?, :minor?, :patch?, :hotfix?, 31 | :upgrade?, :downgrade?, :base_version, :head_version) 32 | 33 | def change_description 34 | if added? 35 | 'Gem added :snowman:' 36 | elsif removed? 37 | 'Gem removed :fire:' 38 | else 39 | version_description 40 | end 41 | end 42 | 43 | def version_description 44 | if major? 45 | "**Major** version #{grade}:exclamation: #{version_diff}" 46 | elsif minor? 47 | "**Minor** version #{grade}:large_orange_diamond: #{version_diff}" 48 | elsif patch? 49 | "**Patch** version #{grade}:small_blue_diamond: #{version_diff}" 50 | elsif hotfix? 51 | "**Hotfix** version #{grade}:small_red_triangle: #{version_diff}" 52 | end 53 | end 54 | 55 | def grade 56 | if upgrade? 57 | 'upgrade :chart_with_upwards_trend:' 58 | elsif downgrade? 59 | 'downgrade :chart_with_downwards_trend::exclamation:' 60 | end 61 | end 62 | 63 | def version_diff 64 | "#{base_version} → #{head_version}" 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /unwrappr.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'unwrappr/version' 6 | 7 | AUTHORS = { 8 | 'emilyn.escabarte@envato.com' => 'Emilyn Escabarte', 9 | 'joe.sustaric@envato.com' => 'Joe Sustaric', 10 | 'orien.madgwick@envato.com' => 'Orien Madgwick', 11 | 'paj+rubygems@johnsy.com' => 'Pete Johns', 12 | 'vladimir.chervanev@envato.com' => 'Vladimir Chervanev' 13 | }.freeze 14 | 15 | GITHUB_URL = 'https://github.com/envato/unwrappr' 16 | HOMEPAGE_URL = GITHUB_URL 17 | 18 | Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength 19 | spec.name = 'unwrappr' 20 | spec.version = Unwrappr::VERSION 21 | spec.authors = AUTHORS.values 22 | spec.email = AUTHORS.keys 23 | 24 | spec.summary = "A tool to unwrap your gems and see what's changed easily" 25 | spec.description = 'bundle update PRs: Automated. Annotated.' 26 | spec.homepage = HOMEPAGE_URL 27 | spec.license = 'MIT' 28 | spec.required_ruby_version = '>= 2.5' 29 | spec.required_rubygems_version = '>= 2.7' 30 | 31 | spec.files = Dir.chdir(__dir__) do 32 | `git ls-files -z`.split("\x0").reject do |f| 33 | f.start_with?(*%w[. CODE_OF_CONDUCT Gemfile Guardfile Rakefile bin spec unwrappr.gemspec]) 34 | end 35 | end 36 | spec.bindir = 'exe' 37 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 38 | spec.require_paths = ['lib'] 39 | 40 | spec.add_dependency 'base64' 41 | spec.add_dependency 'bundler', '< 3' 42 | spec.add_dependency 'bundler-audit', '>= 0.6.0' 43 | spec.add_dependency 'clamp', '~> 1' 44 | spec.add_dependency 'faraday', '~> 1' 45 | spec.add_dependency 'git', '~> 1' 46 | spec.add_dependency 'octokit', '~> 4.0' 47 | spec.add_dependency 'safe_shell', '~> 1' 48 | 49 | spec.metadata = { 50 | 'bug_tracker_uri' => "#{GITHUB_URL}/issues", 51 | 'changelog_uri' => "#{GITHUB_URL}/blob/HEAD/CHANGELOG.md", 52 | 'documentation_uri' => "#{GITHUB_URL}/blob/HEAD/README.md", 53 | 'homepage_uri' => HOMEPAGE_URL, 54 | 'rubygems_mfa_required' => 'true', 55 | 'source_code_uri' => GITHUB_URL 56 | } 57 | end 58 | -------------------------------------------------------------------------------- /lib/unwrappr/github/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'octokit' 4 | 5 | module Unwrappr 6 | module GitHub 7 | # GitHub Interactions 8 | module Client 9 | class << self 10 | def reset_client 11 | @git_client = nil 12 | @github_token = nil 13 | end 14 | 15 | def make_pull_request!(lock_files) 16 | create_and_annotate_pull_request(lock_files) 17 | rescue Octokit::ClientError => e 18 | raise "Failed to create and annotate pull request: #{e}" 19 | end 20 | 21 | private 22 | 23 | def repo_name_and_org 24 | repo_url = Unwrappr::GitCommandRunner.remote.gsub(/\.git$/, '') 25 | pattern = %r{github.com[/:](?.*)/(?.*)} 26 | m = pattern.match(repo_url) 27 | [m[:org], m[:repo]].join('/') 28 | end 29 | 30 | def create_and_annotate_pull_request(lock_files) 31 | pr = git_client.create_pull_request( 32 | repo_name_and_org, 33 | repo_default_branch, 34 | Unwrappr::GitCommandRunner.current_branch_name, 35 | 'Automated Bundle Update', 36 | pull_request_body 37 | ) 38 | annotate_pull_request(pr.number, lock_files) 39 | end 40 | 41 | def repo_default_branch 42 | git_client.repository(repo_name_and_org) 43 | .default_branch 44 | end 45 | 46 | def pull_request_body 47 | <<~BODY 48 | Gems brought up-to-date with :heart: by [Unwrappr](https://github.com/envato/unwrappr). 49 | See individual annotations below for details. 50 | BODY 51 | end 52 | 53 | def annotate_pull_request(pr_number, lock_files) 54 | LockFileAnnotator.annotate_github_pull_request( 55 | repo: repo_name_and_org, 56 | pr_number: pr_number, 57 | lock_files: lock_files, 58 | client: git_client 59 | ) 60 | end 61 | 62 | def git_client 63 | @git_client ||= Octokit::Client.new(access_token: Octokit.access_token_from_environment) 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/unwrappr/writers/github_commit_log.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Writers 5 | # Inform of the number of commits included in the change. Annotate several 6 | # commits, and link to the Github compare page on which we can see all the 7 | # commits and file changes. 8 | # 9 | # Implements the `annotation_writer` interface required by the 10 | # LockFileAnnotator. 11 | class GithubCommitLog 12 | MAX_COMMITS = 10 13 | MAX_MESSAGE = 60 14 | SHA_LENGTH = 7 15 | 16 | def self.write(gem_change, gem_change_info) 17 | new(gem_change, gem_change_info).write 18 | end 19 | 20 | def initialize(gem_change, gem_change_info) 21 | @gem_change = gem_change 22 | @gem_change_info = gem_change_info 23 | end 24 | 25 | def write 26 | return nil if comparison.nil? 27 | 28 | collapsed_section('Commits', <<~MESSAGE) 29 | A change of **#{comparison.total_commits}** commits. See the full changes on [the compare page](#{comparison.html_url}). 30 | 31 | #{list_commits_introduction} 32 | #{commit_messages.join("\n")} 33 | MESSAGE 34 | end 35 | 36 | private 37 | 38 | def list_commits_introduction 39 | if comparison.commits.length > MAX_COMMITS 40 | "These are the first #{MAX_COMMITS} commits:" 41 | else 42 | 'These are the individual commits:' 43 | end 44 | end 45 | 46 | def commit_messages 47 | comparison.commits.first(MAX_COMMITS).map(&method(:commit_message)) 48 | end 49 | 50 | def commit_message(commit) 51 | message = commit.commit.message.lines.first.strip 52 | message = "#{message[0, MAX_MESSAGE]}…" if message.length > MAX_MESSAGE 53 | "- (#{commit.sha[0, SHA_LENGTH]}) [#{message}](#{commit.html_url})" 54 | end 55 | 56 | def comparison 57 | @gem_change_info[:github_comparison] 58 | end 59 | 60 | def collapsed_section(summary, body) 61 | <<~MESSAGE 62 |
63 | #{summary} 64 | 65 | #{body} 66 | 67 |
68 | MESSAGE 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/researchers/github_repo_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Researchers 5 | RSpec.describe GithubRepo do 6 | subject(:github_repo) { described_class.new } 7 | 8 | describe '#research' do 9 | subject(:research) { github_repo.research(gem_change, gem_change_info) } 10 | 11 | let(:gem_change) { instance_double(GemChange) } 12 | [ 13 | [nil, nil, nil], 14 | ['', '', nil], 15 | [' ', ' ', nil], 16 | [nil, 'https://github.com/envato/stack_master/tree', 'envato/stack_master'], 17 | ['', 'https://github.com/envato/stack_master/tree', 'envato/stack_master'], 18 | [' ', 'https://github.com/envato/stack_master/tree', 'envato/stack_master'], 19 | ['https://bitbucket.org/envato/unwrappr/tree', 'https://github.com/envato/stack_master/tree', 'envato/stack_master'], 20 | ['https://github.com/envato/unwrappr/tree', 'https://github.com/envato/stack_master/tree', 'envato/unwrappr'], 21 | [nil, 'https://github.com/rubymem/bundler-leak#readme', 'rubymem/bundler-leak'] 22 | ].each do |source_code_uri, homepage_uri, expected_repo| 23 | context "given source_code_uri: #{source_code_uri.inspect}, homepage_uri: #{homepage_uri.inspect}" do 24 | let(:gem_change_info) do 25 | { 26 | ruby_gems: { 27 | 'source_code_uri' => source_code_uri, 28 | 'homepage_uri' => homepage_uri 29 | } 30 | } 31 | end 32 | 33 | it "sets the github_repo to #{expected_repo.inspect}" do 34 | expect(research[:github_repo]).to eq(expected_repo) 35 | end 36 | 37 | it 'returns the data provided in gem_change_info' do 38 | expect(research).to include(gem_change_info) 39 | end 40 | end 41 | end 42 | 43 | context 'given no ruby_gems info' do 44 | let(:gem_change_info) { { ruby_gems: nil, something_else: 'xyz' } } 45 | 46 | it 'sets the github_repo to nil' do 47 | expect(research[:github_repo]).to be_nil 48 | end 49 | 50 | it 'returns the data provided in gem_change_info' do 51 | expect(research).to include(gem_change_info) 52 | end 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/unwrappr/lock_file_diff.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | # Responsible for identifying all gem changes between two versions of a 5 | # Gemfile.lock file. 6 | class LockFileDiff 7 | def initialize(filename:, base_file:, head_file:, patch:, sha:) 8 | @filename = filename 9 | @base_file = base_file 10 | @head_file = head_file 11 | @patch = patch 12 | @sha = sha 13 | end 14 | 15 | attr_reader :filename, :sha 16 | 17 | def each_gem_change 18 | version_changes.each do |change| 19 | yield GemChange.new( 20 | name: change[:dependency].to_s, 21 | base_version: gem_version(change[:before]), 22 | head_version: gem_version(change[:after]), 23 | line_number: line_number_for_change(change), 24 | lock_file_diff: self 25 | ) 26 | end 27 | end 28 | 29 | private 30 | 31 | def version_changes 32 | @version_changes ||= 33 | LockFileComparator.perform(@base_file, @head_file)[:versions] 34 | end 35 | 36 | def gem_version(version) 37 | version && GemVersion.new(version) 38 | end 39 | 40 | # Obtain the line in the patch that should be annotated 41 | def line_number_for_change(change) 42 | # If a gem is removed, use the `-` line (as there is no `+` line). 43 | # For all other cases use the `+` line. 44 | type = (change[:after].nil? ? '-' : '+') 45 | line_numbers[change[:dependency].to_s][type] 46 | end 47 | 48 | def line_numbers 49 | return @line_numbers if defined?(@line_numbers) 50 | 51 | @line_numbers = Hash.new { |hash, key| hash[key] = {} } 52 | @patch.split("\n").each_with_index do |line, line_number| 53 | gem_name, change_type = extract_gem_and_change_type(line) 54 | next if gem_name.nil? || change_type.nil? 55 | 56 | @line_numbers[gem_name][change_type] = line_number 57 | end 58 | @line_numbers 59 | end 60 | 61 | def extract_gem_and_change_type(line) 62 | # We only care about lines like this: 63 | # '+ websocket-driver (0.6.5)' 64 | # Careful not to match this (note the wider indent): 65 | # '+ websocket-extensions (>= 0.1.0)' 66 | pattern = /^(?[+-]) (?\S+) \(\d/ 67 | match = pattern.match(line) 68 | [match[:gem_name], match[:change_type]] unless match.nil? 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/unwrappr/lock_file_annotator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | # The main entry object for annotating Gemfile.lock files. 5 | # 6 | # This class has four main collaborators: 7 | # 8 | # - **lock_file_diff_source**: Provides a means of obtaining `LockFileDiff` 9 | # instances. 10 | # 11 | # - **annotation_sink**: A place to send gem change annotations. 12 | # 13 | # - **gem_researcher**: Collects extra information about the gem change. 14 | # Unwrapprs if you will. 15 | # 16 | # - **annotation_writer**: Collects the gem change and all the collated 17 | # research and presents it in a nicely formatted annotation. 18 | class LockFileAnnotator 19 | # rubocop:disable Metrics/MethodLength 20 | def self.annotate_github_pull_request( 21 | repo:, pr_number:, lock_files:, client: Octokit.client 22 | ) 23 | new( 24 | lock_file_diff_source: Github::PrSource.new(repo, pr_number, lock_files, client), 25 | annotation_sink: Github::PrSink.new(repo, pr_number, client), 26 | annotation_writer: Writers::Composite.new( 27 | Writers::Title, 28 | Writers::VersionChange, 29 | Writers::ProjectLinks, 30 | Writers::SecurityVulnerabilities, 31 | Writers::GithubCommitLog 32 | ), 33 | gem_researcher: Researchers::Composite.new( 34 | Researchers::RubyGemsInfo.new, 35 | Researchers::GithubRepo.new, 36 | Researchers::GithubComparison.new(client), 37 | Researchers::SecurityVulnerabilities.new 38 | ) 39 | ).annotate 40 | end 41 | # rubocop:enable Metrics/MethodLength 42 | 43 | def initialize( 44 | lock_file_diff_source:, 45 | annotation_sink:, 46 | annotation_writer:, 47 | gem_researcher: 48 | ) 49 | @lock_file_diff_source = lock_file_diff_source 50 | @annotation_sink = annotation_sink 51 | @annotation_writer = annotation_writer 52 | @gem_researcher = gem_researcher 53 | end 54 | 55 | def annotate 56 | @lock_file_diff_source.each_file do |lock_file_diff| 57 | puts "Annotating #{lock_file_diff.filename}" 58 | 59 | lock_file_diff.each_gem_change do |gem_change| 60 | gem_change_info = @gem_researcher.research(gem_change, {}) 61 | message = @annotation_writer.write(gem_change, gem_change_info) 62 | @annotation_sink.annotate_change(gem_change, message) 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/unwrappr/git_command_runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'git' 4 | require 'logger' 5 | 6 | module Unwrappr 7 | # Runs Git commands 8 | module GitCommandRunner 9 | class << self 10 | def create_branch!(base_branch:) 11 | raise 'Not a git working dir' unless git_dir? 12 | raise "failed to create branch from '#{base_branch}'" unless checkout_target_branch(base_branch: base_branch) 13 | end 14 | 15 | def commit_and_push_changes! 16 | raise 'failed to add git changes' unless stage_all_changes 17 | raise 'failed to commit changes' unless commit_staged_changes 18 | raise 'failed to push changes' unless push_current_branch_to_origin 19 | end 20 | 21 | def reset_client 22 | @git = nil 23 | end 24 | 25 | def show(revision, path) 26 | git.show(revision, path) 27 | rescue Git::GitExecuteError 28 | nil 29 | end 30 | 31 | def remote 32 | git.config('remote.origin.url') 33 | end 34 | 35 | def current_branch_name 36 | git.current_branch 37 | end 38 | 39 | def clone_repository(repo, directory) 40 | git_wrap { Git.clone(repo, directory) } 41 | end 42 | 43 | def file_exist?(filename) 44 | !git.ls_files(filename).empty? 45 | end 46 | 47 | private 48 | 49 | def git_dir? 50 | git_wrap { !current_branch_name.empty? } 51 | end 52 | 53 | def checkout_target_branch(base_branch:) 54 | timestamp = Time.now.strftime('%Y%m%d-%H%M').freeze 55 | git_wrap do 56 | git.checkout(base_branch) unless base_branch.nil? 57 | git.branch("auto_bundle_update_#{timestamp}").checkout 58 | end 59 | end 60 | 61 | def stage_all_changes 62 | git_wrap { git.add(all: true) } 63 | end 64 | 65 | def commit_staged_changes 66 | git_wrap { git.commit('Automatic Bundle Update') } 67 | end 68 | 69 | def push_current_branch_to_origin 70 | git_wrap { git.push('origin', current_branch_name) } 71 | end 72 | 73 | def git 74 | if Dir.pwd == @git&.dir&.path 75 | @git 76 | else 77 | Git.open(Dir.pwd, log_options) 78 | end 79 | end 80 | 81 | def log_options 82 | {}.tap do |opt| 83 | opt[:log] = Logger.new($stdout) if ENV['DEBUG'] 84 | end 85 | end 86 | 87 | def git_wrap 88 | yield 89 | true 90 | rescue Git::GitExecuteError 91 | false 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/writers/version_change_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Writers 5 | RSpec.describe VersionChange do 6 | describe '.write' do 7 | subject(:write) { VersionChange.write(gem_change, gem_change_info) } 8 | 9 | let(:gem_change) do 10 | GemChange.new(name: 'test-gem', 11 | head_version: head_version, 12 | base_version: base_version, 13 | line_number: 9870, 14 | lock_file_diff: instance_double(LockFileDiff)) 15 | end 16 | let(:gem_change_info) { {} } 17 | 18 | context 'change from 3.9.0 to 4.0.5' do 19 | let(:base_version) { GemVersion.new('3.9.0') } 20 | let(:head_version) { GemVersion.new('4.0.5') } 21 | 22 | it { should eq <<~MESSAGE } 23 | **Major** version upgrade :chart_with_upwards_trend::exclamation: 3.9.0 → 4.0.5 24 | MESSAGE 25 | end 26 | 27 | context 'change from 3.9.0 to 3.8.5' do 28 | let(:base_version) { GemVersion.new('3.9.0') } 29 | let(:head_version) { GemVersion.new('3.8.5') } 30 | 31 | it { should eq <<~MESSAGE } 32 | **Minor** version downgrade :chart_with_downwards_trend::exclamation::large_orange_diamond: 3.9.0 → 3.8.5 33 | MESSAGE 34 | end 35 | 36 | context 'change from 4.2.0 to 4.2.1' do 37 | let(:base_version) { GemVersion.new('4.2.0') } 38 | let(:head_version) { GemVersion.new('4.2.1') } 39 | 40 | it { should eq <<~MESSAGE } 41 | **Patch** version upgrade :chart_with_upwards_trend::small_blue_diamond: 4.2.0 → 4.2.1 42 | MESSAGE 43 | end 44 | 45 | context 'change from 6.0.2.2 to 6.0.2.1' do 46 | let(:base_version) { GemVersion.new('6.0.2.2') } 47 | let(:head_version) { GemVersion.new('6.0.2.1') } 48 | 49 | it { should eq <<~MESSAGE } 50 | **Hotfix** version downgrade :chart_with_downwards_trend::exclamation::small_red_triangle: 6.0.2.2 → 6.0.2.1 51 | MESSAGE 52 | end 53 | 54 | context 'gem added at 3.8.5' do 55 | let(:base_version) { nil } 56 | let(:head_version) { GemVersion.new('3.8.5') } 57 | 58 | it { should start_with <<~MESSAGE } 59 | Gem added :snowman: 60 | MESSAGE 61 | end 62 | 63 | context 'gem removed at 3.8.5' do 64 | let(:base_version) { GemVersion.new('3.8.5') } 65 | let(:head_version) { nil } 66 | 67 | it { should start_with <<~MESSAGE } 68 | Gem removed :fire: 69 | MESSAGE 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/writers/project_links_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Writers 5 | RSpec.describe ProjectLinks do 6 | describe '.write' do 7 | subject(:write) { ProjectLinks.write(gem_change, gem_change_info) } 8 | 9 | let(:gem_change) do 10 | GemChange.new( 11 | name: 'name', 12 | base_version: base_version, 13 | head_version: head_version, 14 | line_number: nil, 15 | lock_file_diff: nil 16 | ) 17 | end 18 | let(:base_version) { GemVersion.new('1.0') } 19 | let(:head_version) { GemVersion.new('2.0') } 20 | 21 | context 'given gem change info with urls' do 22 | let(:gem_change_info) do 23 | { 24 | ruby_gems: { 25 | 'source_code_uri' => 'source-uri', 26 | 'changelog_uri' => 'changelog-uri' 27 | } 28 | } 29 | end 30 | 31 | it { should eq <<~MESSAGE } 32 | [_[change-log](changelog-uri), [source-code](source-uri), [gem-diff](https://my.diffend.io/gems/name/1.0/2.0)_] 33 | MESSAGE 34 | end 35 | 36 | context 'given gem change info with urls for an added gem' do 37 | let(:gem_change_info) do 38 | { 39 | ruby_gems: { 40 | 'source_code_uri' => 'source-uri', 41 | 'changelog_uri' => 'changelog-uri' 42 | } 43 | } 44 | end 45 | let(:base_version) { nil } 46 | 47 | it { should eq <<~MESSAGE } 48 | [_[change-log](changelog-uri), [source-code](source-uri), ~~gem-diff~~_] 49 | MESSAGE 50 | end 51 | 52 | context 'given gem change info with urls for a removed gem' do 53 | let(:gem_change_info) do 54 | { 55 | ruby_gems: { 56 | 'source_code_uri' => 'source-uri', 57 | 'changelog_uri' => 'changelog-uri' 58 | } 59 | } 60 | end 61 | let(:head_version) { nil } 62 | 63 | it { should eq <<~MESSAGE } 64 | [_[change-log](changelog-uri), [source-code](source-uri), ~~gem-diff~~_] 65 | MESSAGE 66 | end 67 | 68 | context 'given gem change info with missing urls' do 69 | let(:gem_change_info) do 70 | { 71 | ruby_gems: spy(source_code_uri: '', 72 | changelog_uri: '') 73 | } 74 | end 75 | 76 | it { is_expected.to eq <<~MESSAGE } 77 | [_~~change-log~~, ~~source-code~~, [gem-diff](https://my.diffend.io/gems/name/1.0/2.0)_] 78 | MESSAGE 79 | end 80 | 81 | context 'given no gem change info' do 82 | let(:gem_change_info) { {} } 83 | 84 | it { should eq <<~MESSAGE } 85 | [_~~change-log~~, ~~source-code~~, ~~gem-diff~~_] 86 | MESSAGE 87 | end 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/unwrappr/writers/security_vulnerabilities.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Writers 5 | # Present reported security vulnerabilities in the gem change annotation. 6 | # 7 | # Implements the `annotation_writer` interface required by the 8 | # LockFileAnnotator. 9 | class SecurityVulnerabilities 10 | def self.write(gem_change, gem_change_info) 11 | new(gem_change, gem_change_info).write 12 | end 13 | 14 | def initialize(gem_change, gem_change_info) 15 | @gem_change = gem_change 16 | @gem_change_info = gem_change_info 17 | end 18 | 19 | def write 20 | return nil if vulnerabilities.nil? 21 | 22 | <<~MESSAGE 23 | #{patched_vulnerabilities} 24 | #{introduced_vulnerabilities} 25 | #{remaining_vulnerabilities} 26 | MESSAGE 27 | end 28 | 29 | private 30 | 31 | def patched_vulnerabilities 32 | list_vulnerabilites( 33 | ':tada: Patched vulnerabilities:', 34 | vulnerabilities.patched 35 | ) 36 | end 37 | 38 | def introduced_vulnerabilities 39 | list_vulnerabilites( 40 | ':rotating_light::exclamation: Introduced vulnerabilities:', 41 | vulnerabilities.introduced 42 | ) 43 | end 44 | 45 | def remaining_vulnerabilities 46 | list_vulnerabilites( 47 | ':rotating_light: Remaining vulnerabilities:', 48 | vulnerabilities.remaining 49 | ) 50 | end 51 | 52 | def list_vulnerabilites(message, advisories) 53 | return nil if advisories.empty? 54 | 55 | <<~MESSAGE 56 | #{message} 57 | 58 | #{advisories.map(&method(:render_vulnerability)).join("\n")} 59 | MESSAGE 60 | end 61 | 62 | def render_vulnerability(advisory) 63 | <<~MESSAGE 64 | - #{identifier(advisory)} 65 | **#{advisory.title}** 66 | 67 | #{cvss_v2(advisory)} 68 | #{link(advisory)} 69 | 70 | #{advisory.description&.gsub("\n", ' ')&.strip} 71 | 72 | MESSAGE 73 | end 74 | 75 | def identifier(advisory) 76 | if advisory.cve_id 77 | "[#{advisory.cve_id}](#{cve_url(advisory.cve_id)})" 78 | elsif advisory.osvdb_id 79 | advisory.osvdb_id 80 | end 81 | end 82 | 83 | def cve_url(id) 84 | "https://nvd.nist.gov/vuln/detail/#{id}" 85 | end 86 | 87 | def cvss_v2(advisory) 88 | "CVSS V2: [#{advisory.cvss_v2} #{advisory.criticality}](#{cvss_v2_url(advisory.cve_id)})" if advisory.cvss_v2 89 | end 90 | 91 | def cvss_v2_url(id) 92 | "https://nvd.nist.gov/vuln-metrics/cvss/v2-calculator?name=#{id}" 93 | end 94 | 95 | def link(advisory) 96 | "URL: #{advisory.url}" if advisory.url 97 | end 98 | 99 | def vulnerabilities 100 | @gem_change_info[:security_vulnerabilities] 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/writers/github_commit_log_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Writers 5 | RSpec.describe GithubCommitLog do 6 | describe '.write' do 7 | subject(:write) { described_class.write(gem_change, gem_change_info) } 8 | 9 | let(:gem_change) { double } 10 | 11 | context 'given no github comparison' do 12 | let(:gem_change_info) { {} } 13 | 14 | it { is_expected.to be_nil } 15 | end 16 | 17 | context 'given a github comparison' do 18 | let(:gem_change_info) do 19 | { 20 | github_comparison: double( 21 | commits: [ 22 | double( 23 | commit: double(message: 'egg ' * 16), 24 | html_url: 'test-commit-html-url', 25 | sha: '1234567890' 26 | ) 27 | ], 28 | total_commits: 1, 29 | html_url: 'test-html-url' 30 | ) 31 | } 32 | end 33 | 34 | it { is_expected.to eq <<~MESSAGE } 35 |
36 | Commits 37 | 38 | A change of **1** commits. See the full changes on [the compare page](test-html-url). 39 | 40 | These are the individual commits: 41 | - (1234567) [egg egg egg egg egg egg egg egg egg egg egg egg egg egg egg …](test-commit-html-url) 42 | 43 | 44 |
45 | MESSAGE 46 | end 47 | 48 | context 'given a github comparison with 11 commits' do 49 | let(:gem_change_info) do 50 | { 51 | github_comparison: double( 52 | commits: Array.new( 53 | 11, 54 | double( 55 | commit: double(message: 'test-commit-message'), 56 | html_url: 'test-commit-html-url', 57 | sha: 'test-commit-sha' 58 | ) 59 | ), 60 | total_commits: 11, 61 | html_url: 'test-html-url' 62 | ) 63 | } 64 | end 65 | 66 | it 'writes only 10 commits' do 67 | expect(write).to end_with <<~MESSAGE 68 | These are the first 10 commits: 69 | - (test-co) [test-commit-message](test-commit-html-url) 70 | - (test-co) [test-commit-message](test-commit-html-url) 71 | - (test-co) [test-commit-message](test-commit-html-url) 72 | - (test-co) [test-commit-message](test-commit-html-url) 73 | - (test-co) [test-commit-message](test-commit-html-url) 74 | - (test-co) [test-commit-message](test-commit-html-url) 75 | - (test-co) [test-commit-message](test-commit-html-url) 76 | - (test-co) [test-commit-message](test-commit-html-url) 77 | - (test-co) [test-commit-message](test-commit-html-url) 78 | - (test-co) [test-commit-message](test-commit-html-url) 79 | 80 | 81 | 82 | MESSAGE 83 | end 84 | end 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /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, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | 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 to the community leaders responsible for enforcement via [GitHub Issues]. 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://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | [GitHub Issues]: https://github.com/envato/unwrappr/issues 76 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/github/client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Unwrappr::GitHub::Client do 6 | let(:lock_files) { ['Gemfile.lock'] } 7 | 8 | before { described_class.reset_client } 9 | 10 | describe '#make_pull_request!' do 11 | subject(:make_pull_request!) { described_class.make_pull_request!(lock_files) } 12 | 13 | let(:git_url) { 'https://github.com/org/repo.git' } 14 | let(:octokit_client) { instance_spy(Octokit::Client, :fake_octokit, pull_request_files: []) } 15 | 16 | before do 17 | allow(Octokit::Client).to receive(:new).and_return octokit_client 18 | allow(Unwrappr::GitCommandRunner).to receive(:current_branch_name).and_return('some-new-branch') 19 | allow(Unwrappr::GitCommandRunner).to receive(:remote).and_return(git_url) 20 | end 21 | 22 | context 'with a token' do 23 | before do 24 | allow(ENV).to receive(:fetch).with('GITHUB_TOKEN').and_return('fake tokenz r us') 25 | allow(octokit_client).to receive_message_chain('repository.default_branch').and_return('main') 26 | end 27 | 28 | context 'Given a successful Octokit pull request is created' do 29 | before do 30 | expect(octokit_client).to receive(:create_pull_request).with( 31 | 'org/repo', 32 | any_args 33 | ).and_return(response) 34 | end 35 | 36 | let(:agent) { Sawyer::Agent.new('http://foo.com/a/') } 37 | let(:response) { Sawyer::Resource.new(agent, number: 34) } 38 | 39 | context 'When Git URL ends with .git' do 40 | let(:git_url) { 'git@github.com:org/repo.git' } 41 | 42 | it 'annotates the pull request' do 43 | allow(Unwrappr::LockFileAnnotator).to receive(:annotate_github_pull_request) 44 | 45 | make_pull_request! 46 | 47 | expect(Unwrappr::LockFileAnnotator) 48 | .to have_received(:annotate_github_pull_request) 49 | .with(repo: 'org/repo', pr_number: 34, lock_files: ['Gemfile.lock'], client: octokit_client) 50 | end 51 | end 52 | 53 | context 'When Git URL does not end with .git' do 54 | let(:git_url) { 'https://github.com/org/repo' } 55 | 56 | it 'annotates the pull request' do 57 | allow(Unwrappr::LockFileAnnotator).to receive(:annotate_github_pull_request) 58 | 59 | make_pull_request! 60 | 61 | expect(Unwrappr::LockFileAnnotator) 62 | .to have_received(:annotate_github_pull_request) 63 | .with(repo: 'org/repo', pr_number: 34, lock_files: ['Gemfile.lock'], client: octokit_client) 64 | end 65 | end 66 | 67 | context 'When multiple lock files are specified' do 68 | let(:lock_files) { ['Gemfile.lock', 'Gemfile_next.lock'] } 69 | let(:git_url) { 'https://github.com/org/repo' } 70 | 71 | it 'annotates the pull request' do 72 | allow(Unwrappr::LockFileAnnotator).to receive(:annotate_github_pull_request) 73 | 74 | make_pull_request! 75 | 76 | expect(Unwrappr::LockFileAnnotator) 77 | .to have_received(:annotate_github_pull_request) 78 | .with(repo: 'org/repo', pr_number: 34, lock_files: ['Gemfile.lock', 'Gemfile_next.lock'], 79 | client: octokit_client) 80 | end 81 | end 82 | end 83 | 84 | context 'Given an exception is raised from Octokit' do 85 | before do 86 | expect(octokit_client).to receive(:repository) 87 | .and_raise(Octokit::ClientError) 88 | end 89 | 90 | specify do 91 | expect { make_pull_request! } 92 | .to raise_error(RuntimeError, /^Failed to create and annotate pull request: /) 93 | end 94 | end 95 | end 96 | 97 | context 'without a token' do 98 | it 'provides useful feedback' do 99 | expect { make_pull_request! }.to raise_error(RuntimeError, /^Missing environment variable/) 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/unwrappr/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'clamp' 4 | 5 | # Welcome to unwrappr... 6 | module Unwrappr 7 | # Entry point for the app 8 | class CLI < Clamp::Command 9 | self.default_subcommand = 'all' 10 | 11 | option(['-b', '--base'], 12 | 'BRANCH', 13 | <<~DESCRIPTION, 14 | the branch upon which to base the pull-request. Omit this option 15 | to use the current branch, or repository's default branch 16 | (typically 'origin/main') on clone. 17 | DESCRIPTION 18 | attribute_name: :base_branch) 19 | 20 | option ['-f', '--lock-file'], 21 | 'LOCK_FILE1 [-f LOCK_FILE2] [-f LOCK_FILE3] [-f ...]', 22 | 'The Gemfile.lock files to annotate. Useful when working with multiple lock files.', 23 | multivalued: true, 24 | default: ['Gemfile.lock'], 25 | attribute_name: :lock_files 26 | 27 | option ['-v', '--version'], :flag, 'Show version' do 28 | puts "unwrappr v#{Unwrappr::VERSION}" 29 | exit(0) 30 | end 31 | 32 | subcommand 'all', 'run bundle update, push to GitHub, create a pr and annotate changes' do 33 | option ['-R', '--recursive'], 34 | :flag, 35 | 'Recurse into subdirectories', 36 | attribute_name: :recursive 37 | 38 | def execute 39 | Unwrappr.run_unwrappr_in_pwd(base_branch: base_branch, lock_files: lock_files, recursive: recursive?) 40 | end 41 | end 42 | 43 | subcommand 'annotate-pull-request', 44 | 'Annotate Gemfile.lock changes in a Github pull request' do 45 | option ['-r', '--repo'], 'REPO', 46 | 'The repo in github ', 47 | required: true 48 | 49 | option ['-p', '--pr'], 'PR', 50 | 'The GitHub PR number', 51 | required: true 52 | 53 | def execute 54 | LockFileAnnotator.annotate_github_pull_request( 55 | repo: repo, 56 | pr_number: pr.to_i, 57 | lock_files: lock_files 58 | ) 59 | end 60 | end 61 | 62 | subcommand('clone', <<~DESCRIPTION) do 63 | Clone one git repository or more and create an annotated bundle update PR for each. 64 | DESCRIPTION 65 | 66 | option(['-r', '--repo'], 67 | 'REPO', 68 | <<~DESCRIPTION, 69 | a repo in GitHub , may be specified multiple times 70 | DESCRIPTION 71 | required: true, 72 | multivalued: true) 73 | 74 | option ['-R', '--recursive'], 75 | :flag, 76 | 'Recurse into subdirectories', 77 | attribute_name: :recursive 78 | 79 | def execute 80 | repo_list.each do |repo| 81 | GitCommandRunner.clone_repository("https://github.com/#{repo}", repo) unless Dir.exist?(repo) 82 | 83 | Dir.chdir(repo) do 84 | Unwrappr.run_unwrappr_in_pwd(base_branch: base_branch, lock_files: lock_files, recursive: recursive?) 85 | end 86 | end 87 | end 88 | end 89 | end 90 | 91 | def self.run_unwrappr_in_pwd(base_branch:, lock_files:, recursive:) 92 | return unless any_lockfile_present?(lock_files) 93 | 94 | GitCommandRunner.create_branch!(base_branch: base_branch) 95 | bundle_update!(lock_files: lock_files, recursive: recursive) 96 | GitCommandRunner.commit_and_push_changes! 97 | GitHub::Client.make_pull_request!(lock_files) 98 | end 99 | 100 | def self.any_lockfile_present?(lock_files) 101 | lock_files.any? { |lock_file| GitCommandRunner.file_exist?(lock_file) } 102 | end 103 | 104 | def self.bundle_update!(lock_files:, recursive:) 105 | directories(lock_files: lock_files, recursive: recursive).each do |dir| 106 | Dir.chdir(dir) do 107 | puts "Doing the unwrappr thing in #{Dir.pwd}" 108 | BundlerCommandRunner.bundle_update! 109 | end 110 | end 111 | end 112 | 113 | def self.directories(lock_files:, recursive:) 114 | if recursive 115 | lock_files 116 | .flat_map { |f| Dir.glob("**/#{f}") } 117 | .map { |f| File.dirname(f) } 118 | .uniq 119 | else 120 | %w[.] 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/researchers/github_comparison_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Researchers 5 | RSpec.describe GithubComparison do 6 | subject(:github_comparison) { GithubComparison.new(client) } 7 | 8 | let(:client) { instance_double(Octokit::Client) } 9 | before do 10 | allow(client).to receive(:compare).and_raise(Octokit::NotFound) 11 | end 12 | 13 | describe '#research' do 14 | subject(:research) { github_comparison.research(gem_change, gem_change_info) } 15 | 16 | let(:gem_change) do 17 | instance_double( 18 | GemChange, 19 | base_version: base_version, 20 | head_version: head_version 21 | ) 22 | end 23 | let(:gem_change_info) { { github_repo: github_repo } } 24 | let(:base_version) { GemVersion.new('1.0.0') } 25 | let(:head_version) { GemVersion.new('1.1.0') } 26 | let(:response) { double } 27 | 28 | context 'given no Github repo' do 29 | let(:github_repo) { nil } 30 | 31 | it "doesn't add data from Github" do 32 | expect(research[:github_comparision]).to be_nil 33 | end 34 | 35 | it 'returns the data provided in gem_change_info' do 36 | expect(research).to include(gem_change_info) 37 | end 38 | end 39 | 40 | context 'given a Github repo' do 41 | let(:github_repo) { 'envato/unwrappr' } 42 | 43 | context 'given the gem is added' do 44 | let(:base_version) { nil } 45 | let(:head_version) { GemVersion.new('1.1.0') } 46 | 47 | it "doesn't contact GitHub for a result we already know" do 48 | research 49 | expect(client).to_not have_received(:compare) 50 | end 51 | 52 | it "doesn't add data from Github" do 53 | expect(research[:github_comparision]).to be_nil 54 | end 55 | 56 | it 'returns the data provided in gem_change_info' do 57 | expect(research).to include(gem_change_info) 58 | end 59 | end 60 | 61 | context 'given the gem is removed' do 62 | let(:base_version) { GemVersion.new('1.0.0') } 63 | let(:head_version) { nil } 64 | 65 | it "doesn't contact GitHub for a result we already know" do 66 | research 67 | expect(client).to_not have_received(:compare) 68 | end 69 | 70 | it "doesn't add data from Github" do 71 | expect(research[:github_comparision]).to be_nil 72 | end 73 | 74 | it 'returns the data provided in gem_change_info' do 75 | expect(research).to include(gem_change_info) 76 | end 77 | end 78 | 79 | context 'given the gem version changed' do 80 | let(:base_version) { GemVersion.new('1.0.0') } 81 | let(:head_version) { GemVersion.new('1.1.0') } 82 | 83 | context 'given the repo has "vx.x.x" tags' do 84 | before do 85 | allow(client).to receive(:compare) 86 | .with('envato/unwrappr', 'v1.0.0', 'v1.1.0') 87 | .and_return(response) 88 | end 89 | 90 | it 'returns the data from Github' do 91 | expect(research[:github_comparison]).to eq(response) 92 | end 93 | 94 | it 'returns the data provided in gem_change_info' do 95 | expect(research).to include(gem_change_info) 96 | end 97 | end 98 | 99 | context 'given the repo has "x.x.x" tags' do 100 | before do 101 | allow(client).to receive(:compare) 102 | .with('envato/unwrappr', '1.0.0', '1.1.0') 103 | .and_return(response) 104 | end 105 | 106 | it 'returns the data from Github' do 107 | expect(research[:github_comparison]).to eq(response) 108 | end 109 | 110 | it 'returns the data provided in gem_change_info' do 111 | expect(research).to include(gem_change_info) 112 | end 113 | end 114 | end 115 | end 116 | end 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/github/pr_source_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | RSpec.describe Github::PrSource do 5 | subject(:pr_source) { Github::PrSource.new(repo, pr_number, lock_files, client) } 6 | 7 | let(:repo) { 'envato/unwrappr' } 8 | let(:pr_number) { 223 } 9 | let(:lock_files) { ['Gemfile.lock'] } 10 | let(:client) { instance_double(Octokit::Client) } 11 | let(:pr_files) do 12 | [ 13 | double(filename: 'my/Gemfile.lock', patch: 'my-gem-patch'), 14 | double(filename: 'not.a.gem.file.lock', patch: 'another-patch') 15 | ] 16 | end 17 | let(:pr) { double } 18 | let(:content1) { double(content: 'encoded-content-1') } 19 | let(:content2) { double(content: 'encoded-content-2') } 20 | 21 | before do 22 | allow(LockFileDiff).to receive(:new) 23 | .with(hash_including(filename: 'my/Gemfile.lock')) 24 | .and_return(double(filename: 'my/Gemfile.lock')) 25 | allow(Base64).to receive(:decode64) 26 | .with('encoded-content-1') 27 | .and_return('content-1') 28 | allow(Base64).to receive(:decode64) 29 | .with('encoded-content-2') 30 | .and_return('content-2') 31 | 32 | allow(client).to receive(:pull_request_files) 33 | .with(repo, pr_number) 34 | .and_return(pr_files) 35 | allow(client).to receive(:pull_request) 36 | .with(repo, pr_number) 37 | .and_return(pr) 38 | allow(client).to receive(:contents) 39 | .with(repo, path: 'my/Gemfile.lock', ref: 'base-sha') 40 | .and_return(content1) 41 | allow(client).to receive(:contents) 42 | .with(repo, path: 'my/Gemfile.lock', ref: 'head-sha') 43 | .and_return(content2) 44 | 45 | allow(pr).to receive_message_chain(:base, :sha) 46 | .and_return('base-sha') 47 | allow(pr).to receive_message_chain(:head, :sha) 48 | .and_return('head-sha') 49 | end 50 | 51 | describe '#each_file' do 52 | subject(:files) do 53 | files = [] 54 | pr_source.each_file { |lock_diff| files << lock_diff } 55 | files 56 | end 57 | 58 | it 'identifies the Gemfile.lock' do 59 | expect(files.map(&:filename)).to eq(['my/Gemfile.lock']) 60 | end 61 | 62 | it 'produces a LockFileDiff with the expected attributes' do 63 | files 64 | expect(LockFileDiff).to have_received(:new) 65 | .with(filename: 'my/Gemfile.lock', 66 | base_file: 'content-1', 67 | head_file: 'content-2', 68 | patch: 'my-gem-patch', 69 | sha: 'head-sha') 70 | end 71 | 72 | context 'when multiple gem lock files are specified' do 73 | let(:lock_files) { ['Gemfile.lock', 'Gemfile_next.lock'] } 74 | let(:pr_files) do 75 | [ 76 | double(filename: 'my/Gemfile.lock', patch: 'my-gem-patch'), 77 | double(filename: 'Gemfile_next.lock', patch: 'next-gem-patch'), 78 | double(filename: 'not.a.gem.file.lock', patch: 'another-patch') 79 | ] 80 | end 81 | 82 | before do 83 | allow(LockFileDiff).to receive(:new) 84 | .with(hash_including(filename: 'Gemfile_next.lock')) 85 | .and_return(double(filename: 'Gemfile_next.lock')) 86 | allow(client).to receive(:contents) 87 | .with(repo, path: 'Gemfile_next.lock', ref: 'base-sha') 88 | .and_return(content1) 89 | allow(client).to receive(:contents) 90 | .with(repo, path: 'Gemfile_next.lock', ref: 'head-sha') 91 | .and_return(content2) 92 | end 93 | 94 | it 'identifies all the gem lock files' do 95 | expect(files.map(&:filename)).to eq(['my/Gemfile.lock', 'Gemfile_next.lock']) 96 | end 97 | 98 | it 'produces LockFileDiff instances with the expected attributes' do 99 | files 100 | expect(LockFileDiff).to have_received(:new) 101 | .with(filename: 'my/Gemfile.lock', 102 | base_file: 'content-1', 103 | head_file: 'content-2', 104 | patch: 'my-gem-patch', 105 | sha: 'head-sha') 106 | expect(LockFileDiff).to have_received(:new) 107 | .with(filename: 'Gemfile_next.lock', 108 | base_file: 'content-1', 109 | head_file: 'content-2', 110 | patch: 'next-gem-patch', 111 | sha: 'head-sha') 112 | end 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/lock_file_annotator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | RSpec.describe Unwrappr::LockFileAnnotator do 5 | subject(:annotator) do 6 | described_class.new( 7 | lock_file_diff_source: lock_file_diff_source, 8 | annotation_sink: annotation_sink, 9 | annotation_writer: Writers::Composite.new( 10 | Writers::Title, 11 | Writers::VersionChange, 12 | Writers::ProjectLinks 13 | ), 14 | gem_researcher: Researchers::Composite.new( 15 | Researchers::RubyGemsInfo.new 16 | ) 17 | ) 18 | end 19 | 20 | describe '#annotate' do 21 | subject(:annotate) { annotator.annotate } 22 | 23 | context 'given a Gemfile.lock that changes: rspec-support 3.7.0 -> 3.7.1' do 24 | let(:lock_file_diff_source) { instance_double(Github::PrSource) } 25 | let(:annotation_sink) { instance_spy(Github::PrSink) } 26 | let(:base_lock_file) { <<~BASE_FILE } 27 | GEM 28 | remote: https://rubygems.org/ 29 | specs: 30 | diff-lcs (1.3) 31 | rspec (3.7.0) 32 | rspec-core (~> 3.7.0) 33 | rspec-expectations (~> 3.7.0) 34 | rspec-mocks (~> 3.7.0) 35 | rspec-core (3.7.1) 36 | rspec-support (~> 3.7.0) 37 | rspec-expectations (3.7.0) 38 | diff-lcs (>= 1.2.0, < 2.0) 39 | rspec-support (~> 3.7.0) 40 | rspec-mocks (3.7.0) 41 | diff-lcs (>= 1.2.0, < 2.0) 42 | rspec-support (~> 3.7.0) 43 | rspec-support (3.7.0) 44 | 45 | PLATFORMS 46 | ruby 47 | 48 | DEPENDENCIES 49 | rspec 50 | 51 | BUNDLED WITH 52 | 1.16.2 53 | BASE_FILE 54 | 55 | let(:head_lock_file) { <<~HEAD_FILE } 56 | GEM 57 | remote: https://rubygems.org/ 58 | specs: 59 | diff-lcs (1.3) 60 | rspec (3.7.0) 61 | rspec-core (~> 3.7.0) 62 | rspec-expectations (~> 3.7.0) 63 | rspec-mocks (~> 3.7.0) 64 | rspec-core (3.7.1) 65 | rspec-support (~> 3.7.0) 66 | rspec-expectations (3.7.0) 67 | diff-lcs (>= 1.2.0, < 2.0) 68 | rspec-support (~> 3.7.0) 69 | rspec-mocks (3.7.0) 70 | diff-lcs (>= 1.2.0, < 2.0) 71 | rspec-support (~> 3.7.0) 72 | rspec-support (3.7.1) 73 | 74 | PLATFORMS 75 | ruby 76 | 77 | DEPENDENCIES 78 | rspec 79 | 80 | BUNDLED WITH 81 | 1.16.2 82 | HEAD_FILE 83 | 84 | let(:patch) { <<~PATCH } 85 | @@ -14,7 +14,7 @@ GEM 86 | rspec-mocks (3.7.0) 87 | diff-lcs (>= 1.2.0, < 2.0) 88 | rspec-support (~> 3.7.0) 89 | - rspec-support (3.7.0) 90 | + rspec-support (3.7.1) 91 | 92 | PLATFORMS 93 | ruby 94 | PATCH 95 | 96 | before do 97 | allow(::Unwrappr::RubyGems).to receive(:gem_info) 98 | .with('rspec-support', GemVersion.new('3.7.1')) 99 | .and_return({ 'homepage_uri' => 'home-uri', 100 | 'source_code_uri' => 'source-uri', 101 | 'changelog_uri' => 'changelog-uri' }) 102 | allow(lock_file_diff_source).to receive(:each_file) 103 | .and_yield(LockFileDiff.new(filename: 'Gemfile.lock', 104 | base_file: base_lock_file, 105 | head_file: head_lock_file, 106 | patch: patch, 107 | sha: '89ee3f7d')) 108 | end 109 | 110 | it 'annotates gem changes' do 111 | annotate 112 | expect(annotation_sink).to have_received(:annotate_change) 113 | .with( 114 | having_attributes(name: 'rspec-support', 115 | base_version: GemVersion.new('3.7.0'), 116 | head_version: GemVersion.new('3.7.1'), 117 | filename: 'Gemfile.lock', 118 | sha: '89ee3f7d', 119 | line_number: 5), 120 | <<~MESSAGE 121 | ### [rspec-support](home-uri) 122 | 123 | **Patch** version upgrade :chart_with_upwards_trend::small_blue_diamond: 3.7.0 → 3.7.1 124 | 125 | [_[change-log](changelog-uri), [source-code](source-uri), [gem-diff](https://my.diffend.io/gems/rspec-support/3.7.0/3.7.1)_] 126 | MESSAGE 127 | ) 128 | end 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/gem_version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | RSpec.describe GemVersion do 5 | describe 'major, minor, patch, hotfix' do 6 | context 'given 4.2.7' do 7 | subject(:version) { GemVersion.new('4.2.7') } 8 | its(:major) { should be(4) } 9 | its(:minor) { should be(2) } 10 | its(:patch) { should be(7) } 11 | its(:hotfix) { should be(0) } 12 | end 13 | 14 | context 'given 5.1' do 15 | subject(:version) { GemVersion.new('5.1') } 16 | its(:major) { should be(5) } 17 | its(:minor) { should be(1) } 18 | its(:patch) { should be(0) } 19 | its(:hotfix) { should be(0) } 20 | end 21 | 22 | context 'given 7' do 23 | subject(:version) { GemVersion.new('7') } 24 | its(:major) { should be(7) } 25 | its(:minor) { should be(0) } 26 | its(:patch) { should be(0) } 27 | its(:hotfix) { should be(0) } 28 | end 29 | 30 | context 'given 12.5.19-beta' do 31 | subject(:version) { GemVersion.new('12.5.19-beta') } 32 | its(:major) { should be(12) } 33 | its(:minor) { should be(5) } 34 | its(:patch) { should be(19) } 35 | its(:hotfix) { should be_nil } 36 | end 37 | 38 | context 'given 1.235-alpha' do 39 | subject(:version) { GemVersion.new('1.235-alpha') } 40 | its(:major) { should be(1) } 41 | its(:minor) { should be(235) } 42 | its(:patch) { should be_nil } 43 | its(:hotfix) { should be_nil } 44 | end 45 | end 46 | 47 | describe '#<' do 48 | context '4.2.7' do 49 | subject(:version) { GemVersion.new('4.2.7') } 50 | it { should be < GemVersion.new('4.2.8') } 51 | it { should be < GemVersion.new('4.3.6') } 52 | it { should be < GemVersion.new('5.1.6') } 53 | it { should be < GemVersion.new('4.2.7.1') } 54 | end 55 | end 56 | 57 | describe '#>' do 58 | context '4.2.7' do 59 | subject(:version) { GemVersion.new('4.2.7') } 60 | it { should be > GemVersion.new('4.2.6') } 61 | it { should be > GemVersion.new('4.2.6.99') } 62 | it { should be > GemVersion.new('4.1.8') } 63 | it { should be > GemVersion.new('3.3.8') } 64 | end 65 | end 66 | 67 | describe '#major_difference' do 68 | context '4.2.7' do 69 | subject(:version) { GemVersion.new('4.2.7') } 70 | it { should be_major_difference(GemVersion.new('3.2.7')) } 71 | it { should be_major_difference(GemVersion.new('5.2.7')) } 72 | it { should be_major_difference(GemVersion.new('5.3.8')) } 73 | it { should_not be_major_difference(GemVersion.new('4.2.7')) } 74 | it { should_not be_major_difference(GemVersion.new('4.2.7.99')) } 75 | it { should_not be_major_difference(GemVersion.new('4.2.8')) } 76 | it { should_not be_major_difference(GemVersion.new('4.3.7')) } 77 | end 78 | end 79 | 80 | describe '#minor_difference' do 81 | context '4.2.7' do 82 | subject(:version) { GemVersion.new('4.2.7') } 83 | it { should be_minor_difference(GemVersion.new('4.1.7')) } 84 | it { should be_minor_difference(GemVersion.new('4.3.7')) } 85 | it { should be_minor_difference(GemVersion.new('4.3.8')) } 86 | it { should_not be_minor_difference(GemVersion.new('4.2.7')) } 87 | it { should_not be_minor_difference(GemVersion.new('4.2.7.99')) } 88 | it { should_not be_minor_difference(GemVersion.new('4.2.8')) } 89 | it { should_not be_minor_difference(GemVersion.new('5.3.7')) } 90 | end 91 | end 92 | 93 | describe '#patch_difference' do 94 | context '4.2.7' do 95 | subject(:version) { GemVersion.new('4.2.7') } 96 | it { should be_patch_difference(GemVersion.new('4.2.6')) } 97 | it { should be_patch_difference(GemVersion.new('4.2.8')) } 98 | it { should_not be_patch_difference(GemVersion.new('4.2.7')) } 99 | it { should_not be_patch_difference(GemVersion.new('4.2.7.99')) } 100 | it { should_not be_patch_difference(GemVersion.new('4.3.8')) } 101 | it { should_not be_patch_difference(GemVersion.new('5.2.8')) } 102 | end 103 | end 104 | 105 | describe '#hotfix_difference' do 106 | context '4.2.7.0' do 107 | subject(:version) { GemVersion.new('4.2.7.0') } 108 | it { should be_hotfix_difference(GemVersion.new('4.2.7.99')) } 109 | it { should_not be_hotfix_difference(GemVersion.new('4.2.6')) } 110 | it { should_not be_hotfix_difference(GemVersion.new('4.2.6.99')) } 111 | it { should_not be_hotfix_difference(GemVersion.new('4.2.7')) } 112 | it { should_not be_hotfix_difference(GemVersion.new('4.2.8')) } 113 | it { should_not be_hotfix_difference(GemVersion.new('4.2.8.99')) } 114 | it { should_not be_hotfix_difference(GemVersion.new('4.3.8')) } 115 | it { should_not be_hotfix_difference(GemVersion.new('4.3.8.99')) } 116 | it { should_not be_hotfix_difference(GemVersion.new('5.2.8')) } 117 | it { should_not be_hotfix_difference(GemVersion.new('5.2.8.99')) } 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/writers/security_vulnerabilities_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Writers 5 | RSpec.describe SecurityVulnerabilities do 6 | describe '.write' do 7 | subject(:write) { described_class.write(gem_change, gem_change_info) } 8 | 9 | let(:gem_change) { double } 10 | 11 | context 'given no security vulnerabilities' do 12 | let(:gem_change_info) { {} } 13 | 14 | it { is_expected.to be_nil } 15 | end 16 | 17 | context 'given patched security vulnerabilities' do 18 | let(:gem_change_info) do 19 | { 20 | security_vulnerabilities: double( 21 | patched: [ 22 | instance_double( 23 | Bundler::Audit::Advisory, 24 | cve_id: 'CVE-2018-18476', 25 | osvdb_id: nil, 26 | title: 'mysql-binuuid-rails allows SQL Injection by removing default string escaping', 27 | cvss_v2: 9.9, 28 | criticality: 'high', 29 | url: 'https://gist.github.com/viraptor/881276ea61e8d56bac6e28454c79f1e6', 30 | description: <<~DESC 31 | mysql-binuuid-rails 1.1.0 and earlier allows SQL Injection because it removes 32 | default string escaping for affected database columns. ActiveRecord does not 33 | explicitly escape the Binary data type (Type::Binary::Data) for mysql. 34 | mysql-binuuid-rails uses a data type that is derived from the base Binary 35 | type, except, it doesn’t convert the value to hex. Instead, it assumes the 36 | string value provided is a valid hex string and doesn’t do any checks on it. 37 | DESC 38 | ) 39 | ], 40 | introduced: [], 41 | remaining: [] 42 | ) 43 | } 44 | end 45 | 46 | it { is_expected.to include <<~MESSAGE } 47 | :tada: Patched vulnerabilities: 48 | 49 | - [CVE-2018-18476](https://nvd.nist.gov/vuln/detail/CVE-2018-18476) 50 | **mysql-binuuid-rails allows SQL Injection by removing default string escaping** 51 | 52 | CVSS V2: [9.9 high](https://nvd.nist.gov/vuln-metrics/cvss/v2-calculator?name=CVE-2018-18476) 53 | URL: https://gist.github.com/viraptor/881276ea61e8d56bac6e28454c79f1e6 54 | 55 | mysql-binuuid-rails 1.1.0 and earlier allows SQL Injection because it removes default string escaping for affected database columns. ActiveRecord does not explicitly escape the Binary data type (Type::Binary::Data) for mysql. mysql-binuuid-rails uses a data type that is derived from the base Binary type, except, it doesn’t convert the value to hex. Instead, it assumes the string value provided is a valid hex string and doesn’t do any checks on it. 56 | MESSAGE 57 | end 58 | 59 | context 'given introduced security vulnerabilities' do 60 | let(:gem_change_info) do 61 | { 62 | security_vulnerabilities: double( 63 | introduced: [ 64 | instance_double( 65 | Bundler::Audit::Advisory, 66 | cve_id: nil, 67 | osvdb_id: 'legacy_osvdb_id', 68 | title: 'mysql-binuuid-rails allows SQL Injection by removing default string escaping', 69 | cvss_v2: nil, 70 | criticality: nil, 71 | url: nil, 72 | description: nil 73 | ) 74 | ], 75 | patched: [], 76 | remaining: [] 77 | ) 78 | } 79 | end 80 | 81 | it { is_expected.to include <<~MESSAGE } 82 | :rotating_light::exclamation: Introduced vulnerabilities: 83 | 84 | - legacy_osvdb_id 85 | **mysql-binuuid-rails allows SQL Injection by removing default string escaping** 86 | MESSAGE 87 | end 88 | 89 | context 'given remaining security vulnerabilities' do 90 | let(:gem_change_info) do 91 | { 92 | security_vulnerabilities: double( 93 | remaining: [ 94 | instance_double( 95 | Bundler::Audit::Advisory, 96 | cve_id: nil, 97 | osvdb_id: 'legacy_osvdb_id', 98 | title: 'mysql-binuuid-rails allows SQL Injection by removing default string escaping', 99 | cvss_v2: nil, 100 | criticality: nil, 101 | url: nil, 102 | description: nil 103 | ) 104 | ], 105 | patched: [], 106 | introduced: [] 107 | ) 108 | } 109 | end 110 | 111 | it { is_expected.to include <<~MESSAGE } 112 | :rotating_light: Remaining vulnerabilities: 113 | 114 | - legacy_osvdb_id 115 | **mysql-binuuid-rails allows SQL Injection by removing default string escaping** 116 | MESSAGE 117 | end 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![logo](https://user-images.githubusercontent.com/20217279/37953358-6847ed8a-31ee-11e8-9d3f-492e2574d7dc.png) 2 | 3 | > `bundle update` PRs: Automated. Annotated. 4 | 5 | Keeping dependencies up-to-date requires regular work. Some teams automate this, 6 | others do it manually. This project seeks to reduce manual and cerebral labor 7 | to get regular dependency updates into production. 8 | 9 | ## Features 10 | 11 | - Saves your team time in keeping dependencies up-to-date and understanding what's changed 12 | - `unwrappr` runs `bundle update`, creates a GitHub Pull Request with the changes and annotates the differences in your project's `Gemfile.lock` 13 | - Annotations include: 14 | - Major, minor and patch-level changes 15 | - Upgrades versus downgrades 16 | - Vulnerability advisory information using [bundler-audit](https://github.com/rubysec/bundler-audit) 17 | - Links to the home page, source code and change log (where available) of each gem 18 | 19 | ## Development status [![CI Status](https://github.com/envato/unwrappr/workflows/CI/badge.svg)](https://github.com/envato/unwrappr/actions?query=workflow%3ACI) 20 | 21 | `unwrappr` is used in many projects around [Envato][envato] 22 | However, it is still undergoing development and features are likely to change 23 | over time. 24 | 25 | After checking out the repo, run `bin/setup` to install dependencies. Then, run 26 | `rake spec` to run the tests. You can also run `bin/console` for an interactive 27 | prompt that will allow you to experiment. 28 | 29 | To install this gem onto your local machine, run `bundle exec rake install`. To 30 | release a new version, update the version number in `version.rb`, and then run 31 | `bundle exec rake release`, which will create a git tag for the version, push 32 | git commits and tags, and push the `.gem` file to 33 | [rubygems.org](https://rubygems.org). 34 | 35 | 36 | ## Getting started [![Gem version](https://img.shields.io/gem/v/unwrappr.svg?style=flat-square)](https://github.com/envato/unwrappr) [![Gem downloads](https://img.shields.io/gem/dt/unwrappr.svg?style=flat-square)](https://rubygems.org/gems/unwrappr) 37 | 38 | ``` 39 | $ gem install unwrappr 40 | ``` 41 | 42 | ## Configuration 43 | 44 | `unwrappr` needs a [GitHub Personal Access 45 | Token](https://github.com/settings/tokens), stored in the environment as 46 | `GITHUB_TOKEN`. If you have your Personal Access Token stored in the macOS 47 | keychain, you can pull this into your shell environment using the `security` 48 | tool. _E.g:_ 49 | 50 | ```bash 51 | export GITHUB_TOKEN=$(security find-internet-password -gs github.com 2>&1 | awk -F' ' '$1 == "password:" { print $2 }' | tr -d '"') 52 | ``` 53 | 54 | To run `unwrappr` in the current working directory use... 55 | 56 | ```bash 57 | export GITHUB_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 58 | unwrappr 59 | ``` 60 | 61 | To run `unwrappr` against repositories as a part of a time-based job 62 | scheduler, use the `clone` subcommand and specify as many `--repo` options as 63 | you need: 64 | 65 | ```bash 66 | export GITHUB_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 67 | unwrappr clone --repo envato/abc [[--repo envato/xyz] ...] 68 | ``` 69 | 70 | See https://github.com/settings/tokens to set up personal access tokens. 71 | 72 | ## Requirements 73 | 74 | - Ruby (tested against v2.5 and above) 75 | - GitHub access (see Configuration section) 76 | 77 | ## Contact 78 | 79 | - [GitHub project](https://github.com/envato/unwrappr) 80 | - Bug reports and feature requests are welcome via [GitHub Issues](https://github.com/envato/unwrappr/issues) 81 | 82 | ## Authors 83 | 84 | - [Pete Johns](https://github.com/johnsyweb) 85 | - [Orien Madgwick](https://github.com/orien) 86 | - [Joe Sustaric](https://github.com/joesustaric) 87 | - [Vladimir Chervanev](https://github.com/vchervanev) 88 | - [Em Esc](https://github.com/emesc) 89 | - [Chun-wei Kuo](https://github.com/Domon) 90 | 91 | ## License [![license](https://img.shields.io/github/license/mashape/apistatus.svg?style=flat-square)](https://github.com/envato/unwrappr/blob/HEAD/LICENSE.txt) 92 | 93 | `unwrappr` uses MIT license. See 94 | [`LICENSE.txt`](https://github.com/envato/unwrappr/blob/HEAD/LICENSE.txt) for 95 | details. 96 | 97 | ## Code of Conduct 98 | 99 | We welcome contribution from everyone. Read more about it in 100 | [`CODE_OF_CONDUCT.md`](https://github.com/envato/unwrappr/blob/HEAD/CODE_OF_CONDUCT.md) 101 | 102 | ## Contributing [![PRs welcome](https://img.shields.io/badge/PRs-welcome-orange.svg?style=flat-square)](https://github.com/envato/unwrappr/issues) 103 | 104 | For bug fixes, documentation changes, and features: 105 | 106 | 1. [Fork it](./fork) 107 | 1. Create your feature branch (`git checkout -b my-new-feature`) 108 | 1. Commit your changes (`git commit -am 'Add some feature'`) 109 | 1. Push to the branch (`git push origin my-new-feature`) 110 | 1. Create a new Pull Request 111 | 112 | For larger new features: Do everything as above, but first also make contact with the project maintainers to be sure your change fits with the project direction and you won't be wasting effort going in the wrong direction. 113 | 114 | ## About [![code with heart by Envato](https://img.shields.io/badge/%3C%2F%3E%20with%20%E2%99%A5%20by-Envato-ff69b4.svg?style=flat-square)](https://github.com/envato/unwrappr) 115 | 116 | This project is maintained by the Envato engineering team and funded by [Envato][envato]. 117 | 118 | Encouraging the use and creation of open source software is one of the ways we 119 | serve our community. Perhaps [come work with us][careers] 120 | where you'll find an incredibly diverse, intelligent and capable group of people 121 | who help make our company succeed and make our workplace fun, friendly and 122 | happy. 123 | 124 | [envato]: https://envato.com?utm_source=github 125 | [careers]: https://envato.com/careers/?utm_source=github 126 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/researchers/security_vulnerabilities_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | module Researchers 5 | RSpec.describe SecurityVulnerabilities do 6 | subject(:security_vulnerabilities) { described_class.new } 7 | 8 | describe '#research' do 9 | subject(:research) { security_vulnerabilities.research(gem_change, gem_change_info) } 10 | 11 | let(:gem_change) do 12 | instance_double( 13 | GemChange, 14 | name: gem_name, 15 | base_version: base_version, 16 | head_version: head_version 17 | ) 18 | end 19 | let(:gem_name) { 'test_name' } 20 | let(:gem_change_info) { { test: 'test' } } 21 | let(:base_version) { GemVersion.new('1.0.0') } 22 | let(:head_version) { GemVersion.new('1.1.0') } 23 | let(:advisories) { [] } 24 | let(:database) { instance_double(Bundler::Audit::Database) } 25 | before do 26 | allow(Bundler::Audit::Database).to receive(:update!) 27 | allow(Bundler::Audit::Database).to receive(:new).and_return(database) 28 | allow(database).to receive(:advisories_for).with(gem_name).and_return(advisories) 29 | end 30 | 31 | it 'updates the advisory database' do 32 | research 33 | expect(Bundler::Audit::Database).to have_received(:update!) 34 | end 35 | 36 | context 'given no advisories for the gem' do 37 | let(:advisories) { [] } 38 | 39 | it 'reports no vulnerabilities' do 40 | expect(research).to include( 41 | security_vulnerabilities: SecurityVulnerabilities::Vulnerabilites.new([], [], []) 42 | ) 43 | end 44 | 45 | it 'returns the data provided in gem_change_info' do 46 | expect(research).to include(gem_change_info) 47 | end 48 | end 49 | 50 | context 'given base and head versions are not vulnerable' do 51 | let(:advisories) { [advisory] } 52 | let(:advisory) { instance_double(Bundler::Audit::Advisory, vulnerable?: false) } 53 | 54 | it 'reports no vulnerabilities' do 55 | expect(research).to include( 56 | security_vulnerabilities: SecurityVulnerabilities::Vulnerabilites.new([], [], []) 57 | ) 58 | end 59 | 60 | it 'returns the data provided in gem_change_info' do 61 | expect(research).to include(gem_change_info) 62 | end 63 | end 64 | 65 | context 'given base version is vulnerable but not head version' do 66 | let(:advisories) { [advisory] } 67 | let(:advisory) { instance_double(Bundler::Audit::Advisory) } 68 | before do 69 | allow(advisory).to receive(:vulnerable?).with(base_version&.version).and_return(true) 70 | allow(advisory).to receive(:vulnerable?).with(head_version&.version).and_return(false) 71 | allow(advisory).to receive(:vulnerable?).with(nil).and_raise(ArgumentError) 72 | end 73 | 74 | it 'reports patched vulnerabilities' do 75 | expect(research).to include( 76 | security_vulnerabilities: SecurityVulnerabilities::Vulnerabilites.new([advisory], [], []) 77 | ) 78 | end 79 | 80 | it 'returns the data provided in gem_change_info' do 81 | expect(research).to include(gem_change_info) 82 | end 83 | 84 | context 'given the head version is nil' do 85 | let(:head_version) { nil } 86 | 87 | it 'reports patched vulnerabilities' do 88 | expect(research).to include( 89 | security_vulnerabilities: SecurityVulnerabilities::Vulnerabilites.new([advisory], [], []) 90 | ) 91 | end 92 | end 93 | end 94 | 95 | context 'given base version is not vulnerable but the head version is' do 96 | let(:advisories) { [advisory] } 97 | let(:advisory) { instance_double(Bundler::Audit::Advisory) } 98 | before do 99 | allow(advisory).to receive(:vulnerable?).with(base_version&.version).and_return(false) 100 | allow(advisory).to receive(:vulnerable?).with(head_version&.version).and_return(true) 101 | allow(advisory).to receive(:vulnerable?).with(nil).and_raise(ArgumentError) 102 | end 103 | 104 | it 'reports introduced vulnerabilities' do 105 | expect(research).to include( 106 | security_vulnerabilities: SecurityVulnerabilities::Vulnerabilites.new([], [advisory], []) 107 | ) 108 | end 109 | 110 | it 'returns the data provided in gem_change_info' do 111 | expect(research).to include(gem_change_info) 112 | end 113 | 114 | context 'given the base version is nil' do 115 | let(:base_version) { nil } 116 | 117 | it 'reports introduced vulnerabilities' do 118 | expect(research).to include( 119 | security_vulnerabilities: SecurityVulnerabilities::Vulnerabilites.new([], [advisory], []) 120 | ) 121 | end 122 | end 123 | end 124 | 125 | context 'given both the base version and the head version are vulnerable' do 126 | let(:advisories) { [advisory] } 127 | let(:advisory) { instance_double(Bundler::Audit::Advisory, vulnerable?: true) } 128 | 129 | it 'reports remaining vulnerabilities' do 130 | expect(research).to include( 131 | security_vulnerabilities: SecurityVulnerabilities::Vulnerabilites.new([], [], [advisory]) 132 | ) 133 | end 134 | 135 | it 'returns the data provided in gem_change_info' do 136 | expect(research).to include(gem_change_info) 137 | end 138 | end 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | [Unreleased]: https://github.com/envato/unwrappr/compare/v0.8.2...HEAD 10 | 11 | ## [0.8.2] 2024-12-28 12 | 13 | ### Add 14 | - Add Ruby 3.4 to the CI test matrix ([#95]). 15 | - Add `base64` as a runtime dependency to support Ruby 3.4+ ([#95]). 16 | 17 | [0.8.2]: https://github.com/envato/unwrappr/compare/v0.8.1...v0.8.2 18 | [#95]: https://github.com/envato/unwrappr/pull/95 19 | 20 | ## [0.8.1] 2023-02-07 21 | 22 | ### Add 23 | - Add Ruby 3.1 and 3.2 to the CI test matrix ([#92], [#93]). 24 | 25 | ### Fix 26 | - Resolve a number of issues raised by Rubocop ([#92], [#93]). 27 | - Resolve GitHub Actions Node.js 12 deprecation ([#93]). 28 | - Remove development files from the gem package ([#94]). 29 | 30 | ### Documentation 31 | - Document how to grab credentials from the keychain ([#91]). 32 | 33 | [0.8.1]: https://github.com/envato/unwrappr/compare/v0.8.0...v0.8.1 34 | [#91]: https://github.com/envato/unwrappr/pull/91 35 | [#92]: https://github.com/envato/unwrappr/pull/92 36 | [#93]: https://github.com/envato/unwrappr/pull/93 37 | [#94]: https://github.com/envato/unwrappr/pull/94 38 | 39 | ## [0.8.0] 2021-07-22 40 | 41 | ### Add 42 | 43 | - Ability to perform a `bundle update` in subdirectories with the `-R` / 44 | `--recursive` flag. ([#90]) 45 | 46 | [0.8.0]: https://github.com/envato/unwrappr/compare/v0.7.0...v0.8.0 47 | [#90]: https://github.com/envato/unwrappr/pull/90 48 | 49 | ## [0.7.0] 2021-07-15 50 | 51 | ### Add 52 | - Include link to gem contents diff in gem change annotation ([#88]). 53 | 54 | ### Fix 55 | - Fix Rubocop issues ([#89]). 56 | 57 | [0.7.0]: https://github.com/envato/unwrappr/compare/v0.6.0...v0.7.0 58 | [#88]: https://github.com/envato/unwrappr/pull/88 59 | [#89]: https://github.com/envato/unwrappr/pull/89 60 | 61 | ## [0.6.0] 2021-05-12 62 | 63 | ### Add 64 | - Allow specification of Gemfile lock files to annotate. ([#86]) 65 | 66 | [0.6.0]: https://github.com/envato/unwrappr/compare/v0.5.0..v0.6.0 67 | [#86]: https://github.com/envato/unwrappr/pull/86 68 | 69 | ## [0.5.0] 2021-01-04 70 | 71 | ### Add 72 | - Support for Ruby 3. ([#79]) 73 | - Allow specification of base branch, upon which to base the pull-request 74 | ([#80], [#84]) 75 | 76 | ### Changed 77 | - Moved CI to GitHub Actions ([#78]) 78 | - Fixed homepage URL in gemspec ([#77]) 79 | - Default branch is now `main`([#81]) 80 | - Rename private predicate methods in GitCommandRunner to be more descriptive. 81 | ([#82]) 82 | - Upgrade Faraday dependency to version 1 ([#85]) 83 | 84 | [0.5.0]: https://github.com/envato/unwrappr/compare/v0.4.0..v0.5.0 85 | [#77]: https://github.com/envato/unwrappr/pull/77 86 | [#78]: https://github.com/envato/unwrappr/pull/78 87 | [#79]: https://github.com/envato/unwrappr/pull/79 88 | [#80]: https://github.com/envato/unwrappr/pull/80 89 | [#81]: https://github.com/envato/unwrappr/pull/81 90 | [#82]: https://github.com/envato/unwrappr/pull/82 91 | [#84]: https://github.com/envato/unwrappr/pull/84 92 | [#85]: https://github.com/envato/unwrappr/pull/85 93 | 94 | ## [0.4.0] 2020-04-14 95 | ### Changed 96 | - `bundler-audit` limited to `>= 0.6.0` ([#71]) 97 | 98 | ### Removed 99 | - Support for Ruby 2.3 and 2.4 ([#73]) 100 | 101 | ### Added 102 | - Rake vulnerability CVE-2020-8130 fixes ([#72]) 103 | - Support for Ruby 2.6 and 2.7 ([#73]) 104 | - Support for version numbers including a fourth segment (_e.g._ "6.0.2.2") ([#74]) 105 | - Support for GitHub URIs including anchors ([#75]) 106 | 107 | [0.4.0]: https://github.com/envato/unwrappr/compare/v0.3.5..v0.4.0 108 | [#71]: https://github.com/envato/unwrappr/pull/71 109 | [#72]: https://github.com/envato/unwrappr/pull/72 110 | [#73]: https://github.com/envato/unwrappr/pull/73 111 | [#74]: https://github.com/envato/unwrappr/pull/74 112 | [#75]: https://github.com/envato/unwrappr/pull/75 113 | 114 | ## [0.3.5] 2019-11-28 115 | ### Changed 116 | - ISO 8601 Date and time format for branch name ([#68]) 117 | ### Fixed 118 | - Changelog and source links in PR annotation are specific to the version 119 | used in the project, not just the latest available on Rubygems.org ([#69]). 120 | 121 | [0.3.5]: https://github.com/envato/unwrappr/compare/v0.3.4...v0.3.5 122 | [#68]: https://github.com/envato/unwrappr/pull/68 123 | [#69]: https://github.com/envato/unwrappr/pull/69 124 | 125 | ## [0.3.4] 2019-10-24 126 | ### Fixed 127 | - Fix failure to annotate gem change with '.' in its name ([#65]). 128 | 129 | [0.3.4]: https://github.com/envato/unwrappr/compare/v0.3.3...v0.3.4 130 | [#65]: https://github.com/envato/unwrappr/pull/65 131 | 132 | ## [0.3.3] 2019-06-07 133 | ### Fixed 134 | - Fix issue where gem install will now work on RubyGems v3 ([#61]). 135 | 136 | [0.3.3]: https://github.com/envato/unwrappr/compare/v0.3.2...v0.3.3 137 | [#61]: https://github.com/envato/unwrappr/pull/61 138 | 139 | ## [0.3.2] 2018-11-13 140 | ### Added 141 | - Specify Ruby and RubyGems requirements in gemspec ([#56]). 142 | - Clone one git repository or more and create an annotated bundle update PR for each ([#52]). 143 | 144 | [0.3.2]: https://github.com/envato/unwrappr/compare/v0.3.1...v0.3.2 145 | [#56]: https://github.com/envato/unwrappr/pull/56 146 | [#52]: https://github.com/envato/unwrappr/pull/52 147 | 148 | ## [0.3.1] 2018-11-12 149 | ### Changed 150 | - Travis CI enabled ([#55]). 151 | - Ensure we are protected against CVE-2017-8418 ([#54]). 152 | - RubyGems metadata includes a description ([#49]). 153 | 154 | [0.3.1]: https://github.com/envato/unwrappr/compare/v0.3.0...v0.3.1 155 | [#55]: https://github.com/envato/unwrappr/pull/55 156 | [#54]: https://github.com/envato/unwrappr/pull/54 157 | [#49]: https://github.com/envato/unwrappr/pull/49 158 | 159 | ## [0.3.0] 2018-11-12 160 | ### Initial Release 161 | 162 | [0.3.0]: https://github.com/envato/unwrappr/releases/tag/v0.3.0 163 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/git_command_runner_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Unwrappr::GitCommandRunner do 6 | let(:fake_git) { instance_double(Git::Base, :fake_git) } 7 | 8 | before do 9 | described_class.reset_client 10 | allow(Git).to receive(:open).and_return(fake_git) 11 | end 12 | 13 | describe '#create_branch!' do 14 | subject(:create_branch!) { described_class.create_branch!(base_branch: base_branch) } 15 | let(:base_branch) { 'some_branch' } 16 | 17 | before { allow(Time).to receive(:now).and_return(Time.parse('2017-11-01 11:23')) } 18 | 19 | context 'Given current directory is not a git repo' do 20 | before do 21 | expect(fake_git).to receive(:current_branch).and_raise(Git::GitExecuteError) 22 | end 23 | 24 | specify do 25 | expect { create_branch! }.to raise_error(RuntimeError, 'Not a git working dir') 26 | end 27 | end 28 | 29 | context 'Given the current directory is a git repo' do 30 | before do 31 | expect(fake_git).to receive(:current_branch).and_return('main') 32 | allow(fake_git).to receive(:checkout) 33 | end 34 | 35 | context 'with a named base branch' do 36 | let(:base_branch) { 'origin/main' } 37 | 38 | it 'checks out a new branch based on the named branch, with a timestamp' do 39 | expect(fake_git).to receive(:branch).with('auto_bundle_update_20171101-1123').and_return(fake_git) 40 | 41 | expect(create_branch!).to be_nil 42 | 43 | expect(fake_git).to have_received(:checkout).with('origin/main').once 44 | end 45 | end 46 | 47 | context 'without a named base branch' do 48 | let(:base_branch) { nil } 49 | 50 | it 'checks out a new branch based on the current branch, with a timestamp' do 51 | expect(fake_git).to receive(:branch).with('auto_bundle_update_20171101-1123').and_return(fake_git) 52 | 53 | expect(create_branch!).to be_nil 54 | 55 | expect(fake_git).not_to have_received(:checkout).with(nil) 56 | end 57 | end 58 | 59 | context 'When there is some failure in creating the branch' do 60 | before do 61 | expect(fake_git).to receive(:branch) 62 | .with('auto_bundle_update_20171101-1123') 63 | .and_raise(Git::GitExecuteError) 64 | end 65 | 66 | specify do 67 | expect { create_branch! }.to raise_error(RuntimeError, "failed to create branch from 'some_branch'") 68 | end 69 | end 70 | end 71 | end 72 | 73 | describe '#commit_and_push_changes!' do 74 | subject(:commit_and_push_changes!) { described_class.commit_and_push_changes! } 75 | 76 | context 'Given the git add command fails' do 77 | before do 78 | expect(fake_git).to receive(:add).with(all: true).and_raise(Git::GitExecuteError) 79 | end 80 | 81 | specify do 82 | expect { commit_and_push_changes! } 83 | .to raise_error RuntimeError, 'failed to add git changes' 84 | end 85 | end 86 | 87 | context 'Given the git add command is successful' do 88 | before do 89 | expect(fake_git).to receive(:add).with(all: true).and_return(true) 90 | end 91 | 92 | context 'When the git commit command fails' do 93 | before do 94 | expect(fake_git).to receive(:commit).with('Automatic Bundle Update').and_raise(Git::GitExecuteError) 95 | end 96 | 97 | specify do 98 | expect { commit_and_push_changes! }.to raise_error(RuntimeError, 'failed to commit changes') 99 | end 100 | end 101 | 102 | context 'Given the git commit command is successful' do 103 | before do 104 | expect(fake_git).to receive(:commit).with('Automatic Bundle Update').and_return(true) 105 | end 106 | 107 | context 'Given the git push command fails' do 108 | before do 109 | expect(fake_git).to receive(:current_branch).and_return('branchname') 110 | expect(fake_git).to receive(:push).with('origin', 'branchname').and_raise(Git::GitExecuteError) 111 | end 112 | 113 | specify do 114 | expect { commit_and_push_changes! } 115 | .to raise_error(RuntimeError, 'failed to push changes') 116 | end 117 | end 118 | 119 | context 'Given the git push command is successful' do 120 | before do 121 | expect(fake_git).to receive(:current_branch).and_return('branchname') 122 | expect(fake_git).to receive(:push).with('origin', 'branchname').and_return(true) 123 | end 124 | 125 | it { is_expected.to be_nil } 126 | end 127 | end 128 | end 129 | end 130 | 131 | describe '#show' do 132 | subject(:show) { described_class.show('HEAD', 'Gemfile.lock') } 133 | 134 | context 'when the proxied git command succeeds' do 135 | before do 136 | expect(fake_git).to receive(:show).with('HEAD', 'Gemfile.lock').and_return('content') 137 | end 138 | 139 | it { is_expected.to eq('content') } 140 | end 141 | 142 | context 'when the proxied git command fails' do 143 | before do 144 | expect(fake_git).to receive(:show).and_raise(Git::GitExecuteError) 145 | end 146 | 147 | it { is_expected.to be_nil } 148 | end 149 | end 150 | 151 | describe '#file_exist?' do 152 | subject(:file_exist) { described_class.file_exist?('Gemfile.lock') } 153 | 154 | context 'when does not exist' do 155 | before do 156 | expect(fake_git).to receive(:ls_files).with('Gemfile.lock').and_return({}) 157 | end 158 | 159 | it { is_expected.to be false } 160 | end 161 | 162 | context 'when it exists' do 163 | before do 164 | expect(fake_git).to receive(:ls_files) 165 | .with('Gemfile.lock') 166 | .and_return('Gemfile.lock' => { 167 | path: 'Gemfile.lock', 168 | mode_index: '100644', 169 | sha_index: 'cabbage', 170 | stage: '0' 171 | }) 172 | end 173 | 174 | it { is_expected.to be true } 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/gem_change_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | RSpec.describe GemChange do 5 | subject(:gem_change) do 6 | GemChange.new( 7 | name: name, 8 | head_version: head_version, 9 | base_version: base_version, 10 | line_number: line_number, 11 | lock_file_diff: lock_file_diff 12 | ) 13 | end 14 | 15 | let(:name) { 'test-gem' } 16 | let(:head_version) { GemVersion.new('4.2.1') } 17 | let(:base_version) { GemVersion.new('4.2.0') } 18 | let(:line_number) { 2398 } 19 | let(:lock_file_diff) { instance_double(LockFileDiff) } 20 | 21 | describe '#added?' do 22 | context 'given base_version and head_version' do 23 | let(:base_version) { GemVersion.new('4.2.0') } 24 | let(:head_version) { GemVersion.new('4.2.1') } 25 | it { should_not be_added } 26 | end 27 | 28 | context 'given base_version and no head_version' do 29 | let(:base_version) { GemVersion.new('4.2.0') } 30 | let(:head_version) { nil } 31 | it { should_not be_added } 32 | end 33 | 34 | context 'given no base_version and a head_version' do 35 | let(:base_version) { nil } 36 | let(:head_version) { GemVersion.new('4.2.1') } 37 | it { should be_added } 38 | end 39 | end 40 | 41 | describe '#removed?' do 42 | context 'given base_version and head_version' do 43 | let(:base_version) { GemVersion.new('4.2.0') } 44 | let(:head_version) { GemVersion.new('4.2.1') } 45 | it { should_not be_removed } 46 | end 47 | 48 | context 'given base_version and no head_version' do 49 | let(:base_version) { GemVersion.new('4.2.0') } 50 | let(:head_version) { nil } 51 | it { should be_removed } 52 | end 53 | 54 | context 'given no base_version and a head_version' do 55 | let(:base_version) { nil } 56 | let(:head_version) { GemVersion.new('4.2.1') } 57 | it { should_not be_removed } 58 | end 59 | end 60 | 61 | describe '#upgrade?' do 62 | context 'given base_version 4.2.0 and head_version 4.2.1' do 63 | let(:base_version) { GemVersion.new('4.2.0') } 64 | let(:head_version) { GemVersion.new('4.2.1') } 65 | it { should be_upgrade } 66 | end 67 | 68 | context 'given base_version 4.2.0 and head_version 4.2.0' do 69 | let(:base_version) { GemVersion.new('4.2.0') } 70 | let(:head_version) { GemVersion.new('4.2.0') } 71 | it { should_not be_upgrade } 72 | end 73 | 74 | context 'given base_version 4.2.1 and head_version 4.2.0' do 75 | let(:base_version) { GemVersion.new('4.2.1') } 76 | let(:head_version) { GemVersion.new('4.2.0') } 77 | it { should_not be_upgrade } 78 | end 79 | 80 | context 'given base_version and no head_version' do 81 | let(:base_version) { GemVersion.new('4.2.0') } 82 | let(:head_version) { nil } 83 | it { should_not be_upgrade } 84 | end 85 | 86 | context 'given no base_version and a head_version' do 87 | let(:base_version) { nil } 88 | let(:head_version) { GemVersion.new('4.2.1') } 89 | it { should_not be_upgrade } 90 | end 91 | end 92 | 93 | describe '#downgrade?' do 94 | context 'given base_version 4.2.0 and head_version 4.2.1' do 95 | let(:base_version) { GemVersion.new('4.2.0') } 96 | let(:head_version) { GemVersion.new('4.2.1') } 97 | it { should_not be_downgrade } 98 | end 99 | 100 | context 'given base_version 4.2.0 and head_version 4.2.0' do 101 | let(:base_version) { GemVersion.new('4.2.0') } 102 | let(:head_version) { GemVersion.new('4.2.0') } 103 | it { should_not be_downgrade } 104 | end 105 | 106 | context 'given base_version 4.2.1 and head_version 4.2.0' do 107 | let(:base_version) { GemVersion.new('4.2.1') } 108 | let(:head_version) { GemVersion.new('4.2.0') } 109 | it { should be_downgrade } 110 | end 111 | 112 | context 'given base_version and no head_version' do 113 | let(:base_version) { GemVersion.new('4.2.0') } 114 | let(:head_version) { nil } 115 | it { should_not be_downgrade } 116 | end 117 | 118 | context 'given no base_version and a head_version' do 119 | let(:base_version) { nil } 120 | let(:head_version) { GemVersion.new('4.2.1') } 121 | it { should_not be_downgrade } 122 | end 123 | end 124 | 125 | describe 'major, minor, patch' do 126 | context 'given base_version 4.2.0 and head_version 4.2.1' do 127 | let(:base_version) { GemVersion.new('4.2.0') } 128 | let(:head_version) { GemVersion.new('4.2.1') } 129 | it { should_not be_major } 130 | it { should_not be_minor } 131 | it { should be_patch } 132 | end 133 | 134 | context 'given base_version 4.2.5 and head_version 4.3.1' do 135 | let(:base_version) { GemVersion.new('4.2.5') } 136 | let(:head_version) { GemVersion.new('4.3.1') } 137 | it { should_not be_major } 138 | it { should be_minor } 139 | it { should_not be_patch } 140 | end 141 | 142 | context 'given base_version 5.2.5 and head_version 4.3.1' do 143 | let(:base_version) { GemVersion.new('5.2.5') } 144 | let(:head_version) { GemVersion.new('4.3.1') } 145 | it { should be_major } 146 | it { should_not be_minor } 147 | it { should_not be_patch } 148 | end 149 | 150 | context 'given base_version 4.2.0 and head_version 4.2.0' do 151 | let(:base_version) { GemVersion.new('4.2.0') } 152 | let(:head_version) { GemVersion.new('4.2.0') } 153 | it { should_not be_major } 154 | it { should_not be_minor } 155 | it { should_not be_patch } 156 | end 157 | 158 | context 'given base_version 4.2.1 and head_version 4.2.0' do 159 | let(:base_version) { GemVersion.new('4.2.1') } 160 | let(:head_version) { GemVersion.new('4.2.0') } 161 | it { should_not be_major } 162 | it { should_not be_minor } 163 | it { should be_patch } 164 | end 165 | 166 | context 'given base_version and no head_version' do 167 | let(:base_version) { GemVersion.new('4.2.0') } 168 | let(:head_version) { nil } 169 | it { should_not be_major } 170 | it { should_not be_minor } 171 | it { should_not be_patch } 172 | end 173 | 174 | context 'given no base_version and a head_version' do 175 | let(:base_version) { nil } 176 | let(:head_version) { GemVersion.new('4.2.1') } 177 | it { should_not be_major } 178 | it { should_not be_minor } 179 | it { should_not be_patch } 180 | end 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /spec/lib/unwrappr/lock_file_diff_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Unwrappr 4 | RSpec.describe LockFileDiff do 5 | subject(:lock_file_diff) do 6 | LockFileDiff.new( 7 | filename: 'Gemfile.lock', 8 | base_file: base_file, 9 | head_file: head_file, 10 | patch: patch, 11 | sha: '123' 12 | ) 13 | end 14 | 15 | let(:patch) { <<~PATCH } 16 | @@ -1,25 +1,24 @@ 17 | GEM 18 | remote: https://rubygems.org/ 19 | specs: 20 | - diff_lcs (1.3) 21 | - rspec (3.7.0) 22 | + pry (0.11.3) 23 | + rspec (4.0.0) 24 | rspec-core (~> 3.7.0) 25 | - rspec-expectations (~> 3.7.0) 26 | - rspec-mocks (~> 3.7.0) 27 | + rspec-expectations (~> 3.8.0) 28 | + rspec-mocks (~> 3.6.0) 29 | rspec-core (3.7.1) 30 | rspec-support (~> 3.7.0) 31 | - rspec-expectations (3.7.0) 32 | - diff_lcs (>= 1.2.0, < 2.0) 33 | + rspec-expectations (3.8.0) 34 | rspec-support (~> 3.7.0) 35 | - rspec-mocks (3.7.0) 36 | - diff_lcs (>= 1.2.0, < 2.0) 37 | + rspec-mocks (3.6.0) 38 | rspec-support (~> 3.7.0) 39 | - rspec-support (3.7.0) 40 | + rspec-support (3.7.1) 41 | highline (2.0.0) 42 | - http_parser.rb (0.6.0) 43 | - i18n (1.0.1) 44 | + i18n (1.1.0) 45 | 46 | PLATFORMS 47 | ruby 48 | 49 | DEPENDENCIES 50 | + pry 51 | rspec 52 | 53 | BUNDLED WITH 54 | PATCH 55 | 56 | let(:base_file) { <<~BASE_FILE } 57 | GEM 58 | remote: https://rubygems.org/ 59 | specs: 60 | diff_lcs (1.3) 61 | rspec (3.7.0) 62 | rspec-core (~> 3.7.0) 63 | rspec-expectations (~> 3.7.0) 64 | rspec-mocks (~> 3.7.0) 65 | rspec-core (3.7.1) 66 | rspec-support (~> 3.7.0) 67 | rspec-expectations (3.7.0) 68 | diff_lcs (>= 1.2.0, < 2.0) 69 | rspec-support (~> 3.7.0) 70 | rspec-mocks (3.7.0) 71 | diff_lcs (>= 1.2.0, < 2.0) 72 | rspec-support (~> 3.7.0) 73 | rspec-support (3.7.0) 74 | highline (2.0.0) 75 | http_parser.rb (0.6.0) 76 | i18n (1.0.1) 77 | 78 | PLATFORMS 79 | ruby 80 | 81 | DEPENDENCIES 82 | rspec 83 | 84 | BUNDLED WITH 85 | 1.16.2 86 | BASE_FILE 87 | 88 | let(:head_file) { <<~HEAD_FILE } 89 | GEM 90 | remote: https://rubygems.org/ 91 | specs: 92 | pry (0.11.3) 93 | rspec (4.0.0) 94 | rspec-core (~> 3.7.0) 95 | rspec-expectations (~> 3.8.0) 96 | rspec-mocks (~> 3.6.0) 97 | rspec-core (3.7.1) 98 | rspec-support (~> 3.7.0) 99 | rspec-expectations (3.8.0) 100 | rspec-support (~> 3.7.0) 101 | rspec-mocks (3.6.0) 102 | rspec-support (~> 3.7.0) 103 | rspec-support (3.7.1) 104 | highline (2.0.0) 105 | i18n (1.1.0) 106 | 107 | PLATFORMS 108 | ruby 109 | 110 | DEPENDENCIES 111 | pry 112 | rspec 113 | 114 | BUNDLED WITH 115 | 1.16.2 116 | HEAD_FILE 117 | 118 | describe 'yielded gem changes' do 119 | subject(:gem_changes) do 120 | gem_changes = [] 121 | lock_file_diff.each_gem_change { |change| gem_changes << change } 122 | gem_changes 123 | end 124 | 125 | it 'yields the correct number of gem changes' do 126 | expect(gem_changes.count).to eq(8) 127 | end 128 | 129 | describe '1st change' do 130 | subject(:gem_change) { gem_changes[0] } 131 | its(:name) { should eq('diff_lcs') } 132 | it { should be_removed } 133 | its(:line_number) { should eq 4 } 134 | its(:base_version) { should eq GemVersion.new('1.3') } 135 | its(:head_version) { should be_nil } 136 | end 137 | 138 | describe '2nd change' do 139 | subject(:gem_change) { gem_changes[1] } 140 | its(:name) { should eq('http_parser.rb') } 141 | it { should be_removed } 142 | its(:line_number) { should eq 26 } 143 | its(:base_version) { should eq GemVersion.new('0.6.0') } 144 | its(:head_version) { should be_nil } 145 | end 146 | 147 | describe '3rd change' do 148 | subject(:gem_change) { gem_changes[2] } 149 | its(:name) { should eq('i18n') } 150 | it { should be_upgrade } 151 | it { should be_minor } 152 | its(:line_number) { should eq 28 } 153 | its(:base_version) { should eq GemVersion.new('1.0.1') } 154 | its(:head_version) { should eq GemVersion.new('1.1.0') } 155 | end 156 | 157 | describe '4th change' do 158 | subject(:gem_change) { gem_changes[3] } 159 | its(:name) { should eq('pry') } 160 | it { should be_added } 161 | its(:line_number) { should eq 6 } 162 | its(:base_version) { should be_nil } 163 | its(:head_version) { should eq GemVersion.new('0.11.3') } 164 | end 165 | 166 | describe '5th change' do 167 | subject(:gem_change) { gem_changes[4] } 168 | its(:name) { should eq('rspec') } 169 | it { should be_upgrade } 170 | it { should be_major } 171 | its(:line_number) { should eq 7 } 172 | its(:base_version) { should eq GemVersion.new('3.7.0') } 173 | its(:head_version) { should eq GemVersion.new('4.0.0') } 174 | end 175 | 176 | describe '6th change' do 177 | subject(:gem_change) { gem_changes[5] } 178 | its(:name) { should eq('rspec-expectations') } 179 | it { should be_upgrade } 180 | it { should be_minor } 181 | its(:line_number) { should eq 17 } 182 | its(:base_version) { should eq GemVersion.new('3.7.0') } 183 | its(:head_version) { should eq GemVersion.new('3.8.0') } 184 | end 185 | 186 | describe '7th change' do 187 | subject(:gem_change) { gem_changes[6] } 188 | its(:name) { should eq('rspec-mocks') } 189 | it { should be_downgrade } 190 | it { should be_minor } 191 | its(:line_number) { should eq 21 } 192 | its(:base_version) { should eq GemVersion.new('3.7.0') } 193 | its(:head_version) { should eq GemVersion.new('3.6.0') } 194 | end 195 | 196 | describe '8th change' do 197 | subject(:gem_change) { gem_changes[7] } 198 | its(:name) { should eq('rspec-support') } 199 | it { should be_upgrade } 200 | it { should be_patch } 201 | its(:line_number) { should eq 24 } 202 | its(:base_version) { should eq GemVersion.new('3.7.0') } 203 | its(:head_version) { should eq GemVersion.new('3.7.1') } 204 | end 205 | end 206 | end 207 | end 208 | --------------------------------------------------------------------------------