unwrappr 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.buildkite/pipeline.yml +38 -0
  3. data/.buildkite/steps/rspec.sh +9 -0
  4. data/.buildkite/steps/rubocop.sh +9 -0
  5. data/.gitignore +14 -0
  6. data/.rspec +3 -0
  7. data/.rubocop.yml +25 -0
  8. data/.travis.yml +5 -0
  9. data/CHANGELOG.md +9 -0
  10. data/CODE_OF_CONDUCT.md +74 -0
  11. data/Gemfile +8 -0
  12. data/Guardfile +16 -0
  13. data/LICENSE.txt +21 -0
  14. data/README.md +118 -0
  15. data/Rakefile +10 -0
  16. data/bin/console +11 -0
  17. data/bin/setup +8 -0
  18. data/exe/unwrappr +11 -0
  19. data/lib/unwrappr/bundler_command_runner.rb +25 -0
  20. data/lib/unwrappr/cli.rb +45 -0
  21. data/lib/unwrappr/gem_change.rb +54 -0
  22. data/lib/unwrappr/gem_version.rb +57 -0
  23. data/lib/unwrappr/git_command_runner.rb +81 -0
  24. data/lib/unwrappr/github/client.rb +75 -0
  25. data/lib/unwrappr/github/pr_sink.rb +28 -0
  26. data/lib/unwrappr/github/pr_source.rb +59 -0
  27. data/lib/unwrappr/lock_file_annotator.rb +65 -0
  28. data/lib/unwrappr/lock_file_comparator.rb +28 -0
  29. data/lib/unwrappr/lock_file_diff.rb +71 -0
  30. data/lib/unwrappr/octokit.rb +8 -0
  31. data/lib/unwrappr/researchers/composite.rb +21 -0
  32. data/lib/unwrappr/researchers/github_comparison.rb +43 -0
  33. data/lib/unwrappr/researchers/github_repo.rb +25 -0
  34. data/lib/unwrappr/researchers/ruby_gems_info.rb +17 -0
  35. data/lib/unwrappr/researchers/security_vulnerabilities.rb +50 -0
  36. data/lib/unwrappr/ruby_gems.rb +39 -0
  37. data/lib/unwrappr/spec_version_comparator.rb +19 -0
  38. data/lib/unwrappr/version.rb +5 -0
  39. data/lib/unwrappr/writers/composite.rb +21 -0
  40. data/lib/unwrappr/writers/github_commit_log.rb +72 -0
  41. data/lib/unwrappr/writers/project_links.rb +45 -0
  42. data/lib/unwrappr/writers/security_vulnerabilities.rb +109 -0
  43. data/lib/unwrappr/writers/title.rb +32 -0
  44. data/lib/unwrappr/writers/version_change.rb +58 -0
  45. data/lib/unwrappr.rb +32 -0
  46. data/unwrappr.gemspec +56 -0
  47. metadata +299 -0
@@ -0,0 +1,81 @@
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!
11
+ raise 'Not a git working dir' unless git_dir?
12
+ raise 'failed to create branch' unless branch_created?
13
+ end
14
+
15
+ def commit_and_push_changes!
16
+ raise 'failed to add git changes' unless git_added_changes?
17
+ raise 'failed to commit changes' unless git_committed?
18
+ raise 'failed to push changes' unless git_pushed?
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
+ private
40
+
41
+ def git_dir?
42
+ git_wrap { !current_branch_name.empty? }
43
+ end
44
+
45
+ def branch_created?
46
+ timestamp = Time.now.strftime('%Y%d%m-%H%M').freeze
47
+ git_wrap do
48
+ git.checkout('origin/master')
49
+ git.branch("auto_bundle_update_#{timestamp}").checkout
50
+ end
51
+ end
52
+
53
+ def git_added_changes?
54
+ git_wrap { git.add(all: true) }
55
+ end
56
+
57
+ def git_committed?
58
+ git_wrap { git.commit('Automatic Bundle Update') }
59
+ end
60
+
61
+ def git_pushed?
62
+ git_wrap { git.push('origin', current_branch_name) }
63
+ end
64
+
65
+ def git
66
+ log_options = {}.tap do |opt|
67
+ opt[:log] = Logger.new(STDOUT) if ENV['DEBUG']
68
+ end
69
+
70
+ @git ||= Git.open(Dir.pwd, log_options)
71
+ end
72
+
73
+ def git_wrap
74
+ yield
75
+ true
76
+ rescue Git::GitExecuteError
77
+ false
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,75 @@
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!
16
+ create_and_annotate_pull_request
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[/:](?<org>.*)/(?<repo>.*)}
26
+ m = pattern.match(repo_url)
27
+ [m[:org], m[:repo]].join('/')
28
+ end
29
+
30
+ def create_and_annotate_pull_request
31
+ pr = git_client.create_pull_request(
32
+ repo_name_and_org,
33
+ 'master',
34
+ Unwrappr::GitCommandRunner.current_branch_name,
35
+ 'Automated Bundle Update',
36
+ pull_request_body
37
+ )
38
+ annotate_pull_request(pr.number)
39
+ end
40
+
41
+ def pull_request_body
42
+ <<~BODY
43
+ Gems brought up-to-date with :heart: by [Unwrappr](https://github.com/envato/unwrappr).
44
+ See individual annotations below for details.
45
+ BODY
46
+ end
47
+
48
+ def annotate_pull_request(pr_number)
49
+ LockFileAnnotator.annotate_github_pull_request(
50
+ repo: repo_name_and_org,
51
+ pr_number: pr_number,
52
+ client: git_client
53
+ )
54
+ end
55
+
56
+ def git_client
57
+ @git_client ||= Octokit::Client.new(access_token: github_token)
58
+ end
59
+
60
+ def github_token
61
+ @github_token ||= ENV.fetch('GITHUB_TOKEN') do
62
+ raise %(
63
+ Missing environment variable GITHUB_TOKEN.
64
+ See https://github.com/settings/tokens to set up personal access tokens.
65
+ Add to the environment:
66
+
67
+ export GITHUB_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
68
+
69
+ )
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,28 @@
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
@@ -0,0 +1,59 @@
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, client)
13
+ @repo = repo
14
+ @pr_number = pr_number
15
+ @client = client
16
+ end
17
+
18
+ def each_file
19
+ lock_file_diffs.each do |lock_file_diff|
20
+ yield LockFileDiff.new(
21
+ filename: lock_file_diff.filename,
22
+ base_file: file_contents(lock_file_diff.filename, base_sha),
23
+ head_file: file_contents(lock_file_diff.filename, head_sha),
24
+ patch: lock_file_diff.patch,
25
+ sha: head_sha
26
+ )
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def lock_file_diffs
33
+ @lock_file_diffs ||= @client
34
+ .pull_request_files(@repo, @pr_number)
35
+ .select do |file|
36
+ File.basename(file.filename) == 'Gemfile.lock'
37
+ end
38
+ end
39
+
40
+ def file_contents(filename, ref)
41
+ Base64.decode64(
42
+ @client.contents(@repo, path: filename, ref: ref).content
43
+ )
44
+ end
45
+
46
+ def head_sha
47
+ @head_sha ||= pull_request.head.sha
48
+ end
49
+
50
+ def base_sha
51
+ @base_sha ||= pull_request.base.sha
52
+ end
53
+
54
+ def pull_request
55
+ @pull_request ||= @client.pull_request(@repo, @pr_number)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,65 @@
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:, client: Octokit.client
22
+ )
23
+ new(
24
+ lock_file_diff_source: Github::PrSource.new(repo, pr_number, 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
+ lock_file_diff.each_gem_change do |gem_change|
58
+ gem_change_info = @gem_researcher.research(gem_change, {})
59
+ message = @annotation_writer.write(gem_change, gem_change_info)
60
+ @annotation_sink.annotate_change(gem_change, message)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,28 @@
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
+ Hash[lock_file.specs.map { |s| [s.name.to_sym, s.version.to_s] }]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,71 @@
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 = /^(?<change_type>[\+\-]) (?<gem_name>[\w-]+) \(\d/
67
+ match = pattern.match(line)
68
+ return match[:gem_name], match[:change_type] unless match.nil?
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Wrapper around octokit
4
+ module Octokit
5
+ def self.client
6
+ @client ||= Client.new(access_token: ENV['GITHUB_TOKEN'])
7
+ end
8
+ end
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,43 @@
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, gem_change_info)
16
+ repo = gem_change_info[:github_repo]
17
+ return gem_change_info if repo.nil?
18
+
19
+ gem_change_info.merge(
20
+ github_comparison: try_comparing(
21
+ repo: repo,
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
+ end
42
+ end
43
+ end
@@ -0,0 +1,25 @@
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?://github.com/(?<repo>[^/]+/[^/]+)}i.freeze
11
+
12
+ def research(_gem_change, gem_change_info)
13
+ repo = match_repo(gem_change_info, :source_code_uri) ||
14
+ match_repo(gem_change_info, :homepage_uri)
15
+ gem_change_info.merge(github_repo: repo)
16
+ end
17
+
18
+ def match_repo(gem_change_info, uri_name)
19
+ uri = gem_change_info.dig(:ruby_gems, uri_name)
20
+ match = GITHUB_URI_PATTERN.match(uri)
21
+ match[:repo] if match
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
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(gem_change.name)
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,50 @@
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
@@ -0,0 +1,39 @@
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/v1/gems/%s.json'
10
+
11
+ class << self
12
+ def gem_info(name)
13
+ parse(Faraday.get(SERVER + GET_GEM % name), name)
14
+ end
15
+
16
+ def try_get_source_code_uri(gem_name)
17
+ Unwrappr::RubyGems.gem_info(gem_name)&.source_code_uri
18
+ end
19
+
20
+ private
21
+
22
+ def parse(response, name)
23
+ case response.status
24
+ when 200
25
+ JSON.parse(response.body, object_class: OpenStruct)
26
+ when 404
27
+ nil
28
+ else
29
+ STDERR.puts(error_message(response: response, name: name))
30
+ end
31
+ end
32
+
33
+ def error_message(response:, name:)
34
+ "Rubygems response for #{name}: "\
35
+ "HTTP #{response.status}: #{response.body}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unwrappr
4
+ VERSION = '0.3.0'
5
+ end
@@ -0,0 +1,21 @@
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