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.
- checksums.yaml +5 -5
- data/.codeclimate.yml +1 -0
- data/.rubocop.yml +16 -0
- data/CHANGELOG.md +30 -3
- data/Gemfile +0 -6
- data/README.md +5 -2
- data/exe/how_is +7 -2
- data/fixtures/vcr_cassettes/how-is-example-empty-repository.yml +446 -727
- data/fixtures/vcr_cassettes/how-is-example-repository.yml +508 -727
- data/fixtures/vcr_cassettes/how-is-from-config-frontmatter.yml +43522 -1218
- data/fixtures/vcr_cassettes/how-is-how-is-travis-api-repos-builds.yml +287 -0
- data/fixtures/vcr_cassettes/how-is-with-config-file.yml +44307 -23286
- data/fixtures/vcr_cassettes/how_is_contributions_additions_count.yml +307 -0
- data/fixtures/vcr_cassettes/how_is_contributions_all_contributors.yml +81 -0
- data/fixtures/vcr_cassettes/how_is_contributions_changed_files.yml +307 -0
- data/fixtures/vcr_cassettes/how_is_contributions_changes.yml +307 -0
- data/fixtures/vcr_cassettes/how_is_contributions_commits.yml +307 -0
- data/fixtures/vcr_cassettes/how_is_contributions_compare_url.yml +74 -0
- data/fixtures/vcr_cassettes/how_is_contributions_deletions_count.yml +307 -0
- data/fixtures/vcr_cassettes/how_is_contributions_new_contributors.yml +234 -0
- data/fixtures/vcr_cassettes/how_is_contributions_summary.yml +378 -0
- data/fixtures/vcr_cassettes/how_is_contributions_summary_2.yml +378 -0
- data/fixtures/vcr_cassettes/how_is_fetcher_call.yml +572 -0
- data/how_is.gemspec +3 -2
- data/lib/how_is/cli.rb +4 -6
- data/lib/how_is/frontmatter.rb +46 -0
- data/lib/how_is/report.rb +59 -63
- data/lib/how_is/sources/github/contributions.rb +164 -0
- data/lib/how_is/sources/github/issues.rb +142 -0
- data/lib/how_is/sources/github/pulls.rb +20 -0
- data/lib/how_is/sources/github.rb +11 -0
- data/lib/how_is/sources/github_helpers.rb +191 -0
- data/lib/how_is/sources/travis.rb +34 -0
- data/lib/how_is/sources.rb +9 -0
- data/lib/how_is/templates/issues_or_pulls_partial.html_template +7 -0
- data/lib/how_is/templates/report.html_template +27 -0
- data/lib/how_is/templates/report_partial.html_template +12 -0
- data/lib/how_is/version.rb +2 -2
- data/lib/how_is.rb +59 -158
- metadata +42 -16
- data/lib/how_is/analyzer.rb +0 -222
- data/lib/how_is/builds.rb +0 -36
- data/lib/how_is/contributions.rb +0 -156
- data/lib/how_is/fetcher.rb +0 -77
- data/lib/how_is/pulse.rb +0 -47
- data/lib/how_is/report/base_report.rb +0 -148
- data/lib/how_is/report/html.rb +0 -120
- data/lib/how_is/report/json.rb +0 -34
data/lib/how_is/analyzer.rb
DELETED
@@ -1,222 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "contracts"
|
4
|
-
require "ostruct"
|
5
|
-
require "date"
|
6
|
-
require "json"
|
7
|
-
|
8
|
-
class HowIs
|
9
|
-
##
|
10
|
-
# Represents a completed analysis of the repository being analyzed.
|
11
|
-
class Analysis < OpenStruct
|
12
|
-
end
|
13
|
-
|
14
|
-
# Creates Analysis objects with input data formatted in useful ways.
|
15
|
-
class Analyzer
|
16
|
-
include Contracts::Core
|
17
|
-
|
18
|
-
##
|
19
|
-
# Raised when attempting to import to an unsupported format.
|
20
|
-
class UnsupportedImportFormat < StandardError
|
21
|
-
def initialize(format)
|
22
|
-
super("Unsupported import format: #{format}")
|
23
|
-
end
|
24
|
-
end
|
25
|
-
|
26
|
-
##
|
27
|
-
# Generates and returns an analysis.
|
28
|
-
#
|
29
|
-
# @param data [Fetcher::Results] The results gathered by Fetcher.
|
30
|
-
# @param analysis_class (You don't need this.) A class to use instead of
|
31
|
-
# HowIs::Analysis.
|
32
|
-
Contract Fetcher::Results, C::KeywordArgs[analysis_class: C::Optional[Class]] => Analysis
|
33
|
-
def call(data, analysis_class: Analysis)
|
34
|
-
issues = data.issues
|
35
|
-
pulls = data.pulls
|
36
|
-
|
37
|
-
analysis_class.new(
|
38
|
-
issues_url: "https://github.com/#{data.repository}/issues",
|
39
|
-
pulls_url: "https://github.com/#{data.repository}/pulls",
|
40
|
-
|
41
|
-
repository: data.repository,
|
42
|
-
|
43
|
-
number_of_issues: issues.length,
|
44
|
-
number_of_pulls: pulls.length,
|
45
|
-
|
46
|
-
issues_with_label: with_label_links(num_with_label(issues), data.repository),
|
47
|
-
issues_with_no_label: {"link" => nil, "total" => num_with_no_label(issues)},
|
48
|
-
|
49
|
-
average_issue_age: average_age_for(issues),
|
50
|
-
average_pull_age: average_age_for(pulls),
|
51
|
-
|
52
|
-
oldest_issue: issue_or_pull_to_hash(oldest_for(issues)),
|
53
|
-
oldest_pull: issue_or_pull_to_hash(oldest_for(pulls)),
|
54
|
-
|
55
|
-
newest_issue: issue_or_pull_to_hash(newest_for(issues)),
|
56
|
-
newest_pull: issue_or_pull_to_hash(newest_for(pulls)),
|
57
|
-
|
58
|
-
pulse: data.pulse
|
59
|
-
)
|
60
|
-
end
|
61
|
-
|
62
|
-
##
|
63
|
-
# Generates an analysis from a hash of report data.
|
64
|
-
#
|
65
|
-
# @param data [Hash] The hash to generate an Analysis from.
|
66
|
-
def self.from_hash(data)
|
67
|
-
hash = data.map { |k, v|
|
68
|
-
v = DateTime.parse(v) if k.end_with?("_date")
|
69
|
-
|
70
|
-
[k, v]
|
71
|
-
}.to_h
|
72
|
-
|
73
|
-
hash.keys.each do |key|
|
74
|
-
next unless hash[key].is_a?(Hash) && hash[key]["date"]
|
75
|
-
|
76
|
-
hash[key]["date"] = DateTime.parse(hash[key]["date"])
|
77
|
-
end
|
78
|
-
|
79
|
-
Analysis.new(hash)
|
80
|
-
end
|
81
|
-
|
82
|
-
# Given an Array of issues or pulls, return a Hash specifying how many
|
83
|
-
# issues or pulls use each label.
|
84
|
-
def num_with_label(issues_or_pulls)
|
85
|
-
# Returned hash maps labels to frequency.
|
86
|
-
# E.g., given 10 issues/pulls with label "label1" and 5 with label "label2",
|
87
|
-
# {
|
88
|
-
# "label1" => 10,
|
89
|
-
# "label2" => 5
|
90
|
-
# }
|
91
|
-
|
92
|
-
hash = Hash.new(0)
|
93
|
-
issues_or_pulls.each do |iop|
|
94
|
-
next unless iop["labels"]
|
95
|
-
|
96
|
-
iop["labels"].each do |label|
|
97
|
-
hash[label["name"]] += 1
|
98
|
-
end
|
99
|
-
end
|
100
|
-
hash
|
101
|
-
end
|
102
|
-
|
103
|
-
# Returns the number of issues with no label.
|
104
|
-
def num_with_no_label(issues)
|
105
|
-
issues.select { |x| x["labels"].empty? }.length
|
106
|
-
end
|
107
|
-
|
108
|
-
# Given an Array of dates, average the timestamps and return the date that
|
109
|
-
# represents.
|
110
|
-
def average_date_for(issues_or_pulls)
|
111
|
-
timestamps = issues_or_pulls.map { |iop| Date.parse(iop["created_at"]).strftime("%s").to_i }
|
112
|
-
average_timestamp = timestamps.reduce(:+) / issues_or_pulls.length
|
113
|
-
|
114
|
-
DateTime.strptime(average_timestamp.to_s, "%s")
|
115
|
-
end
|
116
|
-
|
117
|
-
# Given an Array of issues or pulls, return the average age of them.
|
118
|
-
# Returns nil if no issues or pulls are provided.
|
119
|
-
def average_age_for(issues_or_pulls)
|
120
|
-
return nil if issues_or_pulls.empty?
|
121
|
-
|
122
|
-
ages = issues_or_pulls.map { |iop| time_ago_in_seconds(iop["created_at"]) }
|
123
|
-
average_age_in_seconds = ages.reduce(:+) / ages.length
|
124
|
-
|
125
|
-
values = period_pairs_for(average_age_in_seconds).reject { |(v, _)| v.zero? }.map { |(v, k)|
|
126
|
-
k += "s" if v != 1
|
127
|
-
[v, k]
|
128
|
-
}
|
129
|
-
|
130
|
-
most_significant = values[0, 2].map { |x| x.join(" ") }
|
131
|
-
|
132
|
-
value =
|
133
|
-
if most_significant.length < 2
|
134
|
-
most_significant.first
|
135
|
-
else
|
136
|
-
most_significant.join(" and ")
|
137
|
-
end
|
138
|
-
|
139
|
-
"approximately #{value}"
|
140
|
-
end
|
141
|
-
|
142
|
-
def sort_iops_by_created_at(issues_or_pulls)
|
143
|
-
issues_or_pulls.sort_by { |x| DateTime.parse(x["created_at"]) }
|
144
|
-
end
|
145
|
-
|
146
|
-
# Given an Array of issues or pulls, return the oldest.
|
147
|
-
# Returns nil if no issues or pulls are provided.
|
148
|
-
def oldest_for(issues_or_pulls)
|
149
|
-
return nil if issues_or_pulls.empty?
|
150
|
-
|
151
|
-
sort_iops_by_created_at(issues_or_pulls).first
|
152
|
-
end
|
153
|
-
|
154
|
-
# Given an Array of issues or pulls, return the newest.
|
155
|
-
# Returns nil if no issues or pulls are provided.
|
156
|
-
def newest_for(issues_or_pulls)
|
157
|
-
return nil if issues_or_pulls.empty?
|
158
|
-
|
159
|
-
sort_iops_by_created_at(issues_or_pulls).last
|
160
|
-
end
|
161
|
-
|
162
|
-
# Given an issue or PR, returns the date it was created.
|
163
|
-
def date_for(issue_or_pull)
|
164
|
-
DateTime.parse(issue_or_pull["created_at"])
|
165
|
-
end
|
166
|
-
|
167
|
-
private
|
168
|
-
|
169
|
-
# Takes an Array of labels, and returns amodified list that includes links
|
170
|
-
# to each label.
|
171
|
-
def with_label_links(labels, repository)
|
172
|
-
labels.map { |label, num_issues|
|
173
|
-
label_link = "https://github.com/#{repository}/issues?q=" + CGI.escape("is:open is:issue label:\"#{label}\"")
|
174
|
-
|
175
|
-
[label, {"link" => label_link, "total" => num_issues}]
|
176
|
-
}.to_h
|
177
|
-
end
|
178
|
-
|
179
|
-
# Returns how many seconds ago a date (as a String) was.
|
180
|
-
def time_ago_in_seconds(x)
|
181
|
-
DateTime.now.strftime("%s").to_i - DateTime.parse(x).strftime("%s").to_i
|
182
|
-
end
|
183
|
-
|
184
|
-
def issue_or_pull_to_hash(iop)
|
185
|
-
return nil if iop.nil?
|
186
|
-
|
187
|
-
ret = {}
|
188
|
-
|
189
|
-
ret["html_url"] = iop["html_url"]
|
190
|
-
ret["number"] = iop["number"]
|
191
|
-
ret["date"] = date_for(iop)
|
192
|
-
|
193
|
-
ret
|
194
|
-
end
|
195
|
-
|
196
|
-
SECONDS_IN_A_YEAR = 31_556_926
|
197
|
-
SECONDS_IN_A_MONTH = 2_629_743
|
198
|
-
SECONDS_IN_A_WEEK = 604_800
|
199
|
-
SECONDS_IN_A_DAY = 86_400
|
200
|
-
|
201
|
-
# Calculates a list of pairs of value and period label.
|
202
|
-
#
|
203
|
-
# @param age_in_seconds [Float]
|
204
|
-
#
|
205
|
-
# @return [Array<Array>] The input age_in_seconds expressed as different
|
206
|
-
# units, as pairs of value and unit name.
|
207
|
-
def period_pairs_for(age_in_seconds)
|
208
|
-
years_remainder = age_in_seconds % SECONDS_IN_A_YEAR
|
209
|
-
|
210
|
-
months_remainder = years_remainder % SECONDS_IN_A_MONTH
|
211
|
-
|
212
|
-
weeks_remainder = months_remainder % SECONDS_IN_A_WEEK
|
213
|
-
|
214
|
-
[
|
215
|
-
[age_in_seconds / SECONDS_IN_A_YEAR, "year"],
|
216
|
-
[years_remainder / SECONDS_IN_A_MONTH, "month"],
|
217
|
-
[months_remainder / SECONDS_IN_A_WEEK, "week"],
|
218
|
-
[weeks_remainder / SECONDS_IN_A_DAY, "day"],
|
219
|
-
]
|
220
|
-
end
|
221
|
-
end
|
222
|
-
end
|
data/lib/how_is/builds.rb
DELETED
@@ -1,36 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "how_is/fetcher"
|
4
|
-
|
5
|
-
class HowIs
|
6
|
-
# Fetches metadata about CI builds.
|
7
|
-
#
|
8
|
-
# Supports Travis
|
9
|
-
class Builds
|
10
|
-
# @param user [String] GitHub user of repository.
|
11
|
-
# @param repo [String] GitHub repository name.
|
12
|
-
def initialize(user:, repo:)
|
13
|
-
@user = user
|
14
|
-
@repo = repo
|
15
|
-
# TODO: Figure out Default Branch of the repo
|
16
|
-
end
|
17
|
-
|
18
|
-
def summary
|
19
|
-
JSON.parse(travis_builds)
|
20
|
-
end
|
21
|
-
|
22
|
-
# Returns API result of /repos/:user/:repo/builds for Push type Travis
|
23
|
-
# events.
|
24
|
-
#
|
25
|
-
# @return [String] JSON result
|
26
|
-
def travis_builds
|
27
|
-
Tessellator::Fetcher::Request::HTTP.call(
|
28
|
-
Tessellator::Fetcher::Config.new,
|
29
|
-
"get",
|
30
|
-
"http://api.travis-ci.org/repos/#{@user}/#{@repo}/builds?event_type=push",
|
31
|
-
{},
|
32
|
-
headers: {"Accept" => "application/vnd.travis-ci.2+json"}
|
33
|
-
).body
|
34
|
-
end
|
35
|
-
end
|
36
|
-
end
|
data/lib/how_is/contributions.rb
DELETED
@@ -1,156 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "how_is/fetcher"
|
4
|
-
|
5
|
-
class HowIs
|
6
|
-
# Fetch information about who has contributed to a repository during a given
|
7
|
-
# period.
|
8
|
-
#
|
9
|
-
# Usage:
|
10
|
-
#
|
11
|
-
# github = Github.new()
|
12
|
-
# c = HowIs::Contributions.new(github: github, start_date: '2017-07-01', user: 'how-is', repo: 'how_is')
|
13
|
-
# c.commits #=> All commits during July 2017.
|
14
|
-
# c.contributors #=> All contributors during July 2017.
|
15
|
-
# c.new_contributors #=> New contributors during July 2017.
|
16
|
-
class Contributions
|
17
|
-
# Returns an object that fetches contributor information about a particular
|
18
|
-
# repository for a month-long period starting on +start_date+.
|
19
|
-
#
|
20
|
-
# @param github [Github] Github client instance.
|
21
|
-
# @param start_date [String] Date in the format YYYY-MM-DD. The first date
|
22
|
-
# to include commits from.
|
23
|
-
# @param user [String] GitHub user of repository.
|
24
|
-
# @param repo [String] GitHub repository name.
|
25
|
-
def initialize(github: Fetcher.default_github_instance, start_date:, user:, repo:)
|
26
|
-
@github = github
|
27
|
-
|
28
|
-
# IMPL. DETAIL: The external API uses "start_date" so it's clearer,
|
29
|
-
# but internally we use "since_date" to match GitHub's API.
|
30
|
-
|
31
|
-
@since_date = Date.strptime(start_date, "%Y-%m-%d")
|
32
|
-
|
33
|
-
d = @since_date.day
|
34
|
-
m = @since_date.month
|
35
|
-
y = @since_date.year
|
36
|
-
@until_date = Date.new(y, m + 1, d)
|
37
|
-
|
38
|
-
@user = user
|
39
|
-
@repo = repo
|
40
|
-
|
41
|
-
@commit = {}
|
42
|
-
end
|
43
|
-
|
44
|
-
# Returns a list of contributors that have zero commits before the @since_date.
|
45
|
-
#
|
46
|
-
# @return [Hash{String => Hash}] Contributors keyed by email
|
47
|
-
def new_contributors
|
48
|
-
# author: GitHub login, name or email by which to filter by commit author.
|
49
|
-
@new_contributors ||= contributors.select do |email, _committer|
|
50
|
-
# Returns true if +email+ never wrote a commit for +@repo+ before +@since_date+.
|
51
|
-
@github.repos.commits.list(user: @user,
|
52
|
-
repo: @repo,
|
53
|
-
until: @since_date,
|
54
|
-
author: email).count.zero?
|
55
|
-
end
|
56
|
-
end
|
57
|
-
|
58
|
-
# @return [Hash{String => Hash}] Author information keyed by author's email.
|
59
|
-
def contributors
|
60
|
-
commits.map { |api_response|
|
61
|
-
[api_response.commit.author.email, api_response.commit.author.to_h]
|
62
|
-
}.to_h
|
63
|
-
end
|
64
|
-
|
65
|
-
def commits
|
66
|
-
@commits ||= begin
|
67
|
-
@github.repos.commits.list(user: @user,
|
68
|
-
repo: @repo,
|
69
|
-
since: @since_date).map { |c|
|
70
|
-
# The commits list endpoint doesn't include all commit data, e.g. stats.
|
71
|
-
# So, we make N requests here, where N == number of commits returned,
|
72
|
-
# and then we die a bit inside.
|
73
|
-
commit(c.sha)
|
74
|
-
}
|
75
|
-
end
|
76
|
-
end
|
77
|
-
|
78
|
-
def commit(sha)
|
79
|
-
@commit[sha] ||= @github.repos.commits.get(user: @user, repo: @repo, sha: sha)
|
80
|
-
end
|
81
|
-
|
82
|
-
def changes
|
83
|
-
if @stats.nil? || @changed_files.nil?
|
84
|
-
@stats = {
|
85
|
-
"total" => 0,
|
86
|
-
"additions" => 0,
|
87
|
-
"deletions" => 0,
|
88
|
-
}
|
89
|
-
|
90
|
-
@changed_files = []
|
91
|
-
|
92
|
-
commits.map do |commit|
|
93
|
-
commit.stats.each do |k, v|
|
94
|
-
@stats[k] += v
|
95
|
-
end
|
96
|
-
|
97
|
-
@changed_files += commit.files.map { |file| file["filename"] }
|
98
|
-
end
|
99
|
-
|
100
|
-
@changed_files.sort.uniq!
|
101
|
-
end
|
102
|
-
|
103
|
-
{"stats" => @stats, "files" => @changed_files}
|
104
|
-
end
|
105
|
-
|
106
|
-
def changed_files
|
107
|
-
changes["files"]
|
108
|
-
end
|
109
|
-
|
110
|
-
def additions_count
|
111
|
-
changes["stats"]["additions"]
|
112
|
-
end
|
113
|
-
|
114
|
-
def deletions_count
|
115
|
-
changes["stats"]["deletions"]
|
116
|
-
end
|
117
|
-
|
118
|
-
def compare_url
|
119
|
-
since_timestamp = @since_date.to_time.to_i
|
120
|
-
until_timestamp = @until_date.to_time.to_i
|
121
|
-
"https://github.com/#{@user}/#{@repo}/compare/#{default_branch}@%7B#{since_timestamp}%7D...#{default_branch}@%7B#{until_timestamp}%7D" # rubocop:disable Metrics/LineLength
|
122
|
-
end
|
123
|
-
|
124
|
-
def default_branch
|
125
|
-
@default_branch ||= @github.repos.get(user: @user,
|
126
|
-
repo: @repo).default_branch
|
127
|
-
end
|
128
|
-
|
129
|
-
def summary(start_text: nil)
|
130
|
-
# TODO: Pulse has information about _all_ branches. Do we want that?
|
131
|
-
# If we do, we'd need to pass a branch name as the 'sha' parameter
|
132
|
-
# to /repos/:owner/:repo/commits.
|
133
|
-
# https://developer.github.com/v3/repos/commits/
|
134
|
-
|
135
|
-
start_text ||= "From #{pretty_date(@since_date)} through #{pretty_date(@until_date)}"
|
136
|
-
|
137
|
-
"#{start_text}, #{@user}/#{@repo} gained "\
|
138
|
-
"<a href=\"#{compare_url}\">#{pluralize('new commit', commits.length)}</a>, " \
|
139
|
-
"contributed by #{pluralize('author', contributors.length)}. There " \
|
140
|
-
"#{(additions_count == 1) ? 'was' : 'were'} " \
|
141
|
-
"#{pluralize('addition', additions_count)} and " \
|
142
|
-
"#{pluralize('deletion', deletions_count)} across " \
|
143
|
-
"#{pluralize('file', changed_files.length)}."
|
144
|
-
end
|
145
|
-
|
146
|
-
private
|
147
|
-
|
148
|
-
def pretty_date(date)
|
149
|
-
date.strftime("%b %d, %Y")
|
150
|
-
end
|
151
|
-
|
152
|
-
def pluralize(string, number)
|
153
|
-
"#{number} #{string}#{(number == 1) ? '' : 's'}"
|
154
|
-
end
|
155
|
-
end
|
156
|
-
end
|
data/lib/how_is/fetcher.rb
DELETED
@@ -1,77 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "contracts"
|
4
|
-
require "github_api"
|
5
|
-
require "how_is/pulse"
|
6
|
-
|
7
|
-
C ||= Contracts
|
8
|
-
|
9
|
-
class HowIs
|
10
|
-
##
|
11
|
-
# Fetches data from GitHub.
|
12
|
-
class Fetcher
|
13
|
-
include Contracts::Core
|
14
|
-
|
15
|
-
# TODO: Fix this bullshit.
|
16
|
-
# :nodoc:
|
17
|
-
def self.default_github_instance
|
18
|
-
Github.new(auto_pagination: true) do |config|
|
19
|
-
config.basic_auth = ENV["HOWIS_BASIC_AUTH"] if ENV["HOWIS_BASIC_AUTH"]
|
20
|
-
end
|
21
|
-
end
|
22
|
-
|
23
|
-
##
|
24
|
-
# Standardized representation for fetcher results.
|
25
|
-
#
|
26
|
-
# Implemented as a class instead of passing around a Hash so that it can
|
27
|
-
# be more easily referenced by Contracts.
|
28
|
-
Results = Struct.new(:repository, :issues, :pulls, :pulse) do
|
29
|
-
include Contracts::Core
|
30
|
-
|
31
|
-
Contract String, C::ArrayOf[Hash], C::ArrayOf[Hash], String => nil
|
32
|
-
def initialize(repository, issues, pulls, pulse)
|
33
|
-
super(repository, issues, pulls, pulse)
|
34
|
-
end
|
35
|
-
|
36
|
-
# Struct defines #to_h, but not #to_hash, so we alias them.
|
37
|
-
alias_method :to_hash, :to_h
|
38
|
-
end
|
39
|
-
|
40
|
-
##
|
41
|
-
# Fetches repository information from GitHub and returns a Results object.
|
42
|
-
Contract String,
|
43
|
-
C::Or[C::RespondTo[:issues, :pulls], nil],
|
44
|
-
C::Or[C::RespondTo[:html_summary], nil] => Results
|
45
|
-
def call(repository,
|
46
|
-
github = nil,
|
47
|
-
pulse = nil)
|
48
|
-
github ||= self.class.default_github_instance
|
49
|
-
pulse ||= HowIs::Pulse.new(repository)
|
50
|
-
user, repo = repository.split("/", 2)
|
51
|
-
|
52
|
-
unless user && repo
|
53
|
-
raise HowIs::CLI::OptionsError, "To generate a report from GitHub, " \
|
54
|
-
"provide the repository " \
|
55
|
-
"username/project. Quitting!"
|
56
|
-
end
|
57
|
-
|
58
|
-
issues = github.issues.list user: user, repo: repo
|
59
|
-
pulls = github.pulls.list user: user, repo: repo
|
60
|
-
|
61
|
-
summary = pulse.html_summary
|
62
|
-
|
63
|
-
Results.new(
|
64
|
-
repository,
|
65
|
-
obj_to_array_of_hashes(issues),
|
66
|
-
obj_to_array_of_hashes(pulls),
|
67
|
-
summary
|
68
|
-
)
|
69
|
-
end
|
70
|
-
|
71
|
-
private
|
72
|
-
|
73
|
-
def obj_to_array_of_hashes(object)
|
74
|
-
object.to_a.map(&:to_h)
|
75
|
-
end
|
76
|
-
end
|
77
|
-
end
|
data/lib/how_is/pulse.rb
DELETED
@@ -1,47 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "tessellator/fetcher"
|
4
|
-
|
5
|
-
class HowIs
|
6
|
-
# This entire class is a monstrous hack, because GitHub doesn't provide
|
7
|
-
# a good API for Pulse.
|
8
|
-
#
|
9
|
-
# TODO: Use GitHub's Statistics API to replace this garbage.
|
10
|
-
# See https://github.com/how-is/how_is/issues/122
|
11
|
-
class Pulse
|
12
|
-
def initialize(repository)
|
13
|
-
@repository = repository
|
14
|
-
@pulse_page_response = fetch_pulse!(repository)
|
15
|
-
end
|
16
|
-
|
17
|
-
# Gets the HTML Pulse summary.
|
18
|
-
def html_summary
|
19
|
-
if stats_section?
|
20
|
-
stats_html_fragment.gsub('<a href="/', '<a href="https://github.com/')
|
21
|
-
else
|
22
|
-
"There hasn't been any activity on #{@repository} in the last month."
|
23
|
-
end
|
24
|
-
end
|
25
|
-
|
26
|
-
private
|
27
|
-
|
28
|
-
HTML_SEPARATOR_FOR_STATS = '<div class="section diffstat-summary">'
|
29
|
-
|
30
|
-
def stats_section?
|
31
|
-
parts.count > 1
|
32
|
-
end
|
33
|
-
|
34
|
-
def parts
|
35
|
-
@parts ||= @pulse_page_response.body.split(HTML_SEPARATOR_FOR_STATS)
|
36
|
-
end
|
37
|
-
|
38
|
-
def stats_html_fragment
|
39
|
-
parts.last.split("</div>").first.strip
|
40
|
-
end
|
41
|
-
|
42
|
-
# Fetch Pulse page from GitHub for scraping.
|
43
|
-
def fetch_pulse!(repository)
|
44
|
-
Tessellator::Fetcher.new.call("get", "https://github.com/#{repository}/pulse/monthly")
|
45
|
-
end
|
46
|
-
end
|
47
|
-
end
|
@@ -1,148 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "json"
|
4
|
-
|
5
|
-
class HowIs
|
6
|
-
BaseReport = Struct.new(:analysis)
|
7
|
-
|
8
|
-
##
|
9
|
-
# Subclasses of BaseReport represent complete reports.
|
10
|
-
class BaseReport
|
11
|
-
def generate_report_text!
|
12
|
-
# title, text, header, horizontal_bar_graph, etc,
|
13
|
-
# append to @r, which is returned at the end of the function.
|
14
|
-
|
15
|
-
title "How is #{analysis.repository}?"
|
16
|
-
|
17
|
-
# DateTime#new_offset(0) sets the timezone to UTC. I think it does this
|
18
|
-
# without changing anything besides the timezone, but who knows, 'cause
|
19
|
-
# new_offset is entirely undocumented! (Even though it's used in the
|
20
|
-
# DateTime documentation!)
|
21
|
-
#
|
22
|
-
# TODO: Stop pretending everyone who runs how_is is in UTC.
|
23
|
-
text "Monthly report, ending on #{DateTime.now.new_offset(0).strftime('%B %e, %Y')}."
|
24
|
-
|
25
|
-
text analysis.pulse
|
26
|
-
|
27
|
-
header "Pull Requests"
|
28
|
-
issue_or_pr_summary "pull", "pull request"
|
29
|
-
|
30
|
-
header "Issues"
|
31
|
-
issue_or_pr_summary "issue", "issue"
|
32
|
-
|
33
|
-
header "Issues Per Label"
|
34
|
-
issues_per_label = analysis.issues_with_label.to_a.sort_by { |(_, v)| v["total"].to_i }.reverse
|
35
|
-
issues_per_label.map! do |label, hash|
|
36
|
-
[label, hash["total"], hash["link"]]
|
37
|
-
end
|
38
|
-
issues_per_label << ["(No label)", analysis.issues_with_no_label["total"], nil]
|
39
|
-
horizontal_bar_graph issues_per_label
|
40
|
-
|
41
|
-
# See comment at beginning of function.
|
42
|
-
@r
|
43
|
-
end
|
44
|
-
|
45
|
-
# === Methods implemented by subclasses of BaseReport ===
|
46
|
-
|
47
|
-
##
|
48
|
-
# Returns the format of report this class generates.
|
49
|
-
#
|
50
|
-
# @return [Symbol] A lowercase symbol denoting the report format.
|
51
|
-
def format
|
52
|
-
raise NotImplementedError
|
53
|
-
end
|
54
|
-
|
55
|
-
##
|
56
|
-
# Appends a title to the report.
|
57
|
-
def title(_content)
|
58
|
-
raise NotImplementedError
|
59
|
-
end
|
60
|
-
|
61
|
-
##
|
62
|
-
# Appends a header to the report.
|
63
|
-
def header(_content)
|
64
|
-
raise NotImplementedError
|
65
|
-
end
|
66
|
-
|
67
|
-
##
|
68
|
-
# Appends a line of text to the report.
|
69
|
-
def text(_content)
|
70
|
-
raise NotImplementedError
|
71
|
-
end
|
72
|
-
|
73
|
-
##
|
74
|
-
# Appends a link to the report.
|
75
|
-
def link(_content, _url)
|
76
|
-
raise NotImplementedError
|
77
|
-
end
|
78
|
-
|
79
|
-
##
|
80
|
-
# Appends an unordered list to the report.
|
81
|
-
def unordered_list(_arr)
|
82
|
-
raise NotImplementedError
|
83
|
-
end
|
84
|
-
|
85
|
-
##
|
86
|
-
# Appends a horizontal bar graph to the report.
|
87
|
-
def horizontal_bar_graph(_data)
|
88
|
-
raise NotImplementedError
|
89
|
-
end
|
90
|
-
|
91
|
-
##
|
92
|
-
# Exports the report as a String.
|
93
|
-
def export
|
94
|
-
raise NotImplementedError
|
95
|
-
end
|
96
|
-
|
97
|
-
##
|
98
|
-
# Exports a report to a file.
|
99
|
-
#
|
100
|
-
# NOTE: May be removed in the future.
|
101
|
-
def export_file(_file)
|
102
|
-
raise NotImplementedError
|
103
|
-
end
|
104
|
-
|
105
|
-
def to_h
|
106
|
-
analysis.to_h
|
107
|
-
end
|
108
|
-
alias_method :to_hash, :to_h
|
109
|
-
|
110
|
-
def to_json
|
111
|
-
JSON.pretty_generate(to_h)
|
112
|
-
end
|
113
|
-
|
114
|
-
private
|
115
|
-
|
116
|
-
def pluralize(text, number)
|
117
|
-
(number == 1) ? text : "#{text}s"
|
118
|
-
end
|
119
|
-
|
120
|
-
def are_is(number)
|
121
|
-
(number == 1) ? "is" : "are"
|
122
|
-
end
|
123
|
-
|
124
|
-
def issue_or_pr_summary(type, type_label)
|
125
|
-
date_format = "%b %e, %Y"
|
126
|
-
a = analysis
|
127
|
-
|
128
|
-
number_of_type = a.public_send("number_of_#{type}s")
|
129
|
-
|
130
|
-
type_link = a.public_send("#{type}s_url")
|
131
|
-
oldest = a.public_send("oldest_#{type}")
|
132
|
-
newest = a.public_send("newest_#{type}")
|
133
|
-
|
134
|
-
if number_of_type.zero?
|
135
|
-
text "There are #{link("no #{type_label}s open", type_link)}."
|
136
|
-
else
|
137
|
-
text "There #{are_is(number_of_type)} #{link("#{number_of_type} "\
|
138
|
-
"#{pluralize(type_label, number_of_type)} open", type_link)}."
|
139
|
-
|
140
|
-
unordered_list [
|
141
|
-
"Average age: #{a.public_send("average_#{type}_age")}.",
|
142
|
-
"#{link('Oldest ' + type_label, oldest['html_url'])} was opened on #{oldest['date'].strftime(date_format)}.",
|
143
|
-
"#{link('Newest ' + type_label, newest['html_url'])} was opened on #{newest['date'].strftime(date_format)}.",
|
144
|
-
]
|
145
|
-
end
|
146
|
-
end
|
147
|
-
end
|
148
|
-
end
|