class GitHubIssueStats

Constants

VERSION

Attributes

client[RW]
logger[RW]
sleep_period[RW]

Public Class Methods

new(token, verbose=false) click to toggle source
# File lib/github_issue_stats.rb, line 39
def initialize(token, verbose=false)
  @logger = Logger.new(STDERR)
  @logger.sev_threshold = verbose ? Logger::DEBUG : Logger::WARN

  @logger.debug "Creating new GitHubIssueStats instance."

  @logger.debug "Creating a new Octokit client with token #{token[0..5]}"

  begin
    @client = Octokit::Client.new(
      :access_token => token,
      :auto_paginate => false,
      :user_agent => "GitHubIssueStats/#{VERSION} (@izuzak) #{Octokit.user_agent}"
    )

    @client.rate_limit
  rescue Octokit::Unauthorized => exception
    @logger.error "Token #{token[0..5]} is not valid"
    raise ArgumentError.new("Token #{token[0..5]} is not valid")
  end

  @logger.debug "Token #{token[0..5]} is valid"
end

Public Instance Methods

compute_previous_time(current_time, period) click to toggle source

Computes the the beginning of the period based on the end of a period

# File lib/github_issue_stats.rb, line 236
def compute_previous_time(current_time, period)
  period_number, period_type = [period[0..-2], period[-1]]
  period_number = Integer(period_number)

  if period_type == "h"
    return current_time - period_number * 3600
  elsif period_type == "d"
    return current_time - period_number * 3600 * 24
  elsif period_type == "w"
    return current_time - period_number * 7 * 3600 * 24
  elsif period_type == "m"
    current_date = Date.new(current_time.year, current_time.month, current_time.day)

    temp_date = current_date
    for i in 1..period_number
      previous_date = temp_date.prev_month
      temp_date = previous_date
    end

    previous_time = Time.utc(previous_date.year, previous_date.month, previous_date.day, current_time.hour, current_time.min, current_time.sec)
  elsif period_type == "y"
    return Time.utc(current_time.year - period_number, current_time.month, current_time.day, current_time.hour, current_time.min, current_time.sec)
  else
    # TODO throw error
  end
end
generate_breakdown_tables(stats, options) click to toggle source
# File lib/github_issue_stats.rb, line 495
def generate_breakdown_tables(stats, options)
  def get_headers(labels, scope, output_format)
    if output_format == "markdown"
      return labels.map do |label|
        query_string = get_search_query_string({:scope => scope, :label => label, :state => "open"})
        "[#{label}](#{get_search_url(query_string)})"
      end
    else
      return labels
    end
  end

  def get_interval_humanized_name(interval)
    period_number, period_type = [interval[0..-2], interval[-1]]
    period_number = Integer(period_number)

    names = {
      "h" => ["hour", "hours"],
      "d" => ["day", "days"],
      "w" => ["week", "weeks"],
      "m" => ["month", "months"],
      "y" => ["year", "years"]
    }

    if period_number == 1
      return "#{period_number} #{names[period_type][0]}"
    else
      return "#{period_number} #{names[period_type][1]}"
    end
  end

  def get_period_date(timestamp, period_type)
    if period_type == "h"
      return timestamp.strftime "%Y-%m-%d %H:00"
    elsif period_type == "d"
      return timestamp.strftime "%Y-%m-%d"
    elsif period_type == "w"
      return timestamp.strftime "%Y-%m-%d"
    elsif period_type == "m"
      return timestamp.strftime "%Y-%m"
    elsif period_type == "y"
      return timestamp.strftime "%Y"
    else
      # TODO throw error
    end
  end

  def get_smallest_period_type(interval_types)
    intervals = ["h", "d", "w", "m", "y"]
    interval_types_indexes = interval_types.map { |interval_type| intervals.index(interval_type) }
    interval_types_indexes << 1

    return intervals[interval_types_indexes.min]
  end

  def get_period_name(slice, intervals, index, type)
    current_interval = intervals[index]
    current_period_number, current_period_type = [current_interval[0..-2], current_interval[-1]]
    current_period_number = Integer(current_period_number)

    if index == 0
      smaller_period_type = get_smallest_period_type([current_period_type])

      return "< #{get_interval_humanized_name(current_interval)} (#{get_period_date(slice[:previous_timestamp], smaller_period_type)})"
    else
      previous_interval = intervals[index-1]
      previous_period_number, previous_period_type = [previous_interval[0..-2], previous_interval[-1]]
      previous_period_number = Integer(previous_period_number)
      smaller_period_type = get_smallest_period_type([current_period_type, previous_period_type])

      return "> #{get_interval_humanized_name(previous_interval)}, < #{get_interval_humanized_name(current_interval)} (#{get_period_date(slice[:previous_timestamp], smaller_period_type)})"
    end
  end

  def get_period_stats(slice, labels, scope, type)
    return labels.map do |label|
      if type == "markdown"
        "[#{slice[scope][label][:interval_still_open_total]}](#{slice[scope][label][:interval_still_open_total_url]})"
      else
        "#{slice[scope][label][:interval_still_open_total]}"
      end
    end
  end

  tables = {}

  for scope in options[:scopes]
    data = []

    data << ["period"] + get_headers(options[:labels], scope, options[:output_format])
    stats.each_with_index do |slice, index|
      data << [get_period_name(slice, options[:intervals], index, options[:output_format])] + get_period_stats(slice, options[:labels], scope, options[:output_format])
    end

    tables[scope] = options[:output_format] == "markdown" ? generate_markdown_table_string(data) : generate_text_table_string(data)
  end

  return tables
end
generate_history_tables(stats, options) click to toggle source

Generates tables for collected statistics, for easy copy-pasting

# File lib/github_issue_stats.rb, line 339
def generate_history_tables(stats, options)
  def get_headers(labels, scope, output_format)
    if output_format == "markdown"
      return labels.map do |label|
        query_string = get_search_query_string({:scope => scope, :label => label, :state => "open"})
        "[#{label}](#{get_search_url(query_string)})"
      end
    else
      return labels
    end
  end

  def get_period_humanized_name(slice, period_type, index)
    names = {
      "h" => ["Now", "1 hour ago", "hours"],
      "d" => ["Today", "Yesterday", "days"],
      "w" => ["This week", "Last week", "weeks"],
      "m" => ["This month", "Last month", "months"],
      "y" => ["This year", "Last year", "years"]
    }

    if index < 2
      return names[period_type][index]
    else
      return "#{index} #{names[period_type][2]} ago"
    end
  end

  def get_period_date(slice, period_type)
    if period_type == "h"
      return slice[:previous_timestamp].strftime "%Y-%m-%d %H:00"
    elsif period_type == "d"
      return slice[:previous_timestamp].strftime "%Y-%m-%d"
    elsif period_type == "w"
      return slice[:previous_timestamp].strftime "%Y-%m-%d"
    elsif period_type == "m"
      return slice[:previous_timestamp].strftime "%Y-%m"
    elsif period_type == "y"
      return slice[:previous_timestamp].strftime "%Y"
    else
      # TODO throw error
    end
  end

  def get_period_name(slice, interval, index, type)
    period_number, period_type = interval.chars
    if type == "markdown"
      return "**#{get_period_humanized_name(slice, period_type, index)}** <br>(#{get_period_date(slice, period_type)})"
    else
      return "#{get_period_humanized_name(slice, period_type, index)} (#{get_period_date(slice, period_type)})"
    end
  end

  def get_period_stats(slice, labels, scope, type)
    def get_difference_string(stats)
      difference_string = "+#{stats[:interval_new_total]}, -#{stats[:interval_closed_total]}"

      # TODO: maybe something like this in the future
      # difference = stats[:interval_new_total] - stats[:interval_closed_total]
      # difference_string = "#{difference}, +#{stats[:interval_new_total]}, -#{stats[:interval_closed_total]}"
      #
      # return "▲" + difference_string if difference > 0
      # return "▼" + difference_string if difference < 0
      # return "▶" + difference_string
    end

    if type == "markdown"
      return labels.map do |label|
        "**#{slice[scope][label][:interval_end_total]}** <br>(#{get_difference_string(slice[scope][label])})"
      end
    else
      return labels.map do |label|
        "#{slice[scope][label][:interval_end_total]} (#{get_difference_string(slice[scope][label])})"
      end
    end
  end

  tables = {}

  for scope in options[:scopes]
    data = []

    data << ["period"] + get_headers(options[:labels], scope, options[:output_format])
    stats.each_with_index do |slice, index|
      data << [get_period_name(slice, options[:interval_length], index, options[:output_format])] + get_period_stats(slice, options[:labels], scope, options[:output_format])
    end

    tables[scope] = options[:output_format] == "markdown" ? generate_markdown_table_string(data) : generate_text_table_string(data)
  end

  return tables
end
generate_markdown_table_string(data) click to toggle source
# File lib/github_issue_stats.rb, line 436
def generate_markdown_table_string(data)
  data.to_markdown_table
end
generate_text_table_string(data) click to toggle source
# File lib/github_issue_stats.rb, line 432
def generate_text_table_string(data)
  return data.to_table(:first_row_is_head => true).to_s
end
get_beginning_of_current_period(current_time, period) click to toggle source

Returns the timestamps for the beginning of the current period

# File lib/github_issue_stats.rb, line 213
def get_beginning_of_current_period(current_time, period)
  period_type = period[1]

  if period_type == "h"
    return Time.utc(current_time.year, current_time.month, current_time.day, current_time.hour, 0, 0)
  elsif period_type == "d"
    return Time.utc(current_time.year, current_time.month, current_time.day, 0, 0, 0)
  elsif period_type == "w"
    current_date = Date.new(current_time.year, current_time.month, current_time.day)
    previous_date = current_date - (current_date.cwday - 1)
    previous_time = Time.utc(previous_date.year, previous_date.month, previous_date.day, 0, 0, 0)
  elsif period_type == "m"
    return Time.utc(current_time.year, current_time.month, 1, 0, 0, 0)
  elsif period_type == "y"
    return Time.utc(current_time.year, 1, 1, 0, 0, 0)
  else
    # TODO throw error
  end
end
get_breakdown_statistics(options) click to toggle source
# File lib/github_issue_stats.rb, line 440
def get_breakdown_statistics(options)
  stats = []
  current_timestamp = Time.now.utc
  for interval in options[:intervals]
    stats << get_breakdown_stats_for_interval(interval, current_timestamp, stats[-1], options)
  end

  return stats
end
get_breakdown_stats_for_interval(interval, current_timestamp, previous_slice, options) click to toggle source
# File lib/github_issue_stats.rb, line 450
def get_breakdown_stats_for_interval(interval, current_timestamp, previous_slice, options)
  slice = {}

  # set timestamps

  if previous_slice.nil? # initial
    slice[:current_timestamp] = current_timestamp
  else # not initial
    slice[:current_timestamp] = previous_slice[:previous_timestamp]
  end

  slice[:previous_timestamp] = compute_previous_time(current_timestamp, interval)

  for scope in options[:scopes]
    scope_stats = {}
    slice[scope] = scope_stats

    for label in options[:labels]
      label_stats = {}
      scope_stats[label] = label_stats

      # number of open issues in period

      search_options = {
        :scope => scope,
        :label => label,
        :state => "open",
        :created_at => {
          :from => slice[:previous_timestamp],
          :until => slice[:current_timestamp]
        }
      }

      query_string = get_search_query_string(search_options)

      label_stats[:interval_still_open_total_url] = get_search_url(query_string)
      label_stats[:interval_still_open_total] = get_search_total_results(query_string)

      @logger.debug "Computed total for interval: #{label_stats[:interval_still_open_total]}"
    end
  end

  return slice
end
get_difference_string(stats) click to toggle source
# File lib/github_issue_stats.rb, line 393
def get_difference_string(stats)
  difference_string = "+#{stats[:interval_new_total]}, -#{stats[:interval_closed_total]}"

  # TODO: maybe something like this in the future
  # difference = stats[:interval_new_total] - stats[:interval_closed_total]
  # difference_string = "#{difference}, +#{stats[:interval_new_total]}, -#{stats[:interval_closed_total]}"
  #
  # return "▲" + difference_string if difference > 0
  # return "▼" + difference_string if difference < 0
  # return "▶" + difference_string
end
get_headers(labels, scope, output_format) click to toggle source
# File lib/github_issue_stats.rb, line 340
def get_headers(labels, scope, output_format)
  if output_format == "markdown"
    return labels.map do |label|
      query_string = get_search_query_string({:scope => scope, :label => label, :state => "open"})
      "[#{label}](#{get_search_url(query_string)})"
    end
  else
    return labels
  end
end
get_history_statistics(options) click to toggle source

Collect and return statistics

Input:

options = {

:interval_length => "1w",             # 1 week interval
:interval_count => 2,                 # 2 intervals to collect data for
:scopes => ["atom", "atom/atom"],     # atom user and atom/atom repo
:labels => ["issues", "pulls", "bug"] # issues, pulls, and bug label

}

Output:

[

{                                     # each interval will be represented as hash
  :interval_end_timestamp => Time,    # end of interval
  :interval_start_timestamp => Time,  # beginning of interval
  "atom" => {                         # each scope will have a key and hash value
     "issues" => {                    # each label will have a key and hash value
       :interval_end_total => 1,      # number of items at end of period
       :interval_beginning_total => 2,# number of items at beginning of period
       :interval_new_total => 3,        # number of new items during period
       :interval_closed_total => 4      # number of closed items during period
     }
   }
}

]

# File lib/github_issue_stats.rb, line 92
def get_history_statistics(options)
  # number_of_calls = get_required_number_of_api_calls(options)
  # @sleep_period = get_api_calls_sleep(number_of_calls)

  stats = []
  for i in 1..options[:interval_count]
    stats << get_stats_for_interval(stats[-1], options)
  end

  return stats
end
get_interval_humanized_name(interval) click to toggle source
# File lib/github_issue_stats.rb, line 507
def get_interval_humanized_name(interval)
  period_number, period_type = [interval[0..-2], interval[-1]]
  period_number = Integer(period_number)

  names = {
    "h" => ["hour", "hours"],
    "d" => ["day", "days"],
    "w" => ["week", "weeks"],
    "m" => ["month", "months"],
    "y" => ["year", "years"]
  }

  if period_number == 1
    return "#{period_number} #{names[period_type][0]}"
  else
    return "#{period_number} #{names[period_type][1]}"
  end
end
get_period_date(slice, period_type) click to toggle source
# File lib/github_issue_stats.rb, line 367
def get_period_date(slice, period_type)
  if period_type == "h"
    return slice[:previous_timestamp].strftime "%Y-%m-%d %H:00"
  elsif period_type == "d"
    return slice[:previous_timestamp].strftime "%Y-%m-%d"
  elsif period_type == "w"
    return slice[:previous_timestamp].strftime "%Y-%m-%d"
  elsif period_type == "m"
    return slice[:previous_timestamp].strftime "%Y-%m"
  elsif period_type == "y"
    return slice[:previous_timestamp].strftime "%Y"
  else
    # TODO throw error
  end
end
get_period_humanized_name(slice, period_type, index) click to toggle source
# File lib/github_issue_stats.rb, line 351
def get_period_humanized_name(slice, period_type, index)
  names = {
    "h" => ["Now", "1 hour ago", "hours"],
    "d" => ["Today", "Yesterday", "days"],
    "w" => ["This week", "Last week", "weeks"],
    "m" => ["This month", "Last month", "months"],
    "y" => ["This year", "Last year", "years"]
  }

  if index < 2
    return names[period_type][index]
  else
    return "#{index} #{names[period_type][2]} ago"
  end
end
get_period_name(slice, interval, index, type) click to toggle source
# File lib/github_issue_stats.rb, line 383
def get_period_name(slice, interval, index, type)
  period_number, period_type = interval.chars
  if type == "markdown"
    return "**#{get_period_humanized_name(slice, period_type, index)}** <br>(#{get_period_date(slice, period_type)})"
  else
    return "#{get_period_humanized_name(slice, period_type, index)} (#{get_period_date(slice, period_type)})"
  end
end
get_period_stats(slice, labels, scope, type) click to toggle source
# File lib/github_issue_stats.rb, line 392
def get_period_stats(slice, labels, scope, type)
  def get_difference_string(stats)
    difference_string = "+#{stats[:interval_new_total]}, -#{stats[:interval_closed_total]}"

    # TODO: maybe something like this in the future
    # difference = stats[:interval_new_total] - stats[:interval_closed_total]
    # difference_string = "#{difference}, +#{stats[:interval_new_total]}, -#{stats[:interval_closed_total]}"
    #
    # return "▲" + difference_string if difference > 0
    # return "▼" + difference_string if difference < 0
    # return "▶" + difference_string
  end

  if type == "markdown"
    return labels.map do |label|
      "**#{slice[scope][label][:interval_end_total]}** <br>(#{get_difference_string(slice[scope][label])})"
    end
  else
    return labels.map do |label|
      "#{slice[scope][label][:interval_end_total]} (#{get_difference_string(slice[scope][label])})"
    end
  end
end
get_required_number_of_api_calls(options) click to toggle source

Computes the number of search API calls to collect all the data

# File lib/github_issue_stats.rb, line 266
def get_required_number_of_api_calls(options)
  return options[:scopes].size * options[:labels].size * (2 * options[:interval_count] + 1)
end
get_search_query_string(options) click to toggle source

Construct the search query string based on different options.

# File lib/github_issue_stats.rb, line 296
def get_search_query_string(options)
  query = ""

  if options[:scope].include?("/")
    query += "repo:#{options[:scope]} "
  else
    query += "user:#{options[:scope]} "
  end

  if options[:label] == "issues"
    query += "is:issue "
  elsif options[:label] == "pulls"
    query += "is:pr "
  else
    query += "label:\"#{options[:label]}\" "
  end

  if !options[:state].nil?
    query += "is:#{options[:state]} "
  end

  if !options[:created_at].nil?
    query += "created:#{options[:created_at][:from].iso8601()}..#{options[:created_at][:until].iso8601()} "
  end

  if !options[:closed_at].nil?
    query += "closed:#{options[:closed_at][:from].iso8601()}..#{options[:closed_at][:until].iso8601()} "
  end

  return query.strip
end
get_search_total_results(query_string) click to toggle source

Call Search API for a query and return total number of results

# File lib/github_issue_stats.rb, line 189
def get_search_total_results(query_string)
  sleep_before_api_call()

  @logger.debug "Getting search results for query: #{query_string}"

  # Print something just so the user know something is going on
  if @logger.sev_threshold != Logger::DEBUG
    STDERR.print(".")
    STDERR.flush
  end

  result = @client.search_issues(query_string, {:per_page => 1})
  @logger.debug "Total count: #{result.total_count}"

  if result.incomplete_results
    @logger.error "Incomplete search API results for query #{query_string}"
  end

  return result.total_count
end
get_search_url(query_string) click to toggle source

Returns the github.com URL for viewing the list of issues which match the given query string

# File lib/github_issue_stats.rb, line 332
def get_search_url(query_string)
  return "https://github.com/issues?q=#{query_string}"
end
get_smallest_period_type(interval_types) click to toggle source
# File lib/github_issue_stats.rb, line 542
def get_smallest_period_type(interval_types)
  intervals = ["h", "d", "w", "m", "y"]
  interval_types_indexes = interval_types.map { |interval_type| intervals.index(interval_type) }
  interval_types_indexes << 1

  return intervals[interval_types_indexes.min]
end
get_stats_for_interval(previous_slice, options) click to toggle source

Collects statistics for a single interval

# File lib/github_issue_stats.rb, line 107
def get_stats_for_interval(previous_slice, options)
  slice = {}

  # set timestamps

  if previous_slice.nil? # initial
    slice[:current_timestamp] = Time.now.utc
    slice[:previous_timestamp] = get_beginning_of_current_period(slice[:current_timestamp], options[:interval_length])
  else # not initial
    slice[:current_timestamp] = previous_slice[:previous_timestamp]
    slice[:previous_timestamp] = compute_previous_time(slice[:current_timestamp], options[:interval_length])
  end

  for scope in options[:scopes]
    scope_stats = {}
    slice[scope] = scope_stats

    for label in options[:labels]
      label_stats = {}
      scope_stats[label] = label_stats

      # current state

      search_options = {
        :scope => scope,
        :label => label,
        :state => "open"
      }

      if previous_slice.nil?
        query_string = get_search_query_string(search_options)
        label_stats[:interval_end_total_url] = get_search_url(query_string)
        label_stats[:interval_end_total] = get_search_total_results(query_string)
      else
        label_stats[:interval_end_total] = previous_slice[scope][label][:interval_beginning_total]
      end

      # number of new issues in period

      search_options = {
        :scope => scope,
        :label => label,
        :created_at => {
          :from => slice[:previous_timestamp],
          :until => slice[:current_timestamp]
        }
      }

      query_string = get_search_query_string(search_options)
      label_stats[:interval_new_total_url] = get_search_url(query_string)
      label_stats[:interval_new_total] = get_search_total_results(query_string)

      # number of closed issues in period

      search_options = {
        :scope => scope,
        :label => label,
        :state => "closed",
        :closed_at => {
          :from => slice[:previous_timestamp],
          :until => slice[:current_timestamp]
        }
      }

      query_string = get_search_query_string(search_options)
      label_stats[:interval_closed_total_url] = get_search_url(query_string)
      label_stats[:interval_closed_total] = get_search_total_results(query_string)

      # number of issues in previous period

      label_stats[:interval_beginning_total] = label_stats[:interval_end_total] + label_stats[:interval_closed_total] - label_stats[:interval_new_total]

      @logger.debug "Computed total at beginning of interval: #{label_stats[:interval_beginning_total]}"
    end
  end

  return slice
end
sleep_before_api_call() click to toggle source

Computes the required sleep period to avoid hitting the API rate limits

# File lib/github_issue_stats.rb, line 273
  def sleep_before_api_call()
    @logger.debug "Calculating sleep period for next search API call"

    rate_limit_data = @client.get("https://api.github.com/rate_limit")

    if rate_limit_data[:resources][:core][:remaining] <= 2
      reset_timestamp = rate_limit_data[:resources][:core][:reset]
      sleep_seconds = reset_timestamp - Time.now.to_i + 3
      @logger.warn "Remaining regular API rate limit is close to 0, sleeping for #{sleep_seconds} seconds."
      sleep(sleep_seconds)
elsif rate_limit_data[:resources][:search][:remaining] <= 2
      reset_timestamp = rate_limit_data[:resources][:search][:reset]
      sleep_seconds = reset_timestamp - Time.now.to_i + 3
      @logger.warn "Remaining search API rate limit is close to 0, sleeping for #{sleep_seconds} seconds."
      sleep(sleep_seconds)
    elsif
      sleep(1)
    end
  end