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 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: []