how_is 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +2 -0
- data/.travis.yml +6 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +44 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/data/issues.plg +22 -0
- data/exe/how_is +48 -0
- data/how_is.gemspec +31 -0
- data/lib/how_is/analyzer.rb +142 -0
- data/lib/how_is/chart.rb +83 -0
- data/lib/how_is/fetcher.rb +44 -0
- data/lib/how_is/report/html.rb +7 -0
- data/lib/how_is/report/json.rb +13 -0
- data/lib/how_is/report/pdf.rb +66 -0
- data/lib/how_is/report.rb +80 -0
- data/lib/how_is/reporter.rb +19 -0
- data/lib/how_is/version.rb +3 -0
- data/lib/how_is.rb +32 -0
- data/roadmap.markdown +49 -0
- metadata +181 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 2a6fdbded1ff0bf48eba79e033d60b63fe5b3227
|
4
|
+
data.tar.gz: 3cce6191b1f525431233d27fc856f593d2e59b3f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9c6e101d9dbf277c6df9f21b0c68457316bd346a2d97d93472a9f4661d44f06ae9987901368e5bb09b24f63d6e66e2ee71f5f91c5c8ff03b46d9d4696e993afd
|
7
|
+
data.tar.gz: 7f66062db788773e525b566acc9b8f38cbcb9c381176f8150059368601e04a10d096867e70e80457e571d9a4b4de678c9954dd55bad81e9406e30981677e342f
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
# Contributor Code of Conduct
|
2
|
+
|
3
|
+
As contributors and maintainers of this project, and in the interest of
|
4
|
+
fostering an open and welcoming community, we pledge to respect all people who
|
5
|
+
contribute through reporting issues, posting feature requests, updating
|
6
|
+
documentation, submitting pull requests or patches, and other activities.
|
7
|
+
|
8
|
+
We are committed to making participation in this project a harassment-free
|
9
|
+
experience for everyone, regardless of level of experience, gender, gender
|
10
|
+
identity and expression, sexual orientation, disability, personal appearance,
|
11
|
+
body size, race, ethnicity, age, religion, or nationality.
|
12
|
+
|
13
|
+
Examples of unacceptable behavior by participants include:
|
14
|
+
|
15
|
+
* The use of sexualized language or imagery
|
16
|
+
* Personal attacks
|
17
|
+
* Trolling or insulting/derogatory comments
|
18
|
+
* Public or private harassment
|
19
|
+
* Publishing other's private information, such as physical or electronic
|
20
|
+
addresses, without explicit permission
|
21
|
+
* Other unethical or unprofessional conduct
|
22
|
+
|
23
|
+
Project maintainers have the right and responsibility to remove, edit, or
|
24
|
+
reject comments, commits, code, wiki edits, issues, and other contributions
|
25
|
+
that are not aligned to this Code of Conduct, or to ban temporarily or
|
26
|
+
permanently any contributor for other behaviors that they deem inappropriate,
|
27
|
+
threatening, offensive, or harmful.
|
28
|
+
|
29
|
+
By adopting this Code of Conduct, project maintainers commit themselves to
|
30
|
+
fairly and consistently applying these principles to every aspect of managing
|
31
|
+
this project. Project maintainers who do not follow or enforce the Code of
|
32
|
+
Conduct may be permanently removed from the project team.
|
33
|
+
|
34
|
+
This code of conduct applies both within project spaces and in public spaces
|
35
|
+
when an individual is representing the project or its community.
|
36
|
+
|
37
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
38
|
+
reported by contacting a project maintainer at me@duckie.co. All
|
39
|
+
complaints will be reviewed and investigated and will result in a response that
|
40
|
+
is deemed necessary and appropriate to the circumstances. Maintainers are
|
41
|
+
obligated to maintain confidentiality with regard to the reporter of an
|
42
|
+
incident.
|
43
|
+
|
44
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
45
|
+
version 1.3.0, available at
|
46
|
+
[http://contributor-covenant.org/version/1/3/0/][version]
|
47
|
+
|
48
|
+
[homepage]: http://contributor-covenant.org
|
49
|
+
[version]: http://contributor-covenant.org/version/1/3/0/
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2016 Ellen Marie Dash
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
[![Stories in Ready](https://badge.waffle.io/duckinator/how_is.png?label=ready&title=Ready)](https://waffle.io/duckinator/how_is)
|
2
|
+
# How is [your repo]?
|
3
|
+
|
4
|
+
`how_is` is tool for generating summaries of the health of a codebase. It uses information available from issues and pull requests to provide an overview of a repository and highlight problem areas of the codebase.
|
5
|
+
|
6
|
+
The summary includes:
|
7
|
+
|
8
|
+
* repository name,
|
9
|
+
* number of open issues,
|
10
|
+
* number of open pull requests,
|
11
|
+
* number of open issues associated with each label and with no label,
|
12
|
+
* average issue age,
|
13
|
+
* average pull request age,
|
14
|
+
* date oldest issue was opened,
|
15
|
+
* date oldest pull request was opened.
|
16
|
+
|
17
|
+
## Installation
|
18
|
+
|
19
|
+
$ gem install how_is
|
20
|
+
|
21
|
+
## Usage
|
22
|
+
|
23
|
+
$ how_is <orgname>/<reponame> [--report-file FILENAME]
|
24
|
+
|
25
|
+
E.g.,
|
26
|
+
|
27
|
+
$ how_is rubygems/rubygems --report-file report.pdf
|
28
|
+
|
29
|
+
The above command creates a PDF containing the summary at `./report.pdf`.
|
30
|
+
|
31
|
+
## Development
|
32
|
+
|
33
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. Run `bundle exec how_is` to use the gem in this directory, ignoring other installed copies of this gem.
|
34
|
+
|
35
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
36
|
+
|
37
|
+
## Contributing
|
38
|
+
|
39
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/duckinator/how_is. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
40
|
+
|
41
|
+
|
42
|
+
## License
|
43
|
+
|
44
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "how_is"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/data/issues.plg
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
if (!exists("chartsize")) chartsize = '500,500'
|
2
|
+
if (!exists("labelfont")) labelfont = 'Helvetica,12'
|
3
|
+
if (!exists("chartfont")) chartfont = 'Helvetica,12'
|
4
|
+
if (!exists("data")) data = 'issues-per-label.dat'
|
5
|
+
if (!exists("pngfile")) data = 'issues-per-label.png'
|
6
|
+
|
7
|
+
set terminal png size chartsize font chartfont
|
8
|
+
|
9
|
+
set output pngfile
|
10
|
+
|
11
|
+
unset key
|
12
|
+
unset border
|
13
|
+
set tics nomirror
|
14
|
+
unset xtics
|
15
|
+
|
16
|
+
set style fill solid 0.15 border
|
17
|
+
|
18
|
+
plot data using 1:2 with boxes, data using 1:(0):3 with \
|
19
|
+
labels left \
|
20
|
+
rotate by 90 \
|
21
|
+
font labelfont \
|
22
|
+
offset 0,0.45
|
data/exe/how_is
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "how_is"
|
4
|
+
require "optparse"
|
5
|
+
|
6
|
+
options = {
|
7
|
+
repository: nil,
|
8
|
+
report_file: "report.pdf",
|
9
|
+
from_file: nil,
|
10
|
+
}
|
11
|
+
|
12
|
+
opts = OptionParser.new do |opts|
|
13
|
+
opts.banner =
|
14
|
+
<<-EOF.gsub(/ *\| ?/, '')
|
15
|
+
| Usage: how_is REPOSITORY [--report REPORT_FILE]
|
16
|
+
|
|
17
|
+
| Where REPOSITORY is of the format <GitHub username or org>/<repository name>.
|
18
|
+
|
|
19
|
+
| E.g., if you wanted to check https://github.com/duckinator/how_is,
|
20
|
+
| you'd run `how_is duckinator/how_is`.
|
21
|
+
|
|
22
|
+
EOF
|
23
|
+
|
24
|
+
opts.separator ""
|
25
|
+
opts.separator "Options:"
|
26
|
+
|
27
|
+
opts.on("-h", "--help", "Print this help") do
|
28
|
+
puts opts
|
29
|
+
exit 0
|
30
|
+
end
|
31
|
+
|
32
|
+
opts.on("--from JSON_REPORT_FILE", "import JSON_REPORT_FILE instead of fetching the data again") do |file|
|
33
|
+
options[:from_file] = file
|
34
|
+
end
|
35
|
+
|
36
|
+
opts.on("--report REPORT_FILE", "CSV file containing the report") do |file|
|
37
|
+
options[:report_file] = file
|
38
|
+
end
|
39
|
+
end
|
40
|
+
opts.parse!
|
41
|
+
|
42
|
+
if ARGV.length >= 1
|
43
|
+
options[:repository] = ARGV.delete_at(0)
|
44
|
+
else
|
45
|
+
abort "Error: No repository specified."
|
46
|
+
end
|
47
|
+
|
48
|
+
HowIs.generate_report(options)
|
data/how_is.gemspec
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'how_is/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "how_is"
|
8
|
+
spec.version = HowIs::VERSION
|
9
|
+
spec.authors = ["Ellen Marie Dash"]
|
10
|
+
spec.email = ["me@duckie.co"]
|
11
|
+
|
12
|
+
spec.summary = %q{Quantify the health of a GitHub repository is.}
|
13
|
+
spec.homepage = "https://github.com/duckinator/how_is"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
17
|
+
spec.bindir = "exe"
|
18
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_runtime_dependency "github_api", "~> 0.13.1"
|
22
|
+
spec.add_runtime_dependency "configru", "~> 3.6.0"
|
23
|
+
spec.add_runtime_dependency "contracts"
|
24
|
+
spec.add_runtime_dependency "prawn"
|
25
|
+
|
26
|
+
spec.add_runtime_dependency "mini_magick"
|
27
|
+
|
28
|
+
spec.add_development_dependency "bundler", "~> 1.11"
|
29
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
30
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
31
|
+
end
|
@@ -0,0 +1,142 @@
|
|
1
|
+
require 'contracts'
|
2
|
+
require 'ostruct'
|
3
|
+
require 'date'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module HowIs
|
7
|
+
##
|
8
|
+
# Represents a completed analysis of the repository being analyzed.
|
9
|
+
class Analysis < OpenStruct
|
10
|
+
end
|
11
|
+
|
12
|
+
class Analyzer
|
13
|
+
include Contracts::Core
|
14
|
+
|
15
|
+
class UnsupportedImportFormat < StandardError
|
16
|
+
def initialize(format)
|
17
|
+
super("Unsupported import format: #{format}")
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
Contract Fetcher::Results, C::KeywordArgs[analysis_class: C::Optional[Class]] => Analysis
|
22
|
+
def call(data, analysis_class: Analysis)
|
23
|
+
issues = data.issues
|
24
|
+
pulls = data.pulls
|
25
|
+
|
26
|
+
analysis_class.new(
|
27
|
+
repository: data.repository,
|
28
|
+
|
29
|
+
number_of_issues: issues.length,
|
30
|
+
number_of_pulls: pulls.length,
|
31
|
+
|
32
|
+
issues_with_label: num_with_label(issues),
|
33
|
+
issues_with_no_label: num_with_no_label(issues),
|
34
|
+
|
35
|
+
average_issue_age: average_age_for(issues),
|
36
|
+
average_pull_age: average_age_for(pulls),
|
37
|
+
|
38
|
+
oldest_issue_date: oldest_date_for(issues),
|
39
|
+
oldest_pull_date: oldest_date_for(pulls),
|
40
|
+
)
|
41
|
+
end
|
42
|
+
|
43
|
+
def from_file(file)
|
44
|
+
extension = file.split('.').last
|
45
|
+
raise UnsupportedImportFormat, extension unless extension == 'json'
|
46
|
+
|
47
|
+
hash = JSON.parse(open(file).read)
|
48
|
+
hash = hash.map do |k, v|
|
49
|
+
v = DateTime.parse(v) if k.end_with?('_date')
|
50
|
+
|
51
|
+
[k, v]
|
52
|
+
end.to_h
|
53
|
+
|
54
|
+
Analysis.new(hash)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Given an Array of issues or pulls, return a Hash specifying how many
|
58
|
+
# issues or pulls use each label.
|
59
|
+
def num_with_label(issues_or_pulls)
|
60
|
+
# Returned hash maps labels to frequency.
|
61
|
+
# E.g., given 10 issues/pulls with label "label1" and 5 with label "label2",
|
62
|
+
# {
|
63
|
+
# "label1" => 10,
|
64
|
+
# "label2" => 5
|
65
|
+
# }
|
66
|
+
|
67
|
+
hash = Hash.new(0)
|
68
|
+
issues_or_pulls.each do |iop|
|
69
|
+
next unless iop['labels']
|
70
|
+
|
71
|
+
iop['labels'].each do |label|
|
72
|
+
hash[label['name']] += 1
|
73
|
+
end
|
74
|
+
end
|
75
|
+
hash
|
76
|
+
end
|
77
|
+
|
78
|
+
def num_with_no_label(issues)
|
79
|
+
issues.select { |x| x['labels'].empty? }.length
|
80
|
+
end
|
81
|
+
|
82
|
+
def average_date_for(issues_or_pulls)
|
83
|
+
timestamps = issues_or_pulls.map { |iop| Time.parse(iop['created_at']).to_i }
|
84
|
+
average_timestamp = timestamps.reduce(:+) / issues_or_pulls.length
|
85
|
+
|
86
|
+
average_time = Time.at(average_timestamp)
|
87
|
+
Date.parse(average_time.to_s)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Given an Array of issues or pulls, return the average age of them.
|
91
|
+
def average_age_for(issues_or_pulls)
|
92
|
+
ages = issues_or_pulls.map {|iop| time_ago_in_seconds(iop['created_at'])}
|
93
|
+
raw_average = ages.reduce(:+) / ages.length
|
94
|
+
|
95
|
+
seconds_in_a_year = 31_556_926
|
96
|
+
seconds_in_a_month = 2_629_743
|
97
|
+
seconds_in_a_week = 604_800
|
98
|
+
seconds_in_a_day = 86_400
|
99
|
+
|
100
|
+
years = raw_average / seconds_in_a_year
|
101
|
+
years_remainder = raw_average % seconds_in_a_year
|
102
|
+
|
103
|
+
months = years_remainder / seconds_in_a_month
|
104
|
+
months_remainder = years_remainder % seconds_in_a_month
|
105
|
+
|
106
|
+
weeks = months_remainder / seconds_in_a_week
|
107
|
+
weeks_remainder = months_remainder % seconds_in_a_week
|
108
|
+
|
109
|
+
days = weeks_remainder / seconds_in_a_day
|
110
|
+
|
111
|
+
values = [
|
112
|
+
[years, "year"],
|
113
|
+
[months, "month"],
|
114
|
+
[weeks, "week"],
|
115
|
+
[days, "day"],
|
116
|
+
].reject {|(v, k)| v == 0}.map{ |(v,k)|
|
117
|
+
k = k + 's' if v != 1
|
118
|
+
[v, k]
|
119
|
+
}
|
120
|
+
|
121
|
+
most_significant = values[0, 2].map {|x| x.join(" ")}
|
122
|
+
|
123
|
+
if most_significant.length < 2
|
124
|
+
value = most_significant.first
|
125
|
+
else
|
126
|
+
value = most_significant.join(" and ")
|
127
|
+
end
|
128
|
+
|
129
|
+
"approximately #{value}"
|
130
|
+
end
|
131
|
+
|
132
|
+
# Given an Array of issues or pulls, return the creation date of the oldest.
|
133
|
+
def oldest_date_for(issues_or_pulls)
|
134
|
+
issues_or_pulls.map {|x| DateTime.parse(x['created_at']) }.sort.first
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
def time_ago_in_seconds(x)
|
139
|
+
DateTime.now.strftime("%s").to_i - DateTime.parse(x).strftime("%s").to_i
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
data/lib/how_is/chart.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
class HowIs::Chart
|
2
|
+
# Generates the gnuplot script in data/issues.plg.
|
3
|
+
#
|
4
|
+
# Some configuration is available. Font locations are path to a TTF or other
|
5
|
+
# Gnuplot-readable font name.
|
6
|
+
#
|
7
|
+
# For example that could be '/Users/anne/Library/Fonts/InputMono-Medium.ttf'
|
8
|
+
# or just 'Helvetica'.
|
9
|
+
#
|
10
|
+
# @param font_location [String] Font for the chart
|
11
|
+
# @param font_size [Integer] Size of the chart text
|
12
|
+
# @param label_font_location [String] Font for labels
|
13
|
+
# @param label_font_size [Integer] Size of the label text
|
14
|
+
#
|
15
|
+
# @return void
|
16
|
+
def self.gnuplot(font_location: nil,
|
17
|
+
font_size: 16,
|
18
|
+
label_font_location: nil,
|
19
|
+
label_font_size: 10,
|
20
|
+
chartsize: '500,500',
|
21
|
+
data_file:,
|
22
|
+
png_file:)
|
23
|
+
default_font_location =
|
24
|
+
if Gem.win_platform?
|
25
|
+
'Arial'
|
26
|
+
else
|
27
|
+
'Helvetica'
|
28
|
+
end
|
29
|
+
|
30
|
+
font_location ||= default_font_location
|
31
|
+
label_font_location ||= font_location
|
32
|
+
|
33
|
+
cmd = %Q{
|
34
|
+
gnuplot -e "labelfont='#{label_font_location},#{label_font_size}'" \
|
35
|
+
-e "chartfont='#{font_location},#{font_size}'" \
|
36
|
+
-e "chartsize='#{chartsize}'" \
|
37
|
+
-e "data='#{data_file}'" \
|
38
|
+
-e "pngfile='#{png_file}'" \
|
39
|
+
-c data/issues.plg
|
40
|
+
}
|
41
|
+
puts cmd
|
42
|
+
IO.popen(cmd, 'w')
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.rotate(offset, filename)
|
46
|
+
if Gem.win_platform?
|
47
|
+
rotate_with_dotnet(filename, offset)
|
48
|
+
else
|
49
|
+
rotate_with_minimagick(filename, offset)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.rotate_with_dotnet(filename, offset)
|
54
|
+
ps_rotate_flip = {
|
55
|
+
90 => 'Rotate90FlipNone',
|
56
|
+
180 => 'Rotate180FlipNone',
|
57
|
+
270 => 'Rotate270FlipNone',
|
58
|
+
-90 => 'Rotate270FlipNone'
|
59
|
+
}[offset]
|
60
|
+
|
61
|
+
command = %Q{
|
62
|
+
$path = "#{filename}"
|
63
|
+
|
64
|
+
[Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms");
|
65
|
+
$i = new-object System.Drawing.Bitmap $path
|
66
|
+
|
67
|
+
$i.RotateFlip("#{ps_rotate_flip}")
|
68
|
+
|
69
|
+
$i.Save($path,"png")
|
70
|
+
|
71
|
+
exit
|
72
|
+
}
|
73
|
+
|
74
|
+
IO.popen(["powershell", "-Command", command], 'w') { |io| }
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.rotate_with_minimagick(filename, offset)
|
78
|
+
require 'mini_magick'
|
79
|
+
image = MiniMagick::Image.new(filename) { |b| b.rotate offset.to_s }
|
80
|
+
image.format 'png'
|
81
|
+
image.write filename
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'contracts'
|
2
|
+
require 'github_api'
|
3
|
+
|
4
|
+
##
|
5
|
+
# Fetches data from GitHub.
|
6
|
+
class HowIs::Fetcher
|
7
|
+
include Contracts::Core
|
8
|
+
|
9
|
+
##
|
10
|
+
# Standardized representation for fetcher results.
|
11
|
+
#
|
12
|
+
# Implemented as a class instead of passing around a Hash so that it can
|
13
|
+
# be more easily referenced by Contracts.
|
14
|
+
class Results < Struct.new(:repository, :issues, :pulls)
|
15
|
+
include Contracts::Core
|
16
|
+
|
17
|
+
Contract String, C::ArrayOf[Hash], C::ArrayOf[Hash] => nil
|
18
|
+
def initialize(repository, issues, pulls)
|
19
|
+
super(repository, issues, pulls)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Struct defines #to_h, but not #to_hash, so we alias them.
|
23
|
+
alias_method :to_hash, :to_h
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
Contract String, C::RespondTo[:issues, :pulls] => Results
|
28
|
+
def call(repository,
|
29
|
+
github = Github.new(auto_pagination: true))
|
30
|
+
user, repo = repository.split('/', 2)
|
31
|
+
issues = github.issues.list user: user, repo: repo
|
32
|
+
pulls = github.pulls.list user: user, repo: repo
|
33
|
+
|
34
|
+
Results.new(
|
35
|
+
repository,
|
36
|
+
obj_to_array_of_hashes(issues),
|
37
|
+
obj_to_array_of_hashes(pulls)
|
38
|
+
)
|
39
|
+
end
|
40
|
+
|
41
|
+
private def obj_to_array_of_hashes(object)
|
42
|
+
object.to_a.map(&:to_h)
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module HowIs
|
2
|
+
class PdfReport < BaseReport
|
3
|
+
attr_accessor :pdf
|
4
|
+
|
5
|
+
def title(_text)
|
6
|
+
pdf.pad_bottom(10) {
|
7
|
+
pdf.text(_text, size: 25)
|
8
|
+
}
|
9
|
+
end
|
10
|
+
|
11
|
+
def header(_text)
|
12
|
+
pdf.pad_top(15) {
|
13
|
+
pdf.pad_bottom(3) {
|
14
|
+
pdf.text _text, size: 20
|
15
|
+
}
|
16
|
+
}
|
17
|
+
end
|
18
|
+
|
19
|
+
def horizontal_bar_graph(data)
|
20
|
+
filename_base = "./issues-per-label"
|
21
|
+
dat_file = filename_base + '.dat'
|
22
|
+
png_file = filename_base + '.png'
|
23
|
+
|
24
|
+
File.open(dat_file, 'w') do |f|
|
25
|
+
data.each_with_index do |(label, n), i|
|
26
|
+
f.puts "#{i}\t#{n}\t\"#{label}\""
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
Chart.gnuplot(label_font_size: 10,
|
31
|
+
font_size: 16,
|
32
|
+
data_file: dat_file,
|
33
|
+
png_file: png_file)
|
34
|
+
Chart.rotate(90, png_file)
|
35
|
+
|
36
|
+
pdf.image png_file
|
37
|
+
end
|
38
|
+
|
39
|
+
def text(_text)
|
40
|
+
pdf.text _text
|
41
|
+
end
|
42
|
+
|
43
|
+
# Prawn (afaict) doesn't let you export to a binary blob.
|
44
|
+
# So export to a file, then read the file.
|
45
|
+
def export(&block)
|
46
|
+
# TODO: Use actual temporary file.
|
47
|
+
export!('temp.pdf', &block)
|
48
|
+
|
49
|
+
open('temp.pdf').read
|
50
|
+
end
|
51
|
+
|
52
|
+
def export!(file, &block)
|
53
|
+
_self = self
|
54
|
+
|
55
|
+
Prawn::Document.generate(file) do |pdf|
|
56
|
+
_self.pdf = pdf
|
57
|
+
|
58
|
+
pdf.font("Helvetica")
|
59
|
+
|
60
|
+
pdf.span(450, position: :center) do
|
61
|
+
_self.instance_eval(&block)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
module HowIs
|
2
|
+
class UnsupportedExportFormat < StandardError
|
3
|
+
def initialize(format)
|
4
|
+
super("Unsupported export format: #{format}")
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
8
|
+
##
|
9
|
+
# Represents a completed report.
|
10
|
+
class BaseReport < Struct.new(:analysis)
|
11
|
+
def to_h
|
12
|
+
analysis.to_h
|
13
|
+
end
|
14
|
+
alias :to_hash :to_h
|
15
|
+
|
16
|
+
def to_json
|
17
|
+
to_h.to_json
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
def issue_or_pr_summary(type, type_label)
|
22
|
+
oldest_date_format = "%b %e, %Y"
|
23
|
+
a = analysis
|
24
|
+
|
25
|
+
number_of_type = a.send("number_of_#{type}s")
|
26
|
+
|
27
|
+
"There are #{number_of_type} #{type_label}s open. " +
|
28
|
+
"The average #{type_label} age is #{a.send("average_#{type}_age")}, and the " +
|
29
|
+
"oldest was opened on #{a.send("oldest_#{type}_date").strftime(oldest_date_format)}."
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class Report
|
34
|
+
require 'how_is/report/pdf'
|
35
|
+
require 'how_is/report/json'
|
36
|
+
|
37
|
+
REPORT_BLOCK = proc do
|
38
|
+
title "How is #{analysis.repository}?"
|
39
|
+
|
40
|
+
header "Pull Requests"
|
41
|
+
text issue_or_pr_summary "pull", "pull request"
|
42
|
+
|
43
|
+
header "Issues"
|
44
|
+
text issue_or_pr_summary "issue", "issue"
|
45
|
+
|
46
|
+
header "Issues Per Label"
|
47
|
+
issues_per_label = analysis.issues_with_label.to_a.sort_by { |(k, v)| v.to_i }.reverse
|
48
|
+
issues_per_label << ["(No label)", analysis.issues_with_no_label]
|
49
|
+
horizontal_bar_graph issues_per_label
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.export(analysis, format = :pdf)
|
53
|
+
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.export!(analysis, file)
|
57
|
+
format = file.split('.').last
|
58
|
+
report = get_report_class(format).new(analysis)
|
59
|
+
|
60
|
+
report.export!(file, &REPORT_BLOCK)
|
61
|
+
end
|
62
|
+
|
63
|
+
def self.export(analysis, format = :pdf)
|
64
|
+
report = get_report_class(format).new(analysis)
|
65
|
+
|
66
|
+
report.export(&REPORT_BLOCK)
|
67
|
+
|
68
|
+
report
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
def self.get_report_class(format)
|
73
|
+
class_name = "#{format.capitalize}Report"
|
74
|
+
|
75
|
+
raise UnsupportedExportFormat, format unless HowIs.const_defined?(class_name)
|
76
|
+
|
77
|
+
HowIs.const_get(class_name)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'contracts'
|
2
|
+
require 'prawn'
|
3
|
+
require 'how_is/chart'
|
4
|
+
|
5
|
+
module HowIs
|
6
|
+
class Reporter
|
7
|
+
require 'how_is/report'
|
8
|
+
include Contracts::Core
|
9
|
+
|
10
|
+
##
|
11
|
+
# Given an Analysis, generate a Report
|
12
|
+
#
|
13
|
+
# Returns a class that inherits from Report.
|
14
|
+
Contract Analysis, String => C::Any
|
15
|
+
def call(analysis, report_file)
|
16
|
+
Report.export!(analysis, report_file)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
data/lib/how_is.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'how_is/version'
|
2
|
+
require 'contracts'
|
3
|
+
|
4
|
+
C = Contracts
|
5
|
+
|
6
|
+
module HowIs
|
7
|
+
include Contracts::Core
|
8
|
+
|
9
|
+
require 'how_is/fetcher'
|
10
|
+
require 'how_is/analyzer'
|
11
|
+
require 'how_is/reporter'
|
12
|
+
|
13
|
+
Contract C::KeywordArgs[repository: String, report_file: String,
|
14
|
+
from_file: C::Optional[C::Or[String, nil]],
|
15
|
+
fetcher: C::Optional[Class],
|
16
|
+
analyzer: C::Optional[Class],
|
17
|
+
reporter: C::Optional[Class]] => C::Any
|
18
|
+
def self.generate_report(repository:, report_file:,
|
19
|
+
from_file: nil,
|
20
|
+
fetcher: Fetcher.new,
|
21
|
+
analyzer: Analyzer.new,
|
22
|
+
reporter: Reporter.new)
|
23
|
+
if from_file
|
24
|
+
analysis = analyzer.from_file(from_file)
|
25
|
+
else
|
26
|
+
raw_data = fetcher.call(repository)
|
27
|
+
analysis = analyzer.call(raw_data)
|
28
|
+
end
|
29
|
+
|
30
|
+
reporter.call(analysis, report_file)
|
31
|
+
end
|
32
|
+
end
|
data/roadmap.markdown
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
# How_is Roadmap
|
2
|
+
|
3
|
+
A brief overview of how_is' goals and current status.
|
4
|
+
|
5
|
+
## Goals
|
6
|
+
|
7
|
+
How_is is intended to be usable both as a standalone program and a as library, with reports generated as either files or Ruby Strings. Initial export formats supported will be JSON and PDF, with HTML to follow later. When exporting a report, you can use the data from a previous JSON export to avoid making network requests.
|
8
|
+
|
9
|
+
Metrics will be divided into two categories during implementation: Simple and Complex. Simple ones will be implementable using only the information gathered from one API call to the issue tracker. Complex metrics require things like cloning the repository or making multiple API requests.
|
10
|
+
|
11
|
+
Simple metrics will include:
|
12
|
+
|
13
|
+
* number of open Issues,
|
14
|
+
* number of open Pull Requests,
|
15
|
+
* number of issues associated with each label, as well as the number associated with no label,
|
16
|
+
* average Issue age,
|
17
|
+
* average Pull Request age,
|
18
|
+
* date oldest Issue was opened,
|
19
|
+
* date oldest Pull Request was opened.
|
20
|
+
|
21
|
+
Complex metrics will include:
|
22
|
+
|
23
|
+
* code churn (code change over time),
|
24
|
+
* average response time by a team member in the past week,
|
25
|
+
* graph of average response time by a team member per week.
|
26
|
+
|
27
|
+
These metrics serve to either quantify the state of the repository, quantify the state of the codebase itself, or both. By quantifying the state of the issue tracker and codebase, it will hopefully be easier to decide what needs to be done.
|
28
|
+
|
29
|
+
Once HTML export is implemented, a web service is planned to be created which tracks these metrics over time for the RubyGems repository. The details of this have not been fully fleshed out, but I am attempting to design the library in such a way to allow the flexibility required to do this.
|
30
|
+
|
31
|
+
## Current Status
|
32
|
+
|
33
|
+
As of June 15th 2016, how_is supports exports to JSON or PDF, but not HTML. For JSON and PDF, all Simple metrics have been implemented in some form, although they made need some polish ([#8](https://github.com/duckinator/how_is/issues/8)). HTML export is not implemented, and no Complex metrics are implemented. Tracking the number of issues without labels has also not been implemented ([#1](https://github.com/duckinator/how_is/issues/1)).
|
34
|
+
|
35
|
+
Exporting to Ruby Strings that contain valid JSON, PDF, or HTML documents has also not been implemented ([#7](https://github.com/duckinator/how_is/issues/7)).
|
36
|
+
|
37
|
+
Authentication is not being used, but will likely be necessary, as it would raise the API rate limits ([#6](https://github.com/duckinator/how_is/issues/6)).
|
38
|
+
|
39
|
+
### Requirements for 1.0
|
40
|
+
|
41
|
+
Once JSON and PDF exports are fully implemented ([#1](https://github.com/duckinator/how_is/issues/1)) and the README has a proper list of the metrics covered, v1.0 will be released.
|
42
|
+
|
43
|
+
### Requirements for 2.0
|
44
|
+
|
45
|
+
Once everything required for v1.0 as well as exporting to Strings ([#7](https://github.com/duckinator/how_is/issues/7)) has been implemented, v2.0 will be released.
|
46
|
+
|
47
|
+
### Other changes
|
48
|
+
|
49
|
+
All other changes ([#8](https://github.com/duckinator/how_is/issues/8), [#6](https://github.com/duckinator/how_is/issues/6), any changes without an accompanying issue) will be grouped together in either the next major release, or a separate minor release.
|
metadata
ADDED
@@ -0,0 +1,181 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: how_is
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ellen Marie Dash
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-06-22 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: github_api
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.13.1
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.13.1
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: configru
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 3.6.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 3.6.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: contracts
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: prawn
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: mini_magick
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: bundler
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '1.11'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '1.11'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rake
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '10.0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '10.0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rspec
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '3.0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '3.0'
|
125
|
+
description:
|
126
|
+
email:
|
127
|
+
- me@duckie.co
|
128
|
+
executables:
|
129
|
+
- how_is
|
130
|
+
extensions: []
|
131
|
+
extra_rdoc_files: []
|
132
|
+
files:
|
133
|
+
- ".gitignore"
|
134
|
+
- ".rspec"
|
135
|
+
- ".travis.yml"
|
136
|
+
- CODE_OF_CONDUCT.md
|
137
|
+
- Gemfile
|
138
|
+
- LICENSE.txt
|
139
|
+
- README.md
|
140
|
+
- Rakefile
|
141
|
+
- bin/console
|
142
|
+
- bin/setup
|
143
|
+
- data/issues.plg
|
144
|
+
- exe/how_is
|
145
|
+
- how_is.gemspec
|
146
|
+
- lib/how_is.rb
|
147
|
+
- lib/how_is/analyzer.rb
|
148
|
+
- lib/how_is/chart.rb
|
149
|
+
- lib/how_is/fetcher.rb
|
150
|
+
- lib/how_is/report.rb
|
151
|
+
- lib/how_is/report/html.rb
|
152
|
+
- lib/how_is/report/json.rb
|
153
|
+
- lib/how_is/report/pdf.rb
|
154
|
+
- lib/how_is/reporter.rb
|
155
|
+
- lib/how_is/version.rb
|
156
|
+
- roadmap.markdown
|
157
|
+
homepage: https://github.com/duckinator/how_is
|
158
|
+
licenses:
|
159
|
+
- MIT
|
160
|
+
metadata: {}
|
161
|
+
post_install_message:
|
162
|
+
rdoc_options: []
|
163
|
+
require_paths:
|
164
|
+
- lib
|
165
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
166
|
+
requirements:
|
167
|
+
- - ">="
|
168
|
+
- !ruby/object:Gem::Version
|
169
|
+
version: '0'
|
170
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
171
|
+
requirements:
|
172
|
+
- - ">="
|
173
|
+
- !ruby/object:Gem::Version
|
174
|
+
version: '0'
|
175
|
+
requirements: []
|
176
|
+
rubyforge_project:
|
177
|
+
rubygems_version: 2.6.3
|
178
|
+
signing_key:
|
179
|
+
specification_version: 4
|
180
|
+
summary: Quantify the health of a GitHub repository is.
|
181
|
+
test_files: []
|