gems_bond 1.0.4 → 1.1.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.
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