how_is 19.0.0 → 20.0.0

Sign up to get free protection for your applications and to get access to all the features.
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