github_issue_stats 0.1.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 +7 -0
- data/bin/github_issue_stats +109 -0
- data/lib/github_issue_stats.rb +421 -0
- metadata +81 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 3d2a2224ba34cdcf8773b071963589cd11301528
|
4
|
+
data.tar.gz: 1803971b22a7e9d595c475ce953d9943927e9946
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 928a826283caa5366d4aa9d8985205342e0695076bb894ae1c1ba9c56e95228986bd38c7cd390ba19f86bdcc8f19ce11c19b5e3e83d87480fd3bd7bb913d20ab
|
7
|
+
data.tar.gz: 6537b9071c607dc09f342efb91d84ad379578a6409578a2520e73988339f9e2c5c6e0152831ad09a14e12235fa1829cc5f90aea3d74a0051d503afbf894b9972
|
@@ -0,0 +1,109 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "optparse"
|
4
|
+
require "github_issue_stats"
|
5
|
+
|
6
|
+
ARGV.push('-h') if ARGV.empty?
|
7
|
+
|
8
|
+
version = "0.1.0"
|
9
|
+
options = {}
|
10
|
+
|
11
|
+
options[:verbose] = false
|
12
|
+
options[:output_format] = 'text'
|
13
|
+
options[:token] = ENV["GITHUB_OAUTH_TOKEN"]
|
14
|
+
options[:labels] = "issues"
|
15
|
+
options[:interval_length] = "1w"
|
16
|
+
options[:interval_count] = 4
|
17
|
+
|
18
|
+
opt_parser = OptionParser.new do |opts|
|
19
|
+
opts.banner = "GitHub Issue Stats -- simple program for collecting stats on issues in GitHub repositories.\n\nUsage: github_issue_stats [options]"
|
20
|
+
|
21
|
+
opts.separator ""
|
22
|
+
opts.separator "Specific options:"
|
23
|
+
|
24
|
+
opts.on("-t", "--token [STRING]", String,
|
25
|
+
"GitHub OAuth token for making API calls. If not specified,",
|
26
|
+
"the GITHUB_OAUTH_TOKEN environment variable is used.",
|
27
|
+
"Create a token here: https://github.com/settings/token", "\n") do |token|
|
28
|
+
options[:token] = token
|
29
|
+
end
|
30
|
+
|
31
|
+
opts.on("-s", "--scopes x,y,z", Array,
|
32
|
+
"List of scopes for which stats will be collected. A scope is",
|
33
|
+
"a username or repo name. Example: --scopes github,rails/rails", "\n") do |scopes|
|
34
|
+
options[:scopes] = scopes
|
35
|
+
end
|
36
|
+
|
37
|
+
opts.on("-l", "--labels [x,y,z]", Array,
|
38
|
+
"List of labels for which stats will be collected for each",
|
39
|
+
"scope. A label is an issue or pull request label, or special",
|
40
|
+
"values 'issues' and 'pulls' representing all issues and all",
|
41
|
+
"pull requests within the scope respectively. Default: 'issues'.",
|
42
|
+
"Example: --labels issues,bug,pulls", "\n") do |labels|
|
43
|
+
options[:labels] = labels
|
44
|
+
end
|
45
|
+
|
46
|
+
opts.on("-i", "--interval_length [STRING]", String,
|
47
|
+
"Size of interval for which stats will be aggregated. Intervals",
|
48
|
+
"are defined with N[hdwmy], where h is hour, d is day, w is week",
|
49
|
+
"m is month, y is year, and N is a positive integer used as a",
|
50
|
+
"multiplier. Default: '1w'. Example: --interval_length 4d", "\n") do |interval_length|
|
51
|
+
options[:interval_length] = interval_length
|
52
|
+
end
|
53
|
+
|
54
|
+
opts.on("-n", "--interval_count [INTEGER]", Integer,
|
55
|
+
"Number of intervals for which stats will be collected.",
|
56
|
+
"Default: 4. Example: --interval_count 2", "\n") do |interval_count|
|
57
|
+
options[:interval_count] = interval_count
|
58
|
+
end
|
59
|
+
|
60
|
+
opts.on("-o", "--output_format [STRING]", String,
|
61
|
+
"Format used for output tables with collected stats. Can be",
|
62
|
+
"'text' or 'markdown'. Default: 'text'. Example: -o markdown", "\n") do |output_format|
|
63
|
+
options[:output_format] = output_format
|
64
|
+
end
|
65
|
+
|
66
|
+
opts.on("--[no-]verbose", "Enable output of detailed debugging information to STDERR", "\n") do |verbose|
|
67
|
+
options[:verbose] = verbose
|
68
|
+
end
|
69
|
+
|
70
|
+
opts.on_tail("-h", "--help", "Show this message", "\n") do
|
71
|
+
STDERR.puts(opts)
|
72
|
+
exit
|
73
|
+
end
|
74
|
+
|
75
|
+
opts.on_tail("-v", "--version", "Show version", "\n") do
|
76
|
+
STDERR.puts(version)
|
77
|
+
exit
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
opt_parser.parse!
|
82
|
+
|
83
|
+
def log_input_error(message, opt_parser)
|
84
|
+
STDERR.puts("ERROR: #{message}\n\n")
|
85
|
+
STDERR.puts(opt_parser)
|
86
|
+
exit
|
87
|
+
end
|
88
|
+
|
89
|
+
log_input_error("--token is required", opt_parser) if options[:token].nil?
|
90
|
+
log_input_error("invalid --token format", opt_parser) unless /\A\h{40}\z/.match(options[:token])
|
91
|
+
log_input_error("--scopes is required", opt_parser) if options[:scopes].nil?
|
92
|
+
log_input_error("invalid --interval_length format", opt_parser) unless /\A\d[hdwmy]\z/.match(options[:interval_length])
|
93
|
+
log_input_error("invalid --interval_count format", opt_parser) if options[:interval_count].nil? || options[:interval_count] < 1
|
94
|
+
log_input_error("invalid --output_format", opt_parser) unless /\A(text)|(markdown)\z/.match(options[:output_format])
|
95
|
+
|
96
|
+
options[:scopes] = Array(options[:scopes])
|
97
|
+
options[:labels] = Array(options[:labels])
|
98
|
+
|
99
|
+
github_issue_stats = GitHubIssueStats.new(options[:token], options[:verbose])
|
100
|
+
|
101
|
+
STDERR.print "Collecting stats..."
|
102
|
+
STDERR.flush
|
103
|
+
|
104
|
+
stats = github_issue_stats.get_statistics(options)
|
105
|
+
tables = github_issue_stats.generate_tables(stats, options)
|
106
|
+
|
107
|
+
for scope, table in tables
|
108
|
+
puts "\n#{scope} stats:\n\n#{table}"
|
109
|
+
end
|
@@ -0,0 +1,421 @@
|
|
1
|
+
require "logger"
|
2
|
+
require "Octokit"
|
3
|
+
require "time"
|
4
|
+
require "text-table"
|
5
|
+
|
6
|
+
#
|
7
|
+
# Extend Text::Table with markdown support.
|
8
|
+
# Taken from https://github.com/aptinio/text-table/pull/10
|
9
|
+
#
|
10
|
+
class Text::Table
|
11
|
+
def to_markdown
|
12
|
+
b = @boundary_intersection
|
13
|
+
@boundary_intersection = '|'
|
14
|
+
rendered_rows = [separator] + text_table_rows.map(&:to_s)
|
15
|
+
rendered_rows.unshift [text_table_head.to_s] if head
|
16
|
+
@boundary_intersection = b
|
17
|
+
rendered_rows.join.gsub('|--', '| :').gsub('--|', ': |')
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
#
|
22
|
+
# Extend Enumberable classes with a to_markdown_table method
|
23
|
+
#
|
24
|
+
module Enumerable
|
25
|
+
def to_markdown_table(options = {})
|
26
|
+
table = Text::Table.new :rows => self.to_a.dup
|
27
|
+
table.head = table.rows.shift
|
28
|
+
table.to_markdown
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
Octokit.auto_paginate = true
|
33
|
+
|
34
|
+
class GitHubIssueStats
|
35
|
+
|
36
|
+
attr_accessor :client, # Octokit client for acesing the API
|
37
|
+
:logger, # Logger for writing debugging info
|
38
|
+
:sleep_period # Sleep period between Search API requests
|
39
|
+
|
40
|
+
def initialize(token, verbose=false)
|
41
|
+
@logger = Logger.new(STDERR)
|
42
|
+
@logger.sev_threshold = verbose ? Logger::DEBUG : Logger::WARN
|
43
|
+
|
44
|
+
@logger.debug "Creating new GitHubIssueStats instance."
|
45
|
+
|
46
|
+
@logger.debug "Creating a new Octokit client with token #{token[0..5]}"
|
47
|
+
|
48
|
+
begin
|
49
|
+
@client = Octokit::Client.new(:access_token => token)
|
50
|
+
@client.rate_limit
|
51
|
+
rescue Octokit::Unauthorized => exception
|
52
|
+
@logger.error "Token #{token[0..5]} is not valid"
|
53
|
+
raise ArgumentError.new("Token #{token[0..5]} is not valid")
|
54
|
+
end
|
55
|
+
|
56
|
+
@logger.debug "Token #{token[0..5]} is valid"
|
57
|
+
end
|
58
|
+
|
59
|
+
#
|
60
|
+
# Collect and return statistics
|
61
|
+
#
|
62
|
+
# Input:
|
63
|
+
#
|
64
|
+
# options = {
|
65
|
+
# :interval_length => "1w", # 1 week interval
|
66
|
+
# :interval_count => 2, # 2 intervals to collect data for
|
67
|
+
# :scopes => ["atom", "atom/atom"], # atom user and atom/atom repo
|
68
|
+
# :labels => ["issues", "pulls", "bug"] # issues, pulls, and bug label
|
69
|
+
# }
|
70
|
+
#
|
71
|
+
# Output:
|
72
|
+
#
|
73
|
+
# [
|
74
|
+
# { # each interval will be represented as hash
|
75
|
+
# :interval_end_timestamp => Time, # end of interval
|
76
|
+
# :interval_start_timestamp => Time, # beginning of interval
|
77
|
+
# "atom" => { # each scope will have a key and hash value
|
78
|
+
# "issues" => { # each label will have a key and hash value
|
79
|
+
# :interval_end_total => 1, # number of items at end of period
|
80
|
+
# :interval_beginning_total => 2,# number of items at beginning of period
|
81
|
+
# :interval_new_total => 3, # number of new items during period
|
82
|
+
# :interval_closed_total => 4 # number of closed items during period
|
83
|
+
# }
|
84
|
+
# }
|
85
|
+
# }
|
86
|
+
# ]
|
87
|
+
#
|
88
|
+
def get_statistics(options)
|
89
|
+
# number_of_calls = get_required_number_of_api_calls(options)
|
90
|
+
# @sleep_period = get_api_calls_sleep(number_of_calls)
|
91
|
+
|
92
|
+
stats = []
|
93
|
+
for i in 1..options[:interval_count]
|
94
|
+
stats << get_stats_for_interval(stats[-1], options)
|
95
|
+
end
|
96
|
+
|
97
|
+
return stats
|
98
|
+
end
|
99
|
+
|
100
|
+
#
|
101
|
+
# Collects statistics for a single interval
|
102
|
+
#
|
103
|
+
def get_stats_for_interval(previous_slice, options)
|
104
|
+
slice = {}
|
105
|
+
|
106
|
+
# set timestamps
|
107
|
+
|
108
|
+
if previous_slice.nil? # initial
|
109
|
+
slice[:current_timestamp] = Time.now.utc
|
110
|
+
slice[:previous_timestamp] = get_beginning_of_current_period(slice[:current_timestamp], options[:interval_length])
|
111
|
+
else # not initial
|
112
|
+
slice[:current_timestamp] = previous_slice[:previous_timestamp]
|
113
|
+
slice[:previous_timestamp] = compute_previous_time(slice[:current_timestamp], options[:interval_length])
|
114
|
+
end
|
115
|
+
|
116
|
+
for scope in options[:scopes]
|
117
|
+
scope_stats = {}
|
118
|
+
slice[scope] = scope_stats
|
119
|
+
|
120
|
+
for label in options[:labels]
|
121
|
+
label_stats = {}
|
122
|
+
scope_stats[label] = label_stats
|
123
|
+
|
124
|
+
# current state
|
125
|
+
|
126
|
+
search_options = {
|
127
|
+
:scope => scope,
|
128
|
+
:label => label,
|
129
|
+
:state => "open"
|
130
|
+
}
|
131
|
+
|
132
|
+
if previous_slice.nil?
|
133
|
+
query_string = get_search_query_string(search_options)
|
134
|
+
label_stats[:interval_end_total_url] = get_search_url(query_string)
|
135
|
+
label_stats[:interval_end_total] = get_search_total_results(query_string)
|
136
|
+
else
|
137
|
+
label_stats[:interval_end_total] = previous_slice[scope][label][:interval_beginning_total]
|
138
|
+
end
|
139
|
+
|
140
|
+
# number of new issues in period
|
141
|
+
|
142
|
+
search_options = {
|
143
|
+
:scope => scope,
|
144
|
+
:label => label,
|
145
|
+
:created_at => {
|
146
|
+
:from => slice[:previous_timestamp],
|
147
|
+
:until => slice[:current_timestamp]
|
148
|
+
}
|
149
|
+
}
|
150
|
+
|
151
|
+
query_string = get_search_query_string(search_options)
|
152
|
+
label_stats[:interval_new_total_url] = get_search_url(query_string)
|
153
|
+
label_stats[:interval_new_total] = get_search_total_results(query_string)
|
154
|
+
|
155
|
+
# number of closed issues in period
|
156
|
+
|
157
|
+
search_options = {
|
158
|
+
:scope => scope,
|
159
|
+
:label => label,
|
160
|
+
:state => "closed",
|
161
|
+
:closed_at => {
|
162
|
+
:from => slice[:previous_timestamp],
|
163
|
+
:until => slice[:current_timestamp]
|
164
|
+
}
|
165
|
+
}
|
166
|
+
|
167
|
+
query_string = get_search_query_string(search_options)
|
168
|
+
label_stats[:interval_closed_total_url] = get_search_url(query_string)
|
169
|
+
label_stats[:interval_closed_total] = get_search_total_results(query_string)
|
170
|
+
|
171
|
+
# number of issues in previous period
|
172
|
+
|
173
|
+
label_stats[:interval_beginning_total] = label_stats[:interval_end_total] + label_stats[:interval_closed_total] - label_stats[:interval_new_total]
|
174
|
+
|
175
|
+
@logger.debug "Computed total at beginning of interval: #{label_stats[:interval_beginning_total]}"
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
return slice
|
180
|
+
end
|
181
|
+
|
182
|
+
#
|
183
|
+
# Call Search API for a query and return total number of results
|
184
|
+
#
|
185
|
+
def get_search_total_results(query_string)
|
186
|
+
sleep_before_api_call()
|
187
|
+
|
188
|
+
@logger.debug "Getting search results for query: #{query_string}"
|
189
|
+
|
190
|
+
# Print something just so the user know something is going on
|
191
|
+
if @logger.sev_threshold != Logger::DEBUG
|
192
|
+
STDERR.print(".")
|
193
|
+
STDERR.flush
|
194
|
+
end
|
195
|
+
|
196
|
+
result = @client.search_issues(query_string)
|
197
|
+
@logger.debug "Total count: #{result.total_count}"
|
198
|
+
|
199
|
+
if result.incomplete_results
|
200
|
+
@logger.error "Incomplete search API results for query #{query_string}"
|
201
|
+
end
|
202
|
+
|
203
|
+
return result.total_count
|
204
|
+
end
|
205
|
+
|
206
|
+
#
|
207
|
+
# Returns the timestamps for the beginning of the current period
|
208
|
+
#
|
209
|
+
def get_beginning_of_current_period(current_time, period)
|
210
|
+
period_type = period[1]
|
211
|
+
|
212
|
+
if period_type == "h"
|
213
|
+
return Time.new(current_time.year, current_time.month, current_time.day, current_time.hour, 0, 0, "+00:00")
|
214
|
+
elsif period_type == "d"
|
215
|
+
return Time.new(current_time.year, current_time.month, current_time.day, 0, 0, 0, "+00:00")
|
216
|
+
elsif period_type == "w"
|
217
|
+
current_date = Date.new(current_time.year, current_time.month, current_time.day)
|
218
|
+
previous_date = current_date - (current_date.cwday - 1)
|
219
|
+
previous_time = Time.new(previous_date.year, previous_date.month, previous_date.day, 0, 0, 0, "+00:00")
|
220
|
+
elsif period_type == "m"
|
221
|
+
return Time.new(current_time.year, current_time.month, 1, 0, 0, 0, "+00:00")
|
222
|
+
elsif period_type == "y"
|
223
|
+
return Time.new(current_time.year, 1, 1, 0, 0, 0, "+00:00")
|
224
|
+
else
|
225
|
+
# TODO throw error
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
#
|
230
|
+
# Computes the the beginning of the period based on the end of a period
|
231
|
+
#
|
232
|
+
def compute_previous_time(current_time, period)
|
233
|
+
period_number, period_type = period.chars
|
234
|
+
period_number = Integer(period_number)
|
235
|
+
|
236
|
+
if period_type == "h"
|
237
|
+
return current_time - period_number * 3600
|
238
|
+
elsif period_type == "d"
|
239
|
+
return current_time - period_number * 3600 * 24
|
240
|
+
elsif period_type == "w"
|
241
|
+
return current_time - 7 * 3600 * 24
|
242
|
+
elsif period_type == "m"
|
243
|
+
current_date = Date.new(current_time.year, current_time.month, current_time.day)
|
244
|
+
previous_date = current_date.prev_month
|
245
|
+
previous_time = Time.new(previous_date.year, previous_date.month, previous_date.day, current_time.hour, current_time.min, current_time.sec, "+00:00")
|
246
|
+
elsif period_type == "y"
|
247
|
+
return Time.new(current_time.year - 1, current_time.month, current_time.day, current_time.hour, current_time.min, current_time.sec, "+00:00")
|
248
|
+
else
|
249
|
+
# TODO throw error
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
#
|
254
|
+
# Computes the number of search API calls to collect all the data
|
255
|
+
#
|
256
|
+
def get_required_number_of_api_calls(options)
|
257
|
+
return options[:scopes].size * options[:labels].size * (2 * options[:interval_count] + 1)
|
258
|
+
end
|
259
|
+
|
260
|
+
#
|
261
|
+
# Computes the required sleep period to avoid hitting the API rate limits
|
262
|
+
#
|
263
|
+
def sleep_before_api_call()
|
264
|
+
@logger.debug "Calculating sleep period for next search API call"
|
265
|
+
|
266
|
+
rate_limit_data = @client.get("https://api.github.com/rate_limit")
|
267
|
+
|
268
|
+
if rate_limit_data[:resources][:core][:remaining] == 0
|
269
|
+
reset_timestamp = rate_limit_data[:resources][:core][:reset]
|
270
|
+
sleep_seconds = reset_timestamp - Time.now.to_i
|
271
|
+
@logger.warn "Remaining regular API rate limit is 0, sleeping for #{sleep_seconds} seconds."
|
272
|
+
sleep(sleep_seconds)
|
273
|
+
elsif rate_limit_data[:resources][:search][:remaining] == 0
|
274
|
+
reset_timestamp = rate_limit_data[:resources][:search][:reset]
|
275
|
+
sleep_seconds = reset_timestamp - Time.now.to_i
|
276
|
+
@logger.warn "Remaining search API rate limit is 0, sleeping for #{sleep_seconds} seconds."
|
277
|
+
sleep(sleep_seconds)
|
278
|
+
elsif
|
279
|
+
sleep(1)
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
#
|
284
|
+
# Construct the search query string based on different options.
|
285
|
+
#
|
286
|
+
def get_search_query_string(options)
|
287
|
+
query = ""
|
288
|
+
|
289
|
+
if options[:scope].include?("/")
|
290
|
+
query += "repo:#{options[:scope]} "
|
291
|
+
else
|
292
|
+
query += "user:#{options[:scope]} "
|
293
|
+
end
|
294
|
+
|
295
|
+
if options[:label] == "issues"
|
296
|
+
query += "is:issue "
|
297
|
+
elsif options[:label] == "pulls"
|
298
|
+
query += "is:pr "
|
299
|
+
else
|
300
|
+
query += "label:#{options[:label]} "
|
301
|
+
end
|
302
|
+
|
303
|
+
if !options[:state].nil?
|
304
|
+
query += "is:#{options[:state]} "
|
305
|
+
end
|
306
|
+
|
307
|
+
if !options[:created_at].nil?
|
308
|
+
query += "created:#{options[:created_at][:from].iso8601()}..#{options[:created_at][:until].iso8601()} "
|
309
|
+
end
|
310
|
+
|
311
|
+
if !options[:closed_at].nil?
|
312
|
+
query += "closed:#{options[:closed_at][:from].iso8601()}..#{options[:closed_at][:until].iso8601()} "
|
313
|
+
end
|
314
|
+
|
315
|
+
return query.strip
|
316
|
+
end
|
317
|
+
|
318
|
+
#
|
319
|
+
# Returns the github.com URL for viewing the list of issues which match the
|
320
|
+
# given query string
|
321
|
+
#
|
322
|
+
def get_search_url(query_string)
|
323
|
+
return "https://github.com/issues?q=#{query_string}"
|
324
|
+
end
|
325
|
+
|
326
|
+
#
|
327
|
+
# Generates tables for collected statistics, for easy copy-pasting
|
328
|
+
#
|
329
|
+
def generate_tables(stats, options)
|
330
|
+
def get_headers(labels, scope, output_format)
|
331
|
+
if output_format == "markdown"
|
332
|
+
return labels.map do |label|
|
333
|
+
query_string = get_search_query_string({:scope => scope, :label => label, :state => "open"})
|
334
|
+
"[#{label}](#{get_search_url(query_string)})"
|
335
|
+
end
|
336
|
+
else
|
337
|
+
return labels
|
338
|
+
end
|
339
|
+
end
|
340
|
+
|
341
|
+
def get_period_humanized_name(slice, period_type, index)
|
342
|
+
names = {
|
343
|
+
"h" => ["Now", "1 hour ago", "hours"],
|
344
|
+
"d" => ["Today", "Yesterday", "days"],
|
345
|
+
"w" => ["This week", "Last week", "weeks"],
|
346
|
+
"m" => ["This month", "Last month", "months"],
|
347
|
+
"y" => ["This year", "Last year", "years"]
|
348
|
+
}
|
349
|
+
|
350
|
+
if index < 2
|
351
|
+
return names[period_type][index]
|
352
|
+
else
|
353
|
+
return "#{index} #{names[period_type][2]} ago"
|
354
|
+
end
|
355
|
+
end
|
356
|
+
|
357
|
+
def get_period_date(slice, period_type)
|
358
|
+
if period_type == "h"
|
359
|
+
return slice[:previous_timestamp].strftime "%Y-%m-%d %H:00"
|
360
|
+
elsif period_type == "d"
|
361
|
+
return slice[:previous_timestamp].strftime "%Y-%m-%d"
|
362
|
+
elsif period_type == "w"
|
363
|
+
return slice[:previous_timestamp].strftime "%Y-%m-%d"
|
364
|
+
elsif period_type == "m"
|
365
|
+
return slice[:previous_timestamp].strftime "%Y-%m"
|
366
|
+
elsif period_type == "y"
|
367
|
+
return slice[:previous_timestamp].strftime "%Y"
|
368
|
+
else
|
369
|
+
# TODO throw error
|
370
|
+
end
|
371
|
+
end
|
372
|
+
|
373
|
+
def get_period_name(slice, interval, index, type)
|
374
|
+
period_number, period_type = interval.chars
|
375
|
+
if type == "markdown"
|
376
|
+
return "**#{get_period_humanized_name(slice, period_type, index)}** <br>(#{get_period_date(slice, period_type)})"
|
377
|
+
else
|
378
|
+
return "#{get_period_humanized_name(slice, period_type, index)} (#{get_period_date(slice, period_type)})"
|
379
|
+
end
|
380
|
+
end
|
381
|
+
|
382
|
+
def get_period_stats(slice, labels, scope, type)
|
383
|
+
def get_difference_string(stats)
|
384
|
+
difference_string = "+#{stats[:interval_new_total]}, -#{stats[:interval_closed_total]}"
|
385
|
+
|
386
|
+
# TODO: maybe something like this in the future
|
387
|
+
# difference = stats[:interval_new_total] - stats[:interval_closed_total]
|
388
|
+
# difference_string = "#{difference}, +#{stats[:interval_new_total]}, -#{stats[:interval_closed_total]}"
|
389
|
+
#
|
390
|
+
# return "▲" + difference_string if difference > 0
|
391
|
+
# return "▼" + difference_string if difference < 0
|
392
|
+
# return "▶" + difference_string
|
393
|
+
end
|
394
|
+
|
395
|
+
if type == "markdown"
|
396
|
+
return labels.map do |label|
|
397
|
+
"**#{slice[scope][label][:interval_end_total]}** <br>(#{get_difference_string(slice[scope][label])})"
|
398
|
+
end
|
399
|
+
else
|
400
|
+
return labels.map do |label|
|
401
|
+
"#{slice[scope][label][:interval_end_total]} (#{get_difference_string(slice[scope][label])})"
|
402
|
+
end
|
403
|
+
end
|
404
|
+
end
|
405
|
+
|
406
|
+
tables = {}
|
407
|
+
|
408
|
+
for scope in options[:scopes]
|
409
|
+
data = []
|
410
|
+
|
411
|
+
data << ["period"] + get_headers(options[:labels], scope, options[:output_format])
|
412
|
+
stats.each_with_index do |slice, index|
|
413
|
+
data << [get_period_name(slice, options[:interval_length], index, options[:output_format])] + get_period_stats(slice, options[:labels], scope, options[:output_format])
|
414
|
+
end
|
415
|
+
|
416
|
+
tables[scope] = options[:output_format] == "markdown" ? data.to_markdown_table : data.to_table(:first_row_is_head => true).to_s
|
417
|
+
end
|
418
|
+
|
419
|
+
return tables
|
420
|
+
end
|
421
|
+
end
|
metadata
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: github_issue_stats
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ivan Zuzak
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-09-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: octokit
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '4.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '4.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: text-table
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.2'
|
34
|
+
- - ">="
|
35
|
+
- !ruby/object:Gem::Version
|
36
|
+
version: 1.2.4
|
37
|
+
type: :runtime
|
38
|
+
prerelease: false
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - "~>"
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '1.2'
|
44
|
+
- - ">="
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: 1.2.4
|
47
|
+
description:
|
48
|
+
email: izuzak@gmail.com
|
49
|
+
executables:
|
50
|
+
- github_issue_stats
|
51
|
+
extensions: []
|
52
|
+
extra_rdoc_files: []
|
53
|
+
files:
|
54
|
+
- bin/github_issue_stats
|
55
|
+
- lib/github_issue_stats.rb
|
56
|
+
homepage: https://github.com/izuzak/github_issue_stats
|
57
|
+
licenses:
|
58
|
+
- MIT
|
59
|
+
metadata: {}
|
60
|
+
post_install_message:
|
61
|
+
rdoc_options: []
|
62
|
+
require_paths:
|
63
|
+
- lib
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: '0'
|
74
|
+
requirements: []
|
75
|
+
rubyforge_project:
|
76
|
+
rubygems_version: 2.2.3
|
77
|
+
signing_key:
|
78
|
+
specification_version: 4
|
79
|
+
summary: Utility for collecting stats on number of open issues over time in GitHub
|
80
|
+
repositories.
|
81
|
+
test_files: []
|