gems_bond 1.0.4 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc62800e859ce8dcf0015580a685bfaf8b5410c69843acae6d49e24fae011c4e
4
- data.tar.gz: 6ee8416eade996316171af619d7e7fd6f14d3c82817f299c92c793902b5b6076
3
+ metadata.gz: 2cafc096896bf8383fde404d0c47020e4b8f9b1fe49c5cabfc1e8357c766682e
4
+ data.tar.gz: 48b00b4c32c30eafa88fa8c839b8ad9c7ddab251420e501c6010853062064047
5
5
  SHA512:
6
- metadata.gz: d82f72f550a6be300e0d5ca5483b10ef8a7070e10912c89e2900675bdafd712ba6c4014728a5641f7789666d84da8ec126ea2c2b4e0d8a155b7134f68261f952
7
- data.tar.gz: ae59181cbfbf9cff164a4c7c5776ccea3ef5c14a773a18d8ae5706be553fc61eff0b9d2a64b54bd11adeb7da8516152cbca5a1cc78daa04f292fdf43391618bb
6
+ metadata.gz: bfe4e248242f9609bad37d5b72cd5a6ef1557ec59039729c76b5f5658f08dea6dcf9ab5c563bab778c27de15688ad77b3f7d9cbea4e569c22f0bfb84b3aa3fd2
7
+ data.tar.gz: b46f8529d54c8a3b3e6de88f9951bb78c61e2747bb8033de3f712556a874d58548428aa1d2fea8216997487e9962f906b112c1c268964d426f4d2a68b27a5308
data/lib/gems_bond.rb CHANGED
@@ -5,11 +5,12 @@ require "gems_bond/railtie" if defined?(Rails)
5
5
 
6
6
  # Gem module
7
7
  module GemsBond
8
- class Error < StandardError; end
9
-
10
8
  class << self
11
9
  attr_accessor :configuration
12
10
 
11
+ # Configures Gems Bond
12
+ # @return GemsBond::Configuration
13
+ # @yieldparam [Proc] configuration to apply
13
14
  def configure
14
15
  self.configuration ||= Configuration.new
15
16
  yield(configuration)
@@ -5,6 +5,8 @@ module GemsBond
5
5
  class Configuration
6
6
  attr_accessor :github_token
7
7
 
8
+ # Initializes the configuration
9
+ # @return [GemsBond::Configuration]
8
10
  def initialize
9
11
  @github_token = nil
10
12
  end
@@ -1,16 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
4
+
3
5
  module GemsBond
4
6
  # Examines gem sanity
5
- module ExaminationHelper
7
+ module Examination
6
8
  SCORES = YAML.safe_load(File.read(File.join(File.dirname(__FILE__), "scores.yml")))
7
9
  BOUNDARIES = SCORES["boundaries"]
8
10
  RESULTS = SCORES["results"]
9
11
 
10
- def version_gap
11
- memoize(:version_gap) { calculate_version_gap }
12
- end
12
+ # @!method activity_score
13
+ # Returns activity score
14
+ # @return [Integer, nil] in [0, 1] (memoized)
15
+
16
+ # @!method popularity_score
17
+ # Returns popularity score
18
+ # @return [Integer, nil] in [0, 1] (memoized)
13
19
 
20
+ # @!method average_score
21
+ # Returns average score
22
+ # @return [Integer, nil] in [0, 1] (memoized)
14
23
  RESULTS.each do |result, values|
15
24
  define_method("#{result}_score") do
16
25
  memoize("#{result}_score") do
@@ -27,6 +36,9 @@ module GemsBond
27
36
 
28
37
  # Scores getters and calculation
29
38
 
39
+ # For each inspected data, generate two methods:
40
+ # - <data>_score that memoizes the calculated score
41
+ # - calculate_<data>_score that calculates the score and returns [Integer, nil] in [0, 1]
30
42
  BOUNDARIES.each_key do |key|
31
43
  # create a _score getter for each key
32
44
  define_method("#{key}_score") do
@@ -61,6 +73,15 @@ module GemsBond
61
73
  end
62
74
  end
63
75
 
76
+ # Returns a score with a logarithmic approach
77
+ # @param value [Float] the value for inspected data
78
+ # @param comparison [Numeric] the value giving the maximal score (1)
79
+ # @returns [Float]
80
+ # @example
81
+ # soften(10.to_f,100.to_f) #=> 0.46
82
+ # The idea is that a gem with half as much stars
83
+ # than rails gem (which is very high and stands for comparison here)
84
+ # should get a very high score but still be behind rails itself
64
85
  def soften(value, comparison)
65
86
  # returns a shaped curve
66
87
  sigmoid = ->(x) { 1 / (1 + Math.exp(-x * 10)) }
@@ -69,8 +90,11 @@ module GemsBond
69
90
  (sigmoid.call(value / comparison) - 0.5) * 2
70
91
  end
71
92
 
72
- # --- COMPUTE
73
-
93
+ # Returns an average after including weight for each value
94
+ # @param scores [Array<Array<Float, Integer>>] each hash looks like [value, weight]
95
+ # @returns [Float]
96
+ # @example
97
+ # weighted_average([[4.0, 2], [1.0, 3]]) #=> 2.2
74
98
  def weighted_average(scores)
75
99
  acc = 0
76
100
  weight = 0
@@ -85,17 +109,5 @@ module GemsBond
85
109
 
86
110
  acc / weight
87
111
  end
88
-
89
- # --- VERSION STATUS
90
-
91
- def calculate_version_gap
92
- return unless version && versions
93
-
94
- index = versions.index { |v| v[:number] == version }
95
- return unless index
96
-
97
- gap = versions[0..index].count { |v| !v[:prerelease] } - 1
98
- gap.positive? ? gap : 0
99
- end
100
112
  end
101
113
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "octokit"
4
+
5
+ module GemsBond
6
+ module Fetchers
7
+ class NotStartedError < StandardError; end
8
+
9
+ # Fetches data
10
+ class Fetcher
11
+ # Initializes an instance
12
+ # @param param [Object] Fetcher dependent
13
+ # @return [GemsBond::Fetchers::Fetcher]
14
+ def initialize(param); end
15
+
16
+ # Starts the service and returns self
17
+ # @note rescue connection errors with nil
18
+ def start
19
+ @started = true
20
+ end
21
+
22
+ # Starts the service and returns self
23
+ # @note rescue connection errors with nil
24
+ def stop
25
+ @started = false
26
+ end
27
+
28
+ # Is the service started?
29
+ def started?
30
+ @started
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,58 +1,79 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "octokit"
4
+ require "gems_bond/fetchers/fetcher"
4
5
 
5
6
  module GemsBond
6
- module Fetcher
7
+ module Fetchers
7
8
  # Fetches data from GitHub
8
- class Github
9
+ class Github < Fetcher
10
+ # GitHub repository pattern, e.g.: "https://github.com/BigBigDoudou/gems_bond"
9
11
  REPOSITORY_REGEX = %r{https?://github.com/(?<repository>.*/.*)}.freeze
10
12
 
11
13
  class << self
14
+ # Validates that `url` matches GitHub repository URL
12
15
  def valid_url?(url)
13
16
  url&.match?(REPOSITORY_REGEX)
14
17
  end
15
18
  end
16
19
 
20
+ # Initializes an instance
21
+ # @param url [String] URL of the GitHub repository
22
+ # @return [GemsBond::Fetchers::Github]
17
23
  def initialize(url)
24
+ super(url)
18
25
  @url = url
19
26
  end
20
27
 
21
- def source
22
- "github"
23
- end
24
-
28
+ # Starts the service
29
+ # @return [Boolean]
30
+ # @note rescue connection errors with nil
25
31
  def start
32
+ super
26
33
  parse_url
27
34
  login
35
+ # ensure repository exists (otherwise it raises Octokit error)
28
36
  set_repository
29
- self
30
37
  rescue Octokit::Unauthorized, Octokit::InvalidRepository, Octokit::NotFound
31
- nil
38
+ stop
32
39
  end
33
40
 
41
+ # Returns number of forks
42
+ # @return [Integer]
34
43
  def forks_count
35
44
  @repository["forks"]
36
45
  end
37
46
 
47
+ # Returns number of stars
48
+ # @return [Integer]
38
49
  def stars_count
39
50
  @repository["watchers"]
40
51
  end
41
52
 
53
+ # Returns number of contributors
54
+ # @return [Integer]
55
+ # @note GitHub API does not provide this number out of the box
56
+ # so we fetch all repository contributors and paginate with 1 per page
57
+ # thus the last page (from headers) should equal the number of contributors
42
58
  def contributors_count
43
59
  client = Octokit::Client.new(access_token: token, per_page: 1)
44
60
  client.contributors(@repository_path)
45
61
  response = client.last_response
46
62
  links = response.headers[:link]
63
+ # e.g.: "[...] <https://api.github.com/repositories/8514/contributors?per_page=1&page=377>; rel=\"last\""
47
64
  return 0 unless links
48
65
 
49
- Integer(links.match(/.*page=(?<last>\d+)>; rel="last"/)[:last], 10)
66
+ Integer(links.match(/.*per_page=1&page=(?<last>\d+)>; rel="last"/)[:last], 10)
50
67
  end
51
68
 
69
+ # Returns number of open issues
70
+ # @return [Integer]
52
71
  def open_issues_count
53
72
  @repository["open_issues"]
54
73
  end
55
74
 
75
+ # Returns date of last commit (on main branch)
76
+ # @return [Date]
56
77
  def last_commit_date
57
78
  date = client.commits(@repository_path).first[:commit][:committer][:date]
58
79
  return unless date
@@ -60,12 +81,16 @@ module GemsBond
60
81
  Date.parse(date.to_s)
61
82
  end
62
83
 
84
+ # Returns number of days since last commit
85
+ # @return [Date]
63
86
  def days_since_last_commit
64
87
  return unless last_commit_date
65
88
 
66
- Date.today - last_commit_date
89
+ Integer(Date.today - last_commit_date)
67
90
  end
68
91
 
92
+ # Returns size of the lib directory
93
+ # @return [Integer]
69
94
  def lib_size
70
95
  contents_size = dir_size("lib")
71
96
  return unless contents_size
@@ -78,6 +103,9 @@ module GemsBond
78
103
 
79
104
  private
80
105
 
106
+ # Parses url to find out repository path
107
+ # @return [String]
108
+ # @raise Octokit::InvalidRepository if the url pattern is invalid
81
109
  def parse_url
82
110
  matches = @url.match(REPOSITORY_REGEX)
83
111
  raise Octokit::InvalidRepository unless matches
@@ -86,28 +114,44 @@ module GemsBond
86
114
  @repository_path = "#{path[0]}/#{path[1]}"
87
115
  end
88
116
 
117
+ # Logs with client
118
+ # @return [String] GitHub'username
89
119
  def login
120
+ return unless token
121
+
90
122
  @login ||= client.user.login
91
123
  end
92
124
 
125
+ # Initializes a client
126
+ # @return [Octokit::Client]
93
127
  def client
94
128
  Octokit::Client.new(access_token: token)
95
129
  end
96
130
 
131
+ # Returns token from configuration
132
+ # @return [String, nil]
97
133
  def token
98
134
  GemsBond.configuration&.github_token
99
135
  end
100
136
 
137
+ # Returns repository object
138
+ # @return [Sawyer::Resource]
101
139
  def set_repository
102
140
  @repository = client.repo(@repository_path)
103
141
  end
104
142
 
143
+ # Returns size of the given directory
144
+ # @param dir_path [String] path to the directory, e.g.: "lib/devise/strategies"
145
+ # @return [Integer]
146
+ # @note sum size of each subdirectories recursively
105
147
  def dir_size(dir_path)
106
148
  contents = client.contents(@repository_path, path: dir_path)
149
+ # starts accumulator with size of files
107
150
  acc =
108
151
  contents
109
152
  .select { |content| content[:type] == "file" }
110
153
  .sum { |content| content[:size] }
154
+ # adds size of subdirectories to the accumulator, with recursion
111
155
  acc +=
112
156
  contents
113
157
  .select { |content| content[:type] == "dir" }
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gems"
4
+ require "rubygems"
5
+ require "gems_bond/fetchers/fetcher"
6
+
7
+ module GemsBond
8
+ module Fetchers
9
+ # Fetches data from RubyGems
10
+ class RubyGems < Fetcher
11
+ # Initializes an instance
12
+ # @param name [String] name of the gem
13
+ # @return [GemsBond::Fetchers::RubyGems]
14
+ def initialize(name)
15
+ super(name)
16
+ @name = name
17
+ end
18
+
19
+ # Starts the service
20
+ # @return [Boolean]
21
+ # @note rescue connection errors with nil
22
+ def start
23
+ super
24
+ # ensure gem exists (otherwise it raises Gems error)
25
+ @info = Gems.info(@name)
26
+ rescue Gems::NotFound
27
+ stop
28
+ end
29
+
30
+ # Returns gem description
31
+ # @return [String]
32
+ def info
33
+ @info["info"]
34
+ end
35
+
36
+ # Returns number of downloads
37
+ # @return [Integer]
38
+ def downloads_count
39
+ Gems.total_downloads(@name)[:total_downloads]
40
+ end
41
+
42
+ # Returns source code URI
43
+ # @return [String]
44
+ def source_code_uri
45
+ @info["source_code_uri"]
46
+ end
47
+
48
+ # Returns versions data (number, date and if it is a prerelease)
49
+ # @return [Array<Hash>]
50
+ # @note each hash structure: { number: String, created_at: Date, prerelease: Boolean }
51
+ def versions
52
+ Gems.versions(@name).map do |version|
53
+ {
54
+ number: version["number"],
55
+ created_at: Date.parse(version["created_at"]),
56
+ prerelease: version["prerelease"]
57
+ }
58
+ end
59
+ end
60
+
61
+ # Returns last version number
62
+ # @return [String]
63
+ def last_version
64
+ versions&.first&.dig(:number)
65
+ end
66
+
67
+ # Returns last version date
68
+ # @return [Date]
69
+ def last_version_date
70
+ versions&.first&.dig(:created_at)
71
+ end
72
+
73
+ # Returns number of days since last version
74
+ # @return [Integer]
75
+ def days_since_last_version
76
+ return unless last_version_date
77
+
78
+ Integer(Date.today - last_version_date)
79
+ end
80
+ end
81
+ end
82
+ end
data/lib/gems_bond/gem.rb CHANGED
@@ -1,46 +1,164 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "gems_bond/fetch_helper"
4
- require "gems_bond/examination_helper"
3
+ require "gems_bond/helpers/concurrency_helper"
4
+ require "gems_bond/fetchers/ruby_gems"
5
+ require "gems_bond/fetchers/github"
6
+ require "gems_bond/examination"
5
7
 
6
8
  module GemsBond
7
9
  # Handles gem data
8
10
  class Gem
9
- include FetchHelper
10
- include ExaminationHelper
11
+ include GemsBond::Helpers::ConcurrencyHelper
12
+ include GemsBond::Examination
11
13
 
12
- def initialize(dependency)
14
+ attr_reader :unknown
15
+
16
+ RUBY_GEM_KEYS = %i[
17
+ days_since_last_version downloads_count info last_version last_version_date source_code_uri versions
18
+ ].freeze
19
+
20
+ GITHUB_KEYS = %i[
21
+ contributors_count days_since_last_commit forks_count last_commit_date open_issues_count stars_count
22
+ ].freeze
23
+
24
+ # Initializes an instance
25
+ # @param dependency [Bundler::Dependency]
26
+ # @param unknown [Boolean] is it a current dependency?
27
+ # @return [GemsBond::Gem]
28
+ def initialize(dependency, unknown: false)
13
29
  @dependency = dependency
30
+ @unknown = unknown
14
31
  end
15
32
 
33
+ # Is the gem hosted on RubyGems?
34
+ # @retun [Boolean]
35
+ def exist?
36
+ ruby_gems_fetcher.started?
37
+ end
38
+
39
+ # Returns name
40
+ # @return [String] (memoized)
16
41
  def name
17
42
  memoize(__method__) { @dependency.name }
18
43
  end
19
44
 
45
+ # Returns description
46
+ # @return [String] (memoized)
20
47
  def description
21
- memoize(__method__) { @dependency.description }
48
+ memoize(__method__) do
49
+ unknown ? info : @dependency.to_spec.description
50
+ end
22
51
  end
23
52
 
53
+ # Returns used version
54
+ # @return [String] (memoized)
24
55
  def version
25
- memoize(__method__) { @dependency.to_spec.version.to_s }
56
+ memoize(__method__) do
57
+ @dependency.to_spec.version.to_s unless unknown
58
+ end
26
59
  end
27
60
 
61
+ # Returns homepage
62
+ # @return [String] (memoized)
28
63
  def homepage
29
- memoize(__method__) { @dependency.to_spec.homepage }
64
+ memoize(__method__) do
65
+ @dependency.to_spec.homepage unless unknown
66
+ end
30
67
  end
31
68
 
69
+ # Returns url
70
+ # @return [String]
32
71
  def url
33
72
  homepage || source_code_uri
34
73
  end
35
74
 
75
+ # Returns GitHub url if exist
76
+ # @return [String, nil]
36
77
  def github_url
37
- return homepage if GemsBond::Fetcher::Github.valid_url?(homepage)
78
+ [homepage, source_code_uri].find do |url|
79
+ GemsBond::Fetchers::Github.valid_url?(url)
80
+ end
81
+ end
82
+
83
+ RUBY_GEM_KEYS.each do |key|
84
+ define_method(key) do
85
+ memoize(key) do
86
+ fetch(ruby_gems_fetcher, key)
87
+ end
88
+ end
89
+ end
90
+
91
+ GITHUB_KEYS.each do |key|
92
+ define_method(key) do
93
+ memoize(key) do
94
+ fetch(github_fetcher, key)
95
+ end
96
+ end
97
+ end
98
+
99
+ # Returns gap between installed and last released version, in days
100
+ # @return [Integer, nil] (memoized)
101
+ def version_gap
102
+ memoize(:version_gap) do
103
+ return unless version && versions
104
+
105
+ index = versions.index { |v| v[:number] == version }
106
+ return unless index
38
107
 
39
- source_code_uri if GemsBond::Fetcher::Github.valid_url?(source_code_uri)
108
+ gap = versions[0..index].count { |v| !v[:prerelease] } - 1
109
+ gap.positive? ? gap : 0
110
+ end
111
+ end
112
+
113
+ # Fetches data from APIs
114
+ # @param concurrency [Boolean] should it be run concurrently?
115
+ # @param verbose [Boolean] should gem's name be stdout?
116
+ # @return [void]
117
+ def prepare_data(keys: nil, concurrency: false, verbose: false)
118
+ fetch_key = ->(key) { (keys.nil? || key.in?(keys)) && __send__(key) }
119
+ if concurrency
120
+ each_concurrently(RUBY_GEM_KEYS + GITHUB_KEYS, &fetch_key)
121
+ else
122
+ (RUBY_GEM_KEYS + GITHUB_KEYS).each(&fetch_key)
123
+ end
124
+ puts(name) if verbose
40
125
  end
41
126
 
42
127
  private
43
128
 
129
+ # Fetches the given data with the given fetcher
130
+ # @param fetcher [GemsBond::Fetchers]
131
+ # @param key [String]
132
+ # @return [Object, nil]
133
+ def fetch(fetcher, key)
134
+ return if fetcher.nil?
135
+ raise GemsBond::Fetchers::NotStartedError unless fetcher.started?
136
+
137
+ fetcher.public_send(key)
138
+ end
139
+
140
+ # Returns a started RubyGems fetcher
141
+ # @return [GemsBond::Fetchers::RubyGems, nil]
142
+ # @note #start is needed to ensure the fetcher works
143
+ def ruby_gems_fetcher
144
+ return @ruby_gems_fetcher if defined?(@ruby_gems_fetcher)
145
+
146
+ @ruby_gems_fetcher = GemsBond::Fetchers::RubyGems.new(name).tap(&:start)
147
+ end
148
+
149
+ # Returns a started GitHub fetcher
150
+ # @return [GemsBond::Fetchers::Github, nil]
151
+ # @note #start is needed to ensure the fetcher works (especially the token)
152
+ def github_fetcher
153
+ return @github_fetcher if defined?(@github_fetcher)
154
+
155
+ @github_fetcher = github_url && GemsBond::Fetchers::Github.new(github_url).tap(&:start)
156
+ end
157
+
158
+ # Memoizes the given key and apply the given block
159
+ # @param key [String] the instance variable key
160
+ # @yieldparam [Object] the value to memoize
161
+ # @return [Object]
44
162
  def memoize(key)
45
163
  return instance_variable_get("@#{key}") if instance_variable_defined?("@#{key}")
46
164