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