cutting_edge 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +10 -0
- data/LICENSE +674 -0
- data/README.md +95 -0
- data/Rakefile +159 -0
- data/bin/cutting_edge +72 -0
- data/cutting_edge.gemspec +57 -0
- data/lib/cutting_edge.rb +3 -0
- data/lib/cutting_edge/app.rb +77 -0
- data/lib/cutting_edge/badge.rb +46 -0
- data/lib/cutting_edge/langs.rb +123 -0
- data/lib/cutting_edge/langs/python.rb +102 -0
- data/lib/cutting_edge/langs/ruby.rb +47 -0
- data/lib/cutting_edge/langs/rust.rb +68 -0
- data/lib/cutting_edge/repo.rb +67 -0
- data/lib/cutting_edge/versions.rb +46 -0
- data/lib/cutting_edge/workers/badge.rb +22 -0
- data/lib/cutting_edge/workers/dependency.rb +100 -0
- data/lib/cutting_edge/workers/helpers.rb +24 -0
- data/spec/langs/python_spec.rb +89 -0
- data/spec/langs/rust_spec.rb +81 -0
- data/spec/spec_helper.rb +111 -0
- metadata +191 -0
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'victor'
|
2
|
+
|
3
|
+
class Badge
|
4
|
+
BADGE_OPTIONS = {
|
5
|
+
up_to_date: {
|
6
|
+
bg_color: '#32CD32',
|
7
|
+
text: 'up-to-date',
|
8
|
+
width: 180
|
9
|
+
},
|
10
|
+
out_of_date: {
|
11
|
+
bg_color: '#ff0000',
|
12
|
+
text: 'out-of-date',
|
13
|
+
width: 190
|
14
|
+
},
|
15
|
+
unknown: {
|
16
|
+
bg_color: '#666',
|
17
|
+
text: 'unknown',
|
18
|
+
width: 170
|
19
|
+
}
|
20
|
+
}
|
21
|
+
|
22
|
+
def self.build_badge(status, num=nil)
|
23
|
+
if ! [:up_to_date, :out_of_date, :unknown].include?(status)
|
24
|
+
status = :unknown
|
25
|
+
end
|
26
|
+
number = Integer(num) rescue nil if num
|
27
|
+
|
28
|
+
svg = Victor::SVG.new width: BADGE_OPTIONS[status][:width], height: 32, template: :html
|
29
|
+
|
30
|
+
style = {
|
31
|
+
stroke: '#d3d3d3',
|
32
|
+
stroke_width: 4
|
33
|
+
}
|
34
|
+
|
35
|
+
svg.build do
|
36
|
+
rect x: 0, y: 0, width: BADGE_OPTIONS[status][:width], height: 32, fill: BADGE_OPTIONS[status][:bg_color], style: style
|
37
|
+
|
38
|
+
g font_size: 14, font_family: 'arial', fill: 'white' do
|
39
|
+
text "#{number} Dependencies #{BADGE_OPTIONS[status][:text]}", x: 10, y: 20
|
40
|
+
end
|
41
|
+
end
|
42
|
+
return svg.render
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
|
@@ -0,0 +1,123 @@
|
|
1
|
+
require 'ostruct'
|
2
|
+
require 'toml-rb'
|
3
|
+
|
4
|
+
class Gem::Dependency
|
5
|
+
TYPES = [:runtime, :development, :build]
|
6
|
+
end
|
7
|
+
|
8
|
+
module LanguageHelpers
|
9
|
+
# Return a mock construct that mimicks Gem::Dependency for depedencies we tried to parse, but weren't valid.
|
10
|
+
def unknown_dependency(name, type = :runtime)
|
11
|
+
OpenStruct.new(name: name, type: type, requirement: 'unknown')
|
12
|
+
end
|
13
|
+
|
14
|
+
# For each dependency, find its latest version and return the two together. Takes account of invalid or dependencies (see #unknown_dependency)
|
15
|
+
#
|
16
|
+
# results - Array of Gem::Dependencies and unknown dependencies.
|
17
|
+
#
|
18
|
+
# Returns an Array of tuples of each dependency and its latest version: e.g., [[<Gem::Dependency>, <Gem::Version>]]
|
19
|
+
def dependency_with_latest(results)
|
20
|
+
results.map do |dependency|
|
21
|
+
[dependency, dependency.requirement.to_s == 'unknown' ? nil : latest_version(dependency.name)]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def log_error(message)
|
26
|
+
logger.error(message) if ::CuttingEdge::App.enable_logging
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class Language
|
31
|
+
include ::SemanticLogger::Loggable
|
32
|
+
extend LanguageHelpers
|
33
|
+
end
|
34
|
+
|
35
|
+
module LanguageVersionHelpers
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def canonical_version(version)
|
40
|
+
version.match(/^\./) ? "0#{version}" : version
|
41
|
+
end
|
42
|
+
|
43
|
+
# Translate wildcard (*) requirement to Ruby/Gem compatible requirement
|
44
|
+
#
|
45
|
+
# req - String version requirement
|
46
|
+
#
|
47
|
+
# Returns a translated String version requirement
|
48
|
+
def translate_wildcard(req)
|
49
|
+
if req =~ /!=/ # Negative Wildcard
|
50
|
+
# Turn != 1.1.* into >= 1.2, < 1.1
|
51
|
+
req.sub!('.*', '.0')
|
52
|
+
req.sub!('!=', '')
|
53
|
+
begin
|
54
|
+
v = Gem::Version.new(req) # Create the bumped version using Gem::Version so pre-release handling will work
|
55
|
+
rescue ArgumentError => e
|
56
|
+
return nil
|
57
|
+
end
|
58
|
+
lower_bound = ">= #{v.bump.version}"
|
59
|
+
upper_bound = "< #{v.version}"
|
60
|
+
[lower_bound, upper_bound]
|
61
|
+
elsif req =~ /^\s*\*\s*/
|
62
|
+
'>= 0' # Turn * into >= 0
|
63
|
+
else
|
64
|
+
"~> #{req.sub('.*', '.0').sub('=', '')}" # Turn =1.1.* or 1.1.* into ~> 1.1.0
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Translate SemVer Caret requirement to Ruby/Gem compatible requirement
|
69
|
+
# Caret requirement:
|
70
|
+
# lower bound = the unmodified version
|
71
|
+
# upper bound = take the left most non-zero digit and +1 it
|
72
|
+
#
|
73
|
+
# req - String version requirement
|
74
|
+
#
|
75
|
+
# Returns a translated String version requirement
|
76
|
+
def translate_caret(req)
|
77
|
+
req.sub!('^', '')
|
78
|
+
begin
|
79
|
+
version = Gem::Version.new(req)
|
80
|
+
rescue ArgumentError => e
|
81
|
+
return nil
|
82
|
+
end
|
83
|
+
segments = version.version.split('.')
|
84
|
+
index = segments.find_index {|seg| seg.to_i > 0} # Find the leftmost non-zero digit.
|
85
|
+
index = segments.rindex {|seg| seg.to_i == 0} unless index # If there is none, find the last 0.
|
86
|
+
segments[index] = segments[index].to_i + 1
|
87
|
+
upper_bound = segments[0..index].join('.')
|
88
|
+
[">= #{version.version}", "< #{upper_bound}"]
|
89
|
+
end
|
90
|
+
|
91
|
+
def parse_toml(content, sections)
|
92
|
+
begin
|
93
|
+
config = TomlRB.parse(content)
|
94
|
+
rescue TomlRB::ParseError => e
|
95
|
+
log_error("Encountered error when parsing TOML: #{e.class} #{e.message}")
|
96
|
+
return []
|
97
|
+
end
|
98
|
+
results = []
|
99
|
+
|
100
|
+
sections.each do |dependency_type, section_name|
|
101
|
+
packages = config[section_name] || next
|
102
|
+
packages.each do |name, info|
|
103
|
+
requirement = info.fetch('version', nil) rescue info
|
104
|
+
if requirement
|
105
|
+
requirements = requirement.split(',').map {|req| translate_requirement(req)}
|
106
|
+
next if requirements.include?(nil) # If a sub-requirement failed to translate, skip this entire dependency.
|
107
|
+
begin
|
108
|
+
results << Gem::Dependency.new(name, requirements, dependency_type)
|
109
|
+
rescue StandardError => e
|
110
|
+
log_error("Encountered error when parsing requirement #{requirements}: #{e.class} #{e.message}")
|
111
|
+
next
|
112
|
+
end
|
113
|
+
else
|
114
|
+
results << unknown_dependency(name, dependency_type)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
results
|
119
|
+
end
|
120
|
+
|
121
|
+
end
|
122
|
+
|
123
|
+
Dir[File.expand_path('../langs/*.rb', __FILE__)].each { |f| require f }
|
@@ -0,0 +1,102 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'http'
|
3
|
+
|
4
|
+
class PythonLang < Language
|
5
|
+
# For Requirements.txt
|
6
|
+
# See https://iscompatible.readthedocs.io/en/latest/
|
7
|
+
COMPARATORS = />=|>|<=|<|==/
|
8
|
+
VERSION_NUM = /\d[\.\w]*/
|
9
|
+
SUFFIX_OPTION = /\s*(\[.*\])?/
|
10
|
+
NAME = /[^,]+/
|
11
|
+
REGEX = /^(#{NAME})\s*(#{COMPARATORS})\s*(#{VERSION_NUM})(\s*,\s*(#{COMPARATORS})\s*(#{VERSION_NUM}))?#{SUFFIX_OPTION}$/
|
12
|
+
|
13
|
+
API_URL = 'https://pypi.org/pypi/'
|
14
|
+
|
15
|
+
PIPFILE_SECTIONS = {
|
16
|
+
:runtime => 'packages',
|
17
|
+
:development => 'dev-packages'
|
18
|
+
}
|
19
|
+
|
20
|
+
extend LanguageVersionHelpers
|
21
|
+
|
22
|
+
class << self
|
23
|
+
|
24
|
+
# Defaults for projects in this language
|
25
|
+
def locations(name = nil)
|
26
|
+
['requirements.txt', 'Pipfile']
|
27
|
+
end
|
28
|
+
|
29
|
+
# Parse a dependency file
|
30
|
+
#
|
31
|
+
# name - String contents of the file
|
32
|
+
# content - String contents of the file
|
33
|
+
#
|
34
|
+
# Returns an Array of tuples of each dependency and its latest version: [[<Gem::Dependency>, <Gem::Version>]]
|
35
|
+
def parse_file(name, content)
|
36
|
+
return nil unless content
|
37
|
+
if name =~ /\.txt$/
|
38
|
+
results = parse_requirements(content)
|
39
|
+
elsif name =~ /Pipfile/
|
40
|
+
results = parse_toml(content, PIPFILE_SECTIONS)
|
41
|
+
end
|
42
|
+
dependency_with_latest(results) if results
|
43
|
+
end
|
44
|
+
|
45
|
+
def parse_requirements(content)
|
46
|
+
results = []
|
47
|
+
content.each_line do |line|
|
48
|
+
next if line =~ /^\s*-e/ # ignore 'editable' dependencies
|
49
|
+
if line =~ COMPARATORS
|
50
|
+
next unless match = line.match(REGEX) # Skip this line if it doesn't conform to our expectations
|
51
|
+
name, first_comp, first_version, _ignore, second_comp, second_version = match.captures
|
52
|
+
first_comp = '=' if first_comp == '=='
|
53
|
+
second_comp = '=' if second_comp == '=='
|
54
|
+
dep = Gem::Dependency.new(name, "#{first_comp} #{first_version}")
|
55
|
+
dep.requirement.concat(["#{second_comp} #{second_version}"]) if second_comp && second_version
|
56
|
+
else
|
57
|
+
dep = Gem::Dependency.new(line.strip) # requries version to be >= 0
|
58
|
+
end
|
59
|
+
results << dep
|
60
|
+
end
|
61
|
+
results
|
62
|
+
end
|
63
|
+
|
64
|
+
# Find the latest versions of a dependency by name
|
65
|
+
#
|
66
|
+
# name - String name of the dependency
|
67
|
+
#
|
68
|
+
# Returns a Gem::Version
|
69
|
+
def latest_version(name)
|
70
|
+
begin
|
71
|
+
content = HTTP.timeout(::CuttingEdge::LAST_VERSION_TIMEOUT).follow(max_hops: 1).get(::File.join(API_URL, name, 'json')).parse
|
72
|
+
version = content['info']['version']
|
73
|
+
Gem::Version.new(canonical_version(version))
|
74
|
+
rescue StandardError, HTTP::TimeoutError => e
|
75
|
+
log_error("Encountered error when fetching latest version of #{name}: #{e.class} #{e.message}")
|
76
|
+
nil
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Translate version requirement syntax for Pipfiles to a String or Array of Strings that Gem::Dependency.new understands
|
81
|
+
# Pipfile support * and != requirements, which Ruby does not
|
82
|
+
# See https://www.python.org/dev/peps/pep-0440/#version-matching
|
83
|
+
#
|
84
|
+
# req - String version requirement
|
85
|
+
#
|
86
|
+
# Returns a translated String version requirement
|
87
|
+
def translate_requirement(req)
|
88
|
+
req.sub!('~=', '~>')
|
89
|
+
req.sub!('==', '=')
|
90
|
+
case req
|
91
|
+
when /\*/
|
92
|
+
translate_wildcard(req)
|
93
|
+
when '!='
|
94
|
+
req.sub!('!=', '')
|
95
|
+
["< #{req}", "> #{req}"]
|
96
|
+
else
|
97
|
+
req
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'gemnasium/parser'
|
2
|
+
require 'rubygems'
|
3
|
+
|
4
|
+
class RubyLang < Language
|
5
|
+
|
6
|
+
class << self
|
7
|
+
|
8
|
+
# Defaults for projects in this language
|
9
|
+
def locations(name)
|
10
|
+
["#{name}.gemspec", 'Gemfile']
|
11
|
+
end
|
12
|
+
|
13
|
+
# Parse a dependency file
|
14
|
+
#
|
15
|
+
# name - String contents of the file
|
16
|
+
# content - String contents of the file
|
17
|
+
#
|
18
|
+
# Returns an Array of tuples of each dependency and its latest version: [[<Bundler::Dependency>, <Gem::Version>]]
|
19
|
+
def parse_file(name, content)
|
20
|
+
return nil unless content
|
21
|
+
results = name =~ /gemspec/ ? parse_gemspec(content) : parse_gemfile(content)
|
22
|
+
dependency_with_latest(results)
|
23
|
+
end
|
24
|
+
|
25
|
+
def latest_version(name)
|
26
|
+
# Fancy todo: cache these?
|
27
|
+
begin
|
28
|
+
Gem::SpecFetcher.fetcher.spec_for_dependency(Gem::Dependency.new(name, nil)).flatten.first.version
|
29
|
+
rescue StandardError => e
|
30
|
+
log_error("Encountered error when fetching latest version of #{name}: #{e.class} #{e.message}")
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def parse_ruby(type, content)
|
36
|
+
Gemnasium::Parser.send(type, content).dependencies
|
37
|
+
end
|
38
|
+
|
39
|
+
def parse_gemspec(content)
|
40
|
+
parse_ruby(:gemspec, content)
|
41
|
+
end
|
42
|
+
|
43
|
+
def parse_gemfile(content)
|
44
|
+
parse_ruby(:gemfile, content)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'http'
|
3
|
+
|
4
|
+
class RustLang < Language
|
5
|
+
API_URL = 'https://crates.io/api/v1/crates'
|
6
|
+
|
7
|
+
CARGOFILE_SECTIONS = {
|
8
|
+
:runtime => 'dependencies',
|
9
|
+
:development => 'dev-dependencies',
|
10
|
+
:build => 'build-dependencies'
|
11
|
+
}
|
12
|
+
|
13
|
+
extend LanguageVersionHelpers
|
14
|
+
|
15
|
+
class << self
|
16
|
+
|
17
|
+
# Defaults for projects in this language
|
18
|
+
def locations(name = nil)
|
19
|
+
['Cargo.toml']
|
20
|
+
end
|
21
|
+
|
22
|
+
# Parse a dependency file
|
23
|
+
#
|
24
|
+
# name - String contents of the file
|
25
|
+
# content - String contents of the file
|
26
|
+
#
|
27
|
+
# Returns an Array of tuples of each dependency and its latest version: [[<Gem::Dependency>, <Gem::Version>]]
|
28
|
+
def parse_file(name, content)
|
29
|
+
return nil unless content
|
30
|
+
results = parse_toml(content, CARGOFILE_SECTIONS)
|
31
|
+
dependency_with_latest(results) if results
|
32
|
+
end
|
33
|
+
|
34
|
+
# Find the latest versions of a dependency by name
|
35
|
+
#
|
36
|
+
# name - String name of the dependency
|
37
|
+
#
|
38
|
+
# Returns a Gem::Version
|
39
|
+
def latest_version(name)
|
40
|
+
begin
|
41
|
+
content = HTTP.timeout(::CuttingEdge::LAST_VERSION_TIMEOUT).get(::File.join(API_URL, name)).parse
|
42
|
+
version = content['crate']['max_version']
|
43
|
+
Gem::Version.new(canonical_version(version))
|
44
|
+
rescue StandardError, HTTP::Error => e
|
45
|
+
log_error("Encountered error when fetching latest version of #{name}: #{e.class} #{e.message}")
|
46
|
+
nil
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Translate Cargo version requirement syntax to a String or Array of Strings that Gem::Dependency.new understands
|
51
|
+
# Cargo.toml files support * and ^ (wildcard and caret) requirements, which Ruby does not
|
52
|
+
# See: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html
|
53
|
+
#
|
54
|
+
# req - String version requirement
|
55
|
+
#
|
56
|
+
# Returns a translated String version requirement
|
57
|
+
def translate_requirement(req)
|
58
|
+
if req =~ /~|<|>|\*|=/
|
59
|
+
return translate_wildcard(req) if req =~ /\*/
|
60
|
+
req.sub!('~', '~>')
|
61
|
+
req
|
62
|
+
else
|
63
|
+
translate_caret(req)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require File.expand_path('../langs.rb', __FILE__)
|
2
|
+
|
3
|
+
module CuttingEdge
|
4
|
+
class Repository
|
5
|
+
|
6
|
+
DEPENDENCY_TYPES = [:runtime] # Which dependency types to accept (default only :runtime, excludes :development).
|
7
|
+
DEFAULT_LANG = 'ruby'
|
8
|
+
|
9
|
+
attr_reader :token, :locations, :lang
|
10
|
+
attr_accessor :dependency_types
|
11
|
+
|
12
|
+
def initialize(org, name, lang = nil, locations = nil, branch = nil, token = nil)
|
13
|
+
@org = org
|
14
|
+
@name = name
|
15
|
+
@branch = branch || 'master'
|
16
|
+
@token = token
|
17
|
+
@lang = lang || DEFAULT_LANG
|
18
|
+
@locations = {}
|
19
|
+
(locations || get_lang(@lang).locations(name)).each do |loc|
|
20
|
+
@locations[loc] = url_for_file(loc)
|
21
|
+
end
|
22
|
+
@dependency_types = DEPENDENCY_TYPES
|
23
|
+
end
|
24
|
+
|
25
|
+
def source
|
26
|
+
''
|
27
|
+
end
|
28
|
+
|
29
|
+
def identifier
|
30
|
+
File.join(source, @org, @name)
|
31
|
+
end
|
32
|
+
|
33
|
+
def url_for_file(file)
|
34
|
+
file
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def get_lang(lang)
|
40
|
+
Object.const_get("::#{lang.capitalize}Lang")
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
class GithubRepository < Repository
|
45
|
+
HOST = 'https://raw.githubusercontent.com'
|
46
|
+
|
47
|
+
def source
|
48
|
+
'github'
|
49
|
+
end
|
50
|
+
|
51
|
+
def url_for_file(file)
|
52
|
+
File.join(HOST, @org, @name, @branch, file)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
class GitlabRepository < Repository
|
57
|
+
HOST = 'https://gitlab.com/'
|
58
|
+
|
59
|
+
def source
|
60
|
+
'gitlab'
|
61
|
+
end
|
62
|
+
|
63
|
+
def url_for_file(file)
|
64
|
+
File.join(HOST, @org, @name, 'raw', @branch, file)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|