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