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.
- 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
|