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