how_is 19.0.0 → 20.0.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 (48) hide show
  1. checksums.yaml +5 -5
  2. data/.codeclimate.yml +1 -0
  3. data/.rubocop.yml +16 -0
  4. data/CHANGELOG.md +30 -3
  5. data/Gemfile +0 -6
  6. data/README.md +5 -2
  7. data/exe/how_is +7 -2
  8. data/fixtures/vcr_cassettes/how-is-example-empty-repository.yml +446 -727
  9. data/fixtures/vcr_cassettes/how-is-example-repository.yml +508 -727
  10. data/fixtures/vcr_cassettes/how-is-from-config-frontmatter.yml +43522 -1218
  11. data/fixtures/vcr_cassettes/how-is-how-is-travis-api-repos-builds.yml +287 -0
  12. data/fixtures/vcr_cassettes/how-is-with-config-file.yml +44307 -23286
  13. data/fixtures/vcr_cassettes/how_is_contributions_additions_count.yml +307 -0
  14. data/fixtures/vcr_cassettes/how_is_contributions_all_contributors.yml +81 -0
  15. data/fixtures/vcr_cassettes/how_is_contributions_changed_files.yml +307 -0
  16. data/fixtures/vcr_cassettes/how_is_contributions_changes.yml +307 -0
  17. data/fixtures/vcr_cassettes/how_is_contributions_commits.yml +307 -0
  18. data/fixtures/vcr_cassettes/how_is_contributions_compare_url.yml +74 -0
  19. data/fixtures/vcr_cassettes/how_is_contributions_deletions_count.yml +307 -0
  20. data/fixtures/vcr_cassettes/how_is_contributions_new_contributors.yml +234 -0
  21. data/fixtures/vcr_cassettes/how_is_contributions_summary.yml +378 -0
  22. data/fixtures/vcr_cassettes/how_is_contributions_summary_2.yml +378 -0
  23. data/fixtures/vcr_cassettes/how_is_fetcher_call.yml +572 -0
  24. data/how_is.gemspec +3 -2
  25. data/lib/how_is/cli.rb +4 -6
  26. data/lib/how_is/frontmatter.rb +46 -0
  27. data/lib/how_is/report.rb +59 -63
  28. data/lib/how_is/sources/github/contributions.rb +164 -0
  29. data/lib/how_is/sources/github/issues.rb +142 -0
  30. data/lib/how_is/sources/github/pulls.rb +20 -0
  31. data/lib/how_is/sources/github.rb +11 -0
  32. data/lib/how_is/sources/github_helpers.rb +191 -0
  33. data/lib/how_is/sources/travis.rb +34 -0
  34. data/lib/how_is/sources.rb +9 -0
  35. data/lib/how_is/templates/issues_or_pulls_partial.html_template +7 -0
  36. data/lib/how_is/templates/report.html_template +27 -0
  37. data/lib/how_is/templates/report_partial.html_template +12 -0
  38. data/lib/how_is/version.rb +2 -2
  39. data/lib/how_is.rb +59 -158
  40. metadata +42 -16
  41. data/lib/how_is/analyzer.rb +0 -222
  42. data/lib/how_is/builds.rb +0 -36
  43. data/lib/how_is/contributions.rb +0 -156
  44. data/lib/how_is/fetcher.rb +0 -77
  45. data/lib/how_is/pulse.rb +0 -47
  46. data/lib/how_is/report/base_report.rb +0 -148
  47. data/lib/how_is/report/html.rb +0 -120
  48. data/lib/how_is/report/json.rb +0 -34
data/how_is.gemspec CHANGED
@@ -19,10 +19,10 @@ Gem::Specification.new do |spec|
19
19
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
20
  spec.require_paths = ["lib"]
21
21
 
22
- spec.add_runtime_dependency "github_api", "~> 0.17.0"
22
+ spec.add_runtime_dependency "github_api", "~> 0.18.1"
23
23
  spec.add_runtime_dependency "contracts", "~> 0.16.0"
24
24
 
25
- spec.add_runtime_dependency "tessellator-fetcher", "~> 5.0.2"
25
+ spec.add_runtime_dependency "okay", "~> 4.0.0"
26
26
 
27
27
  spec.add_development_dependency "bundler", "~> 1.11"
28
28
  spec.add_development_dependency "rake", "~> 11.2"
@@ -32,4 +32,5 @@ Gem::Specification.new do |spec|
32
32
  spec.add_development_dependency "webmock"
33
33
  spec.add_development_dependency "rubocop", "~> 0.49.1"
34
34
  spec.add_development_dependency "github_changelog_generator"
35
+ spec.add_development_dependency "pry"
35
36
  end
data/lib/how_is/cli.rb CHANGED
@@ -3,9 +3,7 @@
3
3
  require "how_is"
4
4
  require "optparse"
5
5
 
6
- class HowIs::CLI
7
- DEFAULT_REPORT_FILE = "report.#{HowIs::DEFAULT_FORMAT}".freeze
8
-
6
+ module HowIs::CLI
9
7
  # Parent class of all exceptions raised in HowIs::CLI.
10
8
  class OptionsError < StandardError
11
9
  end
@@ -67,7 +65,7 @@ class HowIs::CLI
67
65
  end
68
66
 
69
67
  opts.on("--report REPORT_FILE",
70
- "Output file for the report (valid extensions: #{HowIs.supported_formats.join(', ')}; default: #{DEFAULT_REPORT_FILE})") do |filename|
68
+ "Output file for the report (valid extensions: #{HowIs.supported_formats.join(', ')}; default: #{HowIs::DEFAULT_REPORT_FILE})") do |filename|
71
69
  options[:report] = filename
72
70
  end
73
71
 
@@ -115,8 +113,8 @@ class HowIs::CLI
115
113
  # If we get here, we're generating a report from the command line,
116
114
  # without using --from or --config.
117
115
 
118
- # If --report isn't specified, default to DEFAULT_REPORT_FILE.
119
- options[:report] ||= DEFAULT_REPORT_FILE
116
+ # If --report isn't specified, default to HowIs::DEFAULT_REPORT_FILE.
117
+ options[:report] ||= HowIs::DEFAULT_REPORT_FILE
120
118
 
121
119
  # If we can't export to the specified file, raise an exception.
122
120
  unless HowIs.can_export_to?(options[:report])
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "how_is/version"
4
+
5
+ module HowIs
6
+ module Frontmatter
7
+ # Generates YAML frontmatter, as is used in Jekyll and other blog engines.
8
+ #
9
+ # E.g.,
10
+ # generate_frontmatter({'foo' => "bar %{baz}"}, {'baz' => "asdf"})
11
+ # => "---\nfoo: bar asdf\n"
12
+ def self.generate(frontmatter, report_data)
13
+ return "" if frontmatter.nil?
14
+
15
+ frontmatter = convert_keys(frontmatter, :to_s)
16
+ report_data = convert_keys(report_data, :to_sym)
17
+
18
+ frontmatter = frontmatter.map { |k, v|
19
+ # Sometimes report_data has unused keys, which generates a warning, but
20
+ # we're okay with it.
21
+ v = silence_warnings { v % report_data }
22
+
23
+ [k, v]
24
+ }.to_h
25
+
26
+ YAML.dump(frontmatter) + "---\n\n"
27
+ end
28
+
29
+ # @example
30
+ # convert_keys({'foo' => 'bar'}, :to_sym)
31
+ # # => {:foo => 'bar'}
32
+ def self.convert_keys(data, method_name)
33
+ data.map { |k, v| [k.send(method_name), v] }.to_h
34
+ end
35
+ private_class_method :convert_keys
36
+
37
+ def self.silence_warnings(&_block)
38
+ old_verbose = $VERBOSE
39
+ $VERBOSE = nil # Disable warnings entirely.
40
+ yield
41
+ ensure
42
+ $VERBOSE = old_verbose
43
+ end
44
+ private_class_method :silence_warnings
45
+ end
46
+ end
data/lib/how_is/report.rb CHANGED
@@ -1,82 +1,78 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "date"
4
- require "pathname"
5
-
6
- class HowIs
7
- # Raised when attempting to export to an unsupported format
8
- class UnsupportedExportFormat < StandardError
9
- def initialize(format)
10
- super("Unsupported export format: #{format}")
11
- end
12
- end
3
+ require "how_is/frontmatter"
4
+ require "how_is/sources/github/contributions"
5
+ require "how_is/sources/github/issues"
6
+ require "how_is/sources/github/pulls"
7
+ require "how_is/sources/travis"
13
8
 
14
- # Report control class with class methods to make reports for an analysis
15
- # or to save reports in files, or otherwise interact with the files.
9
+ module HowIs
16
10
  class Report
17
- require "how_is/report/json"
18
- require "how_is/report/html"
11
+ def initialize(repository, end_date)
12
+ @repository = repository
13
+ @end_date = end_date
19
14
 
20
- ##
21
- # Export a report to a file.
22
- def self.export_file(analysis, file)
23
- format = file.split(".").last
24
- report = get_report_class(format).new(analysis)
25
-
26
- report.export_file(file)
15
+ @gh_contributions = HowIs::Sources::Github::Contributions.new(repository, end_date)
16
+ @gh_issues = HowIs::Sources::Github::Issues.new(repository, end_date)
17
+ @gh_pulls = HowIs::Sources::Github::Pulls.new(repository, end_date)
18
+ @travis = HowIs::Sources::Travis.new(repository, end_date)
27
19
  end
28
20
 
29
- ##
30
- # Export a report to a String.
31
- def self.export(analysis, format = HowIs::DEFAULT_FORMAT)
32
- report = get_report_class(format).new(analysis)
21
+ def to_h(frontmatter_data = nil)
22
+ @report_hash ||= {
23
+ title: "How is #{@repository}?",
24
+ repository: @repository,
33
25
 
34
- report.export
35
- end
26
+ contributions_summary: @gh_contributions.to_html,
27
+ issues_summary: @gh_issues.to_html,
28
+ pulls_summary: @gh_pulls.to_html,
29
+ issues_per_label: @gh_issues.issues_per_label_html,
36
30
 
37
- ##
38
- # Saves given Report in given file.
39
- #
40
- # @param file [String,Pathname] Name of file to write to
41
- # @param report [Report] Report to store
42
- def self.save_report(file, report)
43
- File.open(file, "w") do |f|
44
- f.write report
45
- end
46
- end
31
+ issues: @gh_issues.to_a,
32
+ pulls: @gh_issues.to_a,
33
+
34
+ number_of_issues: @gh_issues.to_a.length,
35
+ number_of_pulls: @gh_pulls.to_a.length,
36
+
37
+ average_issue_age: @gh_issues.average_age,
38
+ average_pull_age: @gh_pulls.average_age,
47
39
 
48
- ##
49
- # Returns the report format for given filename.
50
- #
51
- # @param file [String] Filename of a report
52
- #
53
- # @return [String] Report format inferred from file name
54
- def self.infer_format(file)
55
- Pathname(file).extname.delete(".")
40
+ oldest_issue_link: @gh_issues.oldest[:link],
41
+ oldest_issue_date: @gh_issues.oldest[:creation_date],
42
+
43
+ newest_issue_link: @gh_issues.newest[:link],
44
+ newest_issue_date: @gh_issues.newest[:creation_date],
45
+
46
+ oldest_pull_link: @gh_pulls.oldest[:link],
47
+ oldest_pull_date: @gh_pulls.oldest[:creation_date],
48
+
49
+ travis_builds: @travis.builds.to_h,
50
+ }
51
+
52
+ frontmatter =
53
+ if frontmatter_data
54
+ HowIs::Frontmatter.generate(frontmatter_data, @report_hash)
55
+ else
56
+ ""
57
+ end
58
+
59
+ @report_hash.merge(frontmatter: frontmatter)
56
60
  end
57
61
 
58
- ##
59
- # Exports given +report+ to the format suitable for given +file+.
60
- #
61
- # @param file [String,Pathname]
62
- # @param report [Report]
63
- #
64
- # @return [String] The rendered report
65
- def self.to_format_based_on(file, report)
66
- report_format = infer_format(file)
67
-
68
- report.public_send("to_#{report_format}")
62
+ def to_html_partial(frontmatter = nil)
63
+ template_data = to_h(frontmatter)
64
+
65
+ Kernel.format(HowIs.template("report_partial.html_template"), template_data)
69
66
  end
70
67
 
71
- # Given a format name (+format+), returns the corresponding <blah>Report
72
- # class.
73
- def self.get_report_class(format)
74
- class_name = "#{format.capitalize}Report"
68
+ def to_html(frontmatter = nil)
69
+ template_data = to_h(frontmatter).merge({report: to_html_partial})
75
70
 
76
- raise UnsupportedExportFormat, format unless HowIs.const_defined?(class_name)
71
+ Kernel.format(HowIs.template("report.html_template"), template_data)
72
+ end
77
73
 
78
- HowIs.const_get(class_name)
74
+ def to_json(frontmatter = nil)
75
+ frontmatter.to_s + JSON.pretty_generate(to_h)
79
76
  end
80
- private_class_method :get_report_class
81
77
  end
82
78
  end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "how_is/version"
4
+ require "how_is/sources/github"
5
+ require "how_is/sources/github_helpers"
6
+ require "date"
7
+
8
+ module HowIs::Sources
9
+ class Github
10
+ # Fetch information about who has contributed to a repository during a given
11
+ # period.
12
+ #
13
+ # Usage:
14
+ #
15
+ # c = HowIs::Contributions.new(start_date: '2017-07-01', user: 'how-is', repo: 'how_is')
16
+ # c.commits #=> All commits during July 2017.
17
+ # c.contributors #=> All contributors during July 2017.
18
+ # c.new_contributors #=> New contributors during July 2017.
19
+ class Contributions
20
+ include HowIs::Sources::GithubHelpers
21
+
22
+ # Returns an object that fetches contributor information about a particular
23
+ # repository for a month-long period starting on +start_date+.
24
+ #
25
+ # @param repository [String] GitHub repository in the form of "user/repo".
26
+ # @param end_date [String] Date in the format YYYY-MM-DD. The last date
27
+ # to include commits from.
28
+ def initialize(repository, end_date)
29
+ @user, @repo = repository.split("/")
30
+ @github = HowIs.github
31
+
32
+ # IMPL. DETAIL: The external API uses "end_date" so it's clearer,
33
+ # but internally we use "until_date" to match GitHub's API.
34
+
35
+ # NOTE: Use DateTime because it defaults to UTC and that's less gross
36
+ # than trying to get Date to use UTC.
37
+ #
38
+ # Not using UTC for this results in #compare_url giving different
39
+ # results for different time zones, which makes it harder to test.
40
+ #
41
+ # (I'm also guessing/hoping that GitHub's URLs use UTC.)
42
+ @until_date = DateTime.strptime(end_date, "%Y-%m-%d")
43
+
44
+ d = @until_date.day
45
+ m = @until_date.month
46
+ y = @until_date.year
47
+ @since_date = DateTime.new(y, m - 1, d)
48
+
49
+ @commit = {}
50
+ @stats = nil
51
+ @changed_files = nil
52
+ end
53
+
54
+ # Returns a list of contributors that have zero commits before the @since_date.
55
+ #
56
+ # @return [Hash{String => Hash}] Contributors keyed by email
57
+ def new_contributors
58
+ # author: GitHub login, name or email by which to filter by commit author.
59
+ @new_contributors ||= contributors.select do |email, _committer|
60
+ # Returns true if +email+ never wrote a commit for +@repo+ before +@since_date+.
61
+ @github.repos.commits.list(user: @user,
62
+ repo: @repo,
63
+ until: @since_date,
64
+ author: email).count.zero?
65
+ end
66
+ end
67
+
68
+ # @return [Hash{String => Hash}] Author information keyed by author's email.
69
+ def contributors
70
+ commits.map { |api_response|
71
+ [api_response.commit.author.email, api_response.commit.author.to_h]
72
+ }.to_h
73
+ end
74
+
75
+ def commits
76
+ @commits ||= begin
77
+ @github.repos.commits.list(user: @user,
78
+ repo: @repo,
79
+ since: @since_date).map { |c|
80
+ # The commits list endpoint doesn't include all commit data, e.g. stats.
81
+ # So, we make N requests here, where N == number of commits returned,
82
+ # and then we die a bit inside.
83
+ commit(c.sha)
84
+ }
85
+ end
86
+ end
87
+
88
+ def commit(sha)
89
+ @commit[sha] ||= @github.repos.commits.get(user: @user, repo: @repo, sha: sha)
90
+ end
91
+
92
+ def changes
93
+ if @stats.nil? || @changed_files.nil?
94
+ @stats = {
95
+ "total" => 0,
96
+ "additions" => 0,
97
+ "deletions" => 0,
98
+ }
99
+
100
+ @changed_files = []
101
+
102
+ commits.map do |commit|
103
+ @stats.keys.each do |key|
104
+ @stats[key] += commit.stats[key]
105
+ end
106
+
107
+ @changed_files += commit.files.map { |file| file["filename"] }
108
+ end
109
+
110
+ @changed_files = @changed_files.sort.uniq
111
+ end
112
+
113
+ {"stats" => @stats, "files" => @changed_files}
114
+ end
115
+
116
+ def changed_files
117
+ changes["files"]
118
+ end
119
+
120
+ def additions_count
121
+ changes["stats"]["additions"]
122
+ end
123
+
124
+ def deletions_count
125
+ changes["stats"]["deletions"]
126
+ end
127
+
128
+ def compare_url
129
+ since_timestamp = @since_date.to_time.to_i
130
+ until_timestamp = @until_date.to_time.to_i
131
+ "https://github.com/#{@user}/#{@repo}/compare/#{default_branch}@%7B#{since_timestamp}%7D...#{default_branch}@%7B#{until_timestamp}%7D" # rubocop:disable Metrics/LineLength
132
+ end
133
+
134
+ def default_branch
135
+ @default_branch ||= @github.repos.get(user: @user,
136
+ repo: @repo).default_branch
137
+ end
138
+
139
+ def to_html(start_text: nil)
140
+ # TODO: Pulse has information about _all_ branches. Do we want that?
141
+ # If we do, we'd need to pass a branch name as the 'sha' parameter
142
+ # to /repos/:owner/:repo/commits.
143
+ # https://developer.github.com/v3/repos/commits/
144
+
145
+ start_text ||= "From #{pretty_date(@since_date)} through #{pretty_date(@until_date)}"
146
+
147
+ "#{start_text}, #{@user}/#{@repo} gained "\
148
+ "<a href=\"#{compare_url}\">#{pluralize('new commit', commits.length)}</a>, " \
149
+ "contributed by #{pluralize('author', contributors.length)}. There " \
150
+ "#{(additions_count == 1) ? 'was' : 'were'} " \
151
+ "#{pluralize('addition', additions_count)} and " \
152
+ "#{pluralize('deletion', deletions_count)} across " \
153
+ "#{pluralize('file', changed_files.length)}."
154
+ end
155
+ alias :summary :to_html # For backwards compatibility.
156
+
157
+ private
158
+
159
+ def pretty_date(date)
160
+ date.strftime("%b %d, %Y")
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "how_is/version"
4
+ require "how_is/sources/github"
5
+ require "how_is/sources/github_helpers"
6
+ require "date"
7
+
8
+ module HowIs::Sources
9
+ class Github
10
+ class Issues
11
+ include HowIs::Sources::GithubHelpers
12
+
13
+ def initialize(repository, end_date)
14
+ @repository = repository
15
+ @user, @repo = repository.split("/", 2)
16
+ end
17
+
18
+ def url
19
+ "https://github.com/#{@repository}/#{type}"
20
+ end
21
+
22
+ def average_age
23
+ average_age_for(@data)
24
+ end
25
+
26
+ def oldest
27
+ fetch!
28
+ oldest_for(@data) || {}
29
+ end
30
+
31
+ def newest
32
+ fetch!
33
+ newest_for(@data) || {}
34
+ end
35
+
36
+ def summary
37
+ number_open = to_a.length
38
+ pretty_number =
39
+ pluralize(pretty_type, number_open, zero_is_no: true)
40
+
41
+ "There #{are_or_is(number_open)} <a href=\"#{url}\">#{pretty_number} open</a>."
42
+ end
43
+
44
+ def to_html
45
+ fetch!
46
+
47
+ summary_ = "<p>#{summary}</p>"
48
+
49
+ return summary_ if to_a.empty?
50
+
51
+ template_data = {
52
+ summary: summary_,
53
+ average_age: average_age,
54
+ type: type,
55
+ pretty_type: pretty_type,
56
+
57
+ oldest_link: oldest[:link],
58
+ oldest_date: pretty_date(oldest[:created_at]),
59
+
60
+ newest_link: newest[:link],
61
+ newest_date: pretty_date(newest[:created_at]),
62
+ }
63
+
64
+ Kernel.format(HowIs.template("issues_or_pulls_partial.html_template"), template_data)
65
+ end
66
+
67
+ # TODO: Clean up Issues Per Label stuff, or replace it with different functionality.
68
+
69
+ def issues_per_label
70
+ ipl = with_label_links(num_with_label(@data), @repository)
71
+ number_with_no_label = num_with_no_label(@data)
72
+
73
+ if number_with_no_label > 0
74
+ ipl["(No label)"] = {
75
+ "link" => nil,
76
+ "total" => number_with_no_label,
77
+ }
78
+ end
79
+
80
+ ipl
81
+ end
82
+
83
+ HTML_GRAPH_ROW = <<-EOF
84
+ <tr>
85
+ <td style="width: %{label_width}">%{label_text}</td>
86
+ <td><span class="fill" style="width: %{percentage}%%">%{link_text}</span></td>
87
+ </tr>
88
+ EOF
89
+
90
+ def issues_per_label_html
91
+ data = issues_per_label
92
+
93
+ return "<p>There are no open issues to graph.</p>" if data.empty?
94
+
95
+ biggest = data.map { |_label, info| info["total"] }.max
96
+ get_percentage = ->(number_of_issues) { number_of_issues * 100 / biggest }
97
+
98
+ longest_label_length = data.map(&:first).map(&:length).max
99
+ label_width = "#{longest_label_length}ch"
100
+
101
+ parts = data.map { |label, info|
102
+ # TODO: Remove this hack to get around unlabeled issues not having a link.
103
+ label_text = label
104
+ unless info["link"].nil?
105
+ label_text = '<a href="' + info["link"] + '">' + label_text + '</a>'
106
+ end
107
+
108
+ Kernel.format(HTML_GRAPH_ROW, {
109
+ label_width: label_width,
110
+ label_text: label_text,
111
+ label_link: info["link"],
112
+ percentage: get_percentage.call(info["total"]),
113
+ link_text: info["total"].to_s,
114
+ })
115
+ }
116
+
117
+ "<table class=\"horizontal-bar-graph\">\n" +
118
+ parts.join("\n") +
119
+ "\n</table>"
120
+ end
121
+
122
+ def to_a
123
+ fetch!
124
+ obj_to_array_of_hashes(@data)
125
+ end
126
+
127
+ private
128
+
129
+ def type
130
+ "issues"
131
+ end
132
+
133
+ def pretty_type
134
+ "issue"
135
+ end
136
+
137
+ def fetch!
138
+ @data ||= HowIs.github.send(type).list(user: @user, repo: @repo)
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "how_is/version"
4
+ require "how_is/sources/github"
5
+ require "how_is/sources/github_helpers"
6
+ require "date"
7
+
8
+ module HowIs::Sources
9
+ class Github
10
+ class Pulls < Issues
11
+ def type
12
+ "pulls"
13
+ end
14
+
15
+ def pretty_type
16
+ "pull request"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "how_is/sources"
4
+
5
+ module HowIs
6
+ module Sources
7
+ class Github
8
+ # ...
9
+ end
10
+ end
11
+ end