kusameter 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
+ SHA256:
3
+ metadata.gz: 28ba868a9d5bf6bc11810650cb371bfaa57b459159ab30992efca1fc45d3bdae
4
+ data.tar.gz: fdb47839bd5232b2d76ecc8cbdd1872d2f3773eed8f7ae6f2fd0d24c33c06d8d
5
+ SHA512:
6
+ metadata.gz: 5382af9708f4a1cb383366133a108ac05c2dae0a8a961f8455b035adadf477b679dc9fba2e4a03a4407d8062d0d478b9efa18434fe023e731cf8ff94e3e2253f
7
+ data.tar.gz: 6ce4568b40dedfec2f287ae1585acf323830ce2e760f42a55e3b7c8b8c6b441bed4a546bdd414e61371b68212a167768d81b5d0dcc541f8d8fa869c04b5fee6c
data/.rubocop.yml ADDED
@@ -0,0 +1,3 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ TargetRubyVersion: 3.3
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [1.0.0] - 2026-10-06
6
+
7
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # Kusameter
2
+
3
+ Kusameter measures a GitHub user's contributions to public repositories over a date range. It counts pull requests, commits, issues, and pull request reviews through a Ruby API or a CLI.
4
+
5
+ Ruby 3.3 or later is required. The gem has no runtime dependencies outside Ruby's standard library.
6
+
7
+ ## Install
8
+
9
+ Add `gem "kusameter"` to your Gemfile, or run `gem install kusameter` after the gem has been published. From a checkout, run `bundle install` and use `bundle exec exe/kusameter`.
10
+
11
+ Set a GitHub token with access to public repositories. `KUSAMETER_TOKEN` takes precedence over `GITHUB_TOKEN`; a token set through `Kusameter.configure` takes precedence over both. The CLI accepts tokens only through the environment.
12
+
13
+ ```sh
14
+ export KUSAMETER_TOKEN=your_token
15
+ bundle exec exe/kusameter octocat --year 2025
16
+ ```
17
+
18
+ ## CLI
19
+
20
+ ```sh
21
+ kusameter octocat
22
+ kusameter octocat --from 2023-04-01 --to 2026-03-31 --exclude-own
23
+ kusameter octocat --year 2025 --exclude-forks --by-repo
24
+ kusameter octocat --year 2025 --period 1Q
25
+ kusameter octocat --year 2025 --period H1
26
+ kusameter alice bob --year 2025 --format csv > contributions.csv
27
+ kusameter octocat --source search --merged-only --format json
28
+ ```
29
+
30
+ Without dates, the CLI covers the last year through today. `--year YYYY` selects a calendar year. Add `--period` to select a quarter or half-year within that year; without `--year`, it uses the current year. Periods use calendar boundaries: `1Q` is January–March and `H1` is January–June. `--year` and `--period` cannot be combined with `--from` or `--to`. Future end dates are capped at today.
31
+
32
+ | Option | Meaning |
33
+ | --- | --- |
34
+ | `--from DATE`, `--to DATE` | Inclusive ISO 8601 dates |
35
+ | `--year YYYY` | Calendar year |
36
+ | `--period NAME` | `1Q`–`4Q`, `H1`/`H2`, or the aliases `上期`/`下期` |
37
+ | `--source graphql\|search` | Data source; GraphQL is the default |
38
+ | `--exclude-own`, `--exclude-forks` | Exclude repositories owned by the user or forks |
39
+ | `--exclude-org NAME`, `--include-org NAME` | Exclude or include repository owners; repeatable |
40
+ | `--merged-only` | Count only merged PRs; requires Search |
41
+ | `--by-repo` | Include a repository breakdown in table or JSON output |
42
+ | `--format table\|json\|csv` | Output format; table is the default |
43
+ | `--time-zone TZ` | Date boundary zone, such as `UTC`, `Asia/Tokyo`, or `+09:00` |
44
+ | `-h`, `--help`; `-v`, `--version` | Help or version |
45
+
46
+ Exit codes are 0 for success, 1 for an API or other error, 2 for invalid input, 3 for missing credentials or authentication failure, 4 for a rate limit, and 5 for a missing user. With multiple users, successful results are still printed if another user fails; the exit code reflects the first failure.
47
+
48
+ ## Ruby API
49
+
50
+ ```ruby
51
+ require "kusameter"
52
+
53
+ result = Kusameter.stats("octocat", from: "2025-01-01", to: "2025-12-31", exclude_own: true)
54
+ puts result.pull_requests
55
+ puts result.commits
56
+ puts result.issues
57
+ puts result.reviews
58
+ puts result.total
59
+ result.by_repository.each { |repo| puts "#{repo.name_with_owner}: #{repo.commits} commits" }
60
+ puts result.warnings if result.truncated?
61
+
62
+ results = Kusameter.stats_for(%w[alice bob], from: "2025-01-01", to: "2025-12-31")
63
+ ```
64
+
65
+ `Kusameter.stats` accepts `Date`, `Time`, or ISO 8601 date strings for `from` and `to`. Its result exposes the four counts, `period`, `source`, `by_repository`, `warnings`, `total`, `truncated?`, and `to_h`.
66
+
67
+ ```ruby
68
+ Kusameter.configure do |config|
69
+ config.token = ENV.fetch("MY_GITHUB_TOKEN")
70
+ config.source = :graphql
71
+ config.time_zone = "Asia/Tokyo"
72
+ config.timeout = 30
73
+ config.max_retries = 3
74
+ config.max_rate_limit_wait = 60
75
+ end
76
+ ```
77
+
78
+ For GitHub Enterprise Server, also set `api_endpoint` to `https://HOST/api/v3` and `graphql_endpoint` to `https://HOST/api/graphql`.
79
+
80
+ ## Counting rules and limits
81
+
82
+ GraphQL is the default source. It follows [GitHub's contribution rules](https://docs.github.com/en/account-and-profile/reference/profile-contributions-reference) and counts only public repositories. A commit must be linked to the user and present on the default branch or `gh-pages` to count. Commits that exist only on a fork do not count. GraphQL review contributions reflect [GitHub's contribution collection](https://docs.github.com/en/graphql/reference/users), which represents the latest submitted review per reviewed PR. A range longer than one year is divided into one-year requests and summed.
83
+
84
+ GraphQL returns at most 100 repositories per metric and request. When a response reaches that limit and its repository counts fall short of the collection totals, Kusameter splits the date range and retries each half. It continues until the counts are complete or the range is a single day. Any remaining difference produces a warning; filtered counts always come from the returned repositories, and `truncated?` reports that warning.
85
+
86
+ The optional Search source counts PRs, issues, and commits with GitHub Search. Its review metric counts reviewed PRs by PR creation date, so it is an approximation of reviews submitted during the requested period. Search does not provide a repository breakdown and cannot exclude forks; it warns when `exclude_forks` is requested or GitHub reports incomplete results. `merged_only` applies only to the PR metric. Search requests are spaced by at least two seconds to respect its tighter rate limit. Date boundaries for Search use GitHub's date query syntax; `time_zone` controls GraphQL boundaries and the default end date.
87
+
88
+ API rate limits are retried up to `max_retries` when the required wait does not exceed `max_rate_limit_wait`. Otherwise `Kusameter::RateLimitError` provides `reset_at`. Authentication, missing users, invalid periods, and other API failures raise subclasses of `Kusameter::Error`.
89
+
90
+ ## Development
91
+
92
+ ```sh
93
+ bundle install
94
+ bundle exec rake
95
+ bundle exec rubocop --only Lint
96
+ bundle exec rbs validate
97
+ bundle exec steep check
98
+ ```
99
+
100
+ Tests use fake HTTP responses and do not call GitHub. `bundle exec rake` enforces at least 90% line coverage and checks the public API facade with Steep. The project is licensed under [MIT](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ ENV["KUSAMETER_COVERAGE"] = "1"
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ task :lint do
10
+ sh "bundle exec rubocop --only Lint"
11
+ sh "bundle exec rbs validate"
12
+ sh "bundle exec steep check"
13
+ end
14
+
15
+ task default: %i[spec lint]
data/Steepfile ADDED
@@ -0,0 +1,5 @@
1
+ target :public_api do
2
+ library "date"
3
+ signature "sig"
4
+ check "lib/kusameter.rb"
5
+ end
data/exe/kusameter ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "kusameter"
5
+
6
+ exit Kusameter::CLI.new.run(ARGV)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kusameter
4
+ class Calculator
5
+ def initialize(source:)
6
+ @source = source
7
+ end
8
+
9
+ def call(login:, period:, filter:)
10
+ windows = @source.max_period ? period.split : [period]
11
+ windows.map { |window| @source.fetch(login:, period: window, filter:) }
12
+ .reduce(:merge).with(period:)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Kusameter
6
+ class CLI
7
+ def initialize(stdout: $stdout, stderr: $stderr)
8
+ @stdout = stdout
9
+ @stderr = stderr
10
+ end
11
+
12
+ def run(args)
13
+ options = { exclude_orgs: [], include_orgs: [] }
14
+ format = "table"
15
+ by_repo = false
16
+ year = nil
17
+ period = nil
18
+ zone = nil
19
+ parser = OptionParser.new do |opts|
20
+ opts.banner = "Usage: kusameter [options] LOGIN [LOGIN ...]"
21
+ opts.on("--from DATE", "Start date") { |value| options[:from] = value }
22
+ opts.on("--to DATE", "End date") { |value| options[:to] = value }
23
+ opts.on("--year YYYY", "Calendar year") { |value| year = value }
24
+ opts.on("--period NAME", "1Q, 2Q, 3Q, 4Q, H1, or H2") { |value| period = value }
25
+ opts.on("--source NAME", "graphql or search") { |value| options[:source] = value.to_sym }
26
+ opts.on("--exclude-own", "Exclude repositories owned by the user") { options[:exclude_own] = true }
27
+ opts.on("--exclude-forks", "Exclude forks") { options[:exclude_forks] = true }
28
+ opts.on("--exclude-org NAME", "Exclude an owner") { |value| options[:exclude_orgs] << value }
29
+ opts.on("--include-org NAME", "Include an owner") { |value| options[:include_orgs] << value }
30
+ opts.on("--merged-only", "Count merged PRs (search only)") { options[:merged_only] = true }
31
+ opts.on("--by-repo", "Show repository breakdown") { by_repo = true }
32
+ opts.on("--format NAME", "table, json, or csv") { |value| format = value }
33
+ opts.on("--time-zone TZ", "Date boundary time zone") { |value| zone = value }
34
+ opts.on("-v", "--version", "Show version") { @stdout.puts(VERSION); return 0 }
35
+ opts.on("-h", "--help", "Show help") { @stdout.puts(opts); return 0 }
36
+ end
37
+ logins = parser.parse(args.dup)
38
+ raise ArgumentError, "at least one LOGIN is required" if logins.empty?
39
+ raise ArgumentError, "--year cannot be combined with --from or --to" if year && (options[:from] || options[:to])
40
+ raise ArgumentError, "--period cannot be combined with --from or --to" if period && (options[:from] || options[:to])
41
+ raise ArgumentError, "invalid year" if year && !year.match?(/\A\d{4}\z/)
42
+ raise ArgumentError, "invalid format" unless %w[table json csv].include?(format)
43
+ raise ArgumentError, "invalid source" if options[:source] && !%i[graphql search].include?(options[:source])
44
+ raise ArgumentError, "--merged-only requires --source search" if options[:merged_only] && (options[:source] || configuration.source) != :search
45
+
46
+ if zone
47
+ original_zone = configuration.time_zone
48
+ configuration.time_zone = zone
49
+ begin
50
+ configuration.today
51
+ rescue ConfigurationError => error
52
+ raise ArgumentError, error.message
53
+ end
54
+ end
55
+ if period
56
+ ranges = { "1Q" => [0, 3], "2Q" => [3, 3], "3Q" => [6, 3], "4Q" => [9, 3],
57
+ "H1" => [0, 6], "H2" => [6, 6], "上期" => [0, 6], "下期" => [6, 6] }
58
+ offset, months = ranges[period.upcase]
59
+ raise ArgumentError, "invalid period" unless offset
60
+
61
+ start_date = Date.new(year ? year.to_i : configuration.today.year, 1, 1) >> offset
62
+ options[:from] = start_date
63
+ options[:to] = (start_date >> months) - 1
64
+ elsif year
65
+ options[:from] = Date.new(year.to_i, 1, 1)
66
+ options[:to] = Date.new(year.to_i, 12, 31)
67
+ end
68
+ results = []
69
+ exit_code = 0
70
+ logins.each do |login|
71
+ results << Kusameter.stats(login, **options)
72
+ rescue Error, ArgumentError => error
73
+ exit_code = error_code(error) if exit_code.zero?
74
+ @stderr.puts(redact("#{login}: #{error.message}"))
75
+ end
76
+ unless results.empty?
77
+ formatter = { "table" => Formatters::Table, "json" => Formatters::Json, "csv" => Formatters::Csv }.fetch(format)
78
+ @stdout.print(formatter.new.call(results, by_repo:))
79
+ results.each { |result| result.warnings.each { |warning| @stderr.puts("#{result.login}: #{warning}") } } unless format == "json"
80
+ end
81
+ exit_code
82
+ rescue OptionParser::ParseError, ArgumentError, InvalidPeriodError => error
83
+ @stderr.puts(redact(error.message))
84
+ 2
85
+ rescue ConfigurationError, AuthenticationError => error
86
+ @stderr.puts(redact(error.message))
87
+ 3
88
+ ensure
89
+ configuration.time_zone = original_zone if original_zone
90
+ end
91
+
92
+ private
93
+
94
+ def configuration = Kusameter.configuration
95
+
96
+ def redact(message)
97
+ secret = configuration.token || ENV["KUSAMETER_TOKEN"] || ENV["GITHUB_TOKEN"]
98
+ secret && !secret.empty? ? message.gsub(secret, "[FILTERED]") : message
99
+ end
100
+
101
+ def error_code(error)
102
+ case error
103
+ when InvalidPeriodError, ArgumentError then 2
104
+ when ConfigurationError, AuthenticationError then 3
105
+ when RateLimitError then 4
106
+ when UserNotFoundError then 5
107
+ else 1
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Kusameter
6
+ class Configuration
7
+ ZONE_MUTEX = Mutex.new
8
+ attr_accessor :token, :api_endpoint, :graphql_endpoint, :source, :time_zone,
9
+ :timeout, :max_retries, :max_rate_limit_wait, :logger
10
+
11
+ def initialize
12
+ @token = nil
13
+ @api_endpoint = "https://api.github.com"
14
+ @graphql_endpoint = "https://api.github.com/graphql"
15
+ @source = :graphql
16
+ @time_zone = "UTC"
17
+ @timeout = 30
18
+ @max_retries = 3
19
+ @max_rate_limit_wait = 60
20
+ @logger = nil
21
+ end
22
+
23
+ def resolved_token
24
+ value = token || ENV["KUSAMETER_TOKEN"] || ENV["GITHUB_TOKEN"]
25
+ raise ConfigurationError, "GitHub token is required" if value.nil? || value.empty?
26
+
27
+ value
28
+ end
29
+
30
+ def inspect
31
+ description = "#<#{self.class} token=[FILTERED] api_endpoint=#{api_endpoint.inspect} " \
32
+ "graphql_endpoint=#{graphql_endpoint.inspect} source=#{source.inspect} time_zone=#{time_zone.inspect}>"
33
+ secret = token || ENV["KUSAMETER_TOKEN"] || ENV["GITHUB_TOKEN"]
34
+ secret && !secret.empty? ? description.gsub(secret, "[FILTERED]") : description
35
+ end
36
+
37
+ def today = in_time_zone { |offset| offset ? Time.now.getlocal(offset).to_date : Time.now.to_date }
38
+
39
+ def start_of(date)
40
+ in_time_zone { |offset| local_midnight(date, offset).iso8601 }
41
+ end
42
+
43
+ def end_of(date)
44
+ following = date + 1
45
+ in_time_zone { |offset| (local_midnight(following, offset) - Rational(1, 1_000_000)).iso8601(6) }
46
+ end
47
+
48
+ private
49
+
50
+ def local_midnight(date, offset)
51
+ offset ? Time.new(date.year, date.month, date.day, 0, 0, 0, offset) : Time.local(date.year, date.month, date.day)
52
+ end
53
+
54
+ def in_time_zone
55
+ zone = time_zone.to_s
56
+ offset = zone.match?(/\A[+-](?:0\d|1[0-4]):[0-5]\d\z/)
57
+ valid = zone == "UTC" || offset ||
58
+ (zone.match?(/\A[A-Za-z_]+(?:\/[A-Za-z_]+)+\z/) && File.file?("/usr/share/zoneinfo/#{zone}"))
59
+ raise ConfigurationError, "invalid time zone" unless valid
60
+ return yield zone if offset
61
+
62
+ # ponytail: TZ is process-wide; use a timezone library if concurrent calls need different zones.
63
+ ZONE_MUTEX.synchronize do
64
+ previous = ENV["TZ"]
65
+ ENV["TZ"] = zone
66
+ begin
67
+ yield
68
+ ensure
69
+ previous ? ENV["TZ"] = previous : ENV.delete("TZ")
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kusameter
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+ class InvalidPeriodError < Error; end
7
+ class AuthenticationError < Error; end
8
+ class UserNotFoundError < Error; end
9
+
10
+ class RateLimitError < Error
11
+ attr_reader :reset_at
12
+
13
+ def initialize(message = "GitHub API rate limit exceeded", reset_at: nil)
14
+ @reset_at = reset_at
15
+ super(message)
16
+ end
17
+ end
18
+
19
+ class APIError < Error
20
+ attr_reader :status, :body
21
+
22
+ def initialize(message = "GitHub API error", status: nil, body: nil)
23
+ @status = status
24
+ @body = body
25
+ super(message)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kusameter
4
+ Filter = Data.define(:exclude_own, :exclude_forks, :exclude_orgs, :include_orgs, :merged_only) do
5
+ def initialize(exclude_own: false, exclude_forks: false, exclude_orgs: [], include_orgs: [], merged_only: false)
6
+ excluded = Array(exclude_orgs)
7
+ included = Array(include_orgs)
8
+ names = excluded + included
9
+ raise ArgumentError, "invalid repository owner" unless names.all? { |name| name.is_a?(String) && name.match?(/\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\z/) }
10
+
11
+ super(exclude_own:, exclude_forks:, exclude_orgs: excluded, include_orgs: included, merged_only:)
12
+ end
13
+
14
+ def match?(repo, login:)
15
+ owner = repo.fetch("owner").fetch("login")
16
+ return false if repo.fetch("isPrivate")
17
+ return false if exclude_own && owner.casecmp?(login)
18
+ return false if exclude_forks && repo.fetch("isFork")
19
+ return false if exclude_orgs.any? { |name| owner.casecmp?(name) }
20
+ return false if include_orgs.any? && include_orgs.none? { |name| owner.casecmp?(name) }
21
+
22
+ true
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kusameter
4
+ module Formatters
5
+ class Csv
6
+ HEADER = %w[login from to pull_requests commits issues reviews].freeze
7
+
8
+ def call(results, **_options)
9
+ rows = results.map do |result|
10
+ [result.login, result.period.from, result.period.to, result.pull_requests,
11
+ result.commits, result.issues, result.reviews]
12
+ end
13
+ ([HEADER] + rows).map { |row| row.join(",") }.join("\n") + "\n"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Kusameter
6
+ module Formatters
7
+ class Json
8
+ def call(results, by_repo: false)
9
+ values = results.map do |result|
10
+ hash = result.to_h
11
+ hash.delete(:by_repository) unless by_repo
12
+ hash
13
+ end
14
+ JSON.pretty_generate(values.length == 1 ? values.first : values) + "\n"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kusameter
4
+ module Formatters
5
+ class Table
6
+ def call(results, by_repo: false)
7
+ rows = results.map do |result|
8
+ [result.login, "#{result.period.from}..#{result.period.to}", result.pull_requests,
9
+ result.commits, result.issues, result.reviews].map(&:to_s)
10
+ end
11
+ lines = render(%w[LOGIN PERIOD PRS COMMITS ISSUES REVIEWS], rows)
12
+ if by_repo
13
+ results.each do |result|
14
+ next if result.by_repository.empty?
15
+
16
+ detail = result.by_repository.map do |repo|
17
+ [repo.name_with_owner, repo.pull_requests, repo.commits, repo.issues, repo.reviews].map(&:to_s)
18
+ end
19
+ lines << "\n#{result.login} repositories:"
20
+ lines.concat(render(%w[REPOSITORY PRS COMMITS ISSUES REVIEWS], detail))
21
+ end
22
+ end
23
+ lines.join("\n") + "\n"
24
+ end
25
+
26
+ private
27
+
28
+ def render(header, rows)
29
+ widths = header.each_index.map { |index| ([header[index]] + rows.map { |row| row[index] }).map(&:length).max }
30
+ ([header] + rows).map do |row|
31
+ row.each_with_index.map { |value, index| value.ljust(widths[index]) }.join(" ").rstrip
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Kusameter
8
+ module HTTP
9
+ class Client
10
+ TRANSIENT = [502, 503, 504].freeze
11
+ NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED].freeze
12
+
13
+ def initialize(configuration, transport: nil, sleeper: Kernel.method(:sleep))
14
+ @configuration = configuration
15
+ @transport = transport || method(:default_transport)
16
+ @sleeper = sleeper
17
+ end
18
+
19
+ def graphql(query, variables)
20
+ uri = endpoint(@configuration.graphql_endpoint)
21
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
22
+ request.body = JSON.generate(query:, variables:)
23
+ parse(perform(uri, request), graphql: true)
24
+ end
25
+
26
+ def get(path, params = {})
27
+ base = @configuration.api_endpoint.chomp("/")
28
+ uri = endpoint("#{base}#{path}")
29
+ uri.query = URI.encode_www_form(params)
30
+ parse(perform(uri, Net::HTTP::Get.new(uri.request_uri, headers)))
31
+ end
32
+
33
+ private
34
+
35
+ def endpoint(value)
36
+ uri = URI.parse(value)
37
+ raise ConfigurationError, "API endpoint must use HTTPS" unless uri.is_a?(URI::HTTPS) && uri.host && !uri.userinfo
38
+
39
+ uri
40
+ rescue URI::InvalidURIError
41
+ raise ConfigurationError, "invalid API endpoint"
42
+ end
43
+
44
+ def headers
45
+ { "Authorization" => "Bearer #{@configuration.resolved_token}",
46
+ "User-Agent" => "kusameter/#{VERSION}",
47
+ "Accept" => "application/vnd.github+json",
48
+ "Content-Type" => "application/json" }
49
+ end
50
+
51
+ def default_transport(uri, request)
52
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: @configuration.timeout,
53
+ read_timeout: @configuration.timeout) { |http| http.request(request) }
54
+ end
55
+
56
+ def perform(uri, request)
57
+ retries = 0
58
+ loop do
59
+ response = @transport.call(uri, request)
60
+ status = response.code.to_i
61
+ if rate_limited?(response)
62
+ reset_at, wait = rate_wait(response)
63
+ raise RateLimitError.new(reset_at:) if wait > @configuration.max_rate_limit_wait || retries >= @configuration.max_retries
64
+
65
+ @sleeper.call(wait)
66
+ elsif TRANSIENT.include?(status)
67
+ raise APIError.new("GitHub API unavailable", status:) if retries >= @configuration.max_retries
68
+
69
+ @sleeper.call(2**retries)
70
+ else
71
+ return response
72
+ end
73
+ retries += 1
74
+ rescue *NETWORK_ERRORS
75
+ raise APIError, "GitHub API connection failed" if retries >= @configuration.max_retries
76
+
77
+ @sleeper.call(2**retries)
78
+ retries += 1
79
+ end
80
+ end
81
+
82
+ def rate_limited?(response)
83
+ [403, 429].include?(response.code.to_i) &&
84
+ (response.code.to_i == 429 || response["x-ratelimit-remaining"] == "0" || response["retry-after"])
85
+ end
86
+
87
+ def rate_wait(response)
88
+ if response["retry-after"]
89
+ wait = [response["retry-after"].to_f, 0].max
90
+ [Time.now + wait, wait]
91
+ elsif response["x-ratelimit-reset"]
92
+ reset_at = Time.at(response["x-ratelimit-reset"].to_i)
93
+ [reset_at, [reset_at - Time.now, 0].max]
94
+ else
95
+ [Time.now, 0]
96
+ end
97
+ end
98
+
99
+ def parse(response, graphql: false)
100
+ status = response.code.to_i
101
+ raise AuthenticationError, "GitHub authentication failed" if status == 401
102
+ raise APIError.new("GitHub API returned HTTP #{status}", status:, body: safe_body(response.body)) unless status.between?(200, 299)
103
+
104
+ data = JSON.parse(response.body)
105
+ return data unless graphql && data["errors"]&.any?
106
+
107
+ raise UserNotFoundError, "GitHub user not found" if data["errors"].any? { |error| error["type"] == "NOT_FOUND" }
108
+
109
+ raise APIError, "GitHub GraphQL request failed"
110
+ rescue JSON::ParserError
111
+ raise APIError.new("Invalid GitHub API response", status:)
112
+ end
113
+
114
+ def safe_body(body)
115
+ body.to_s.gsub(@configuration.resolved_token, "[FILTERED]")
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Kusameter
6
+ Period = Data.define(:from, :to) do
7
+ def initialize(from:, to:)
8
+ from = parse_date(from)
9
+ to = parse_date(to)
10
+ raise InvalidPeriodError, "from must be on or before to" if from > to
11
+
12
+ super
13
+ end
14
+
15
+ def split
16
+ windows = []
17
+ cursor = from
18
+ while cursor <= to
19
+ finish = [cursor.next_year - 1, to].min
20
+ windows << self.class.new(from: cursor, to: finish)
21
+ cursor = finish + 1
22
+ end
23
+ windows
24
+ end
25
+
26
+ private
27
+
28
+ def parse_date(value)
29
+ case value
30
+ when Date then value
31
+ when Time then value.to_date
32
+ when String then Date.iso8601(value)
33
+ else raise InvalidPeriodError, "date must be a Date, Time, or ISO 8601 string"
34
+ end
35
+ rescue Date::Error
36
+ raise InvalidPeriodError, "invalid ISO 8601 date"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kusameter
4
+ RepositoryStat = Data.define(:name_with_owner, :pull_requests, :commits, :issues, :reviews) do
5
+ def to_h = { name_with_owner:, pull_requests:, commits:, issues:, reviews: }
6
+ end
7
+
8
+ Result = Data.define(:login, :period, :source, :pull_requests, :commits, :issues, :reviews, :by_repository, :warnings) do
9
+ def merge(other)
10
+ raise ArgumentError, "cannot merge different users or sources" unless login == other.login && source == other.source
11
+
12
+ repos = (by_repository + other.by_repository).group_by { |repo| repo.name_with_owner.downcase }.values.map do |items|
13
+ RepositoryStat.new(name_with_owner: items.first.name_with_owner,
14
+ **count_keys.to_h { |key| [key, items.sum(&key)] })
15
+ end
16
+ self.class.new(login:, source:, period: Period.new(from: [period.from, other.period.from].min,
17
+ to: [period.to, other.period.to].max),
18
+ **count_keys.to_h { |key| [key, public_send(key) + other.public_send(key)] },
19
+ by_repository: repos.sort_by(&:name_with_owner), warnings: (warnings + other.warnings).uniq)
20
+ end
21
+
22
+ def total = count_keys.sum { |key| public_send(key) }
23
+ def truncated? = warnings.any? { |warning| warning.start_with?("Truncated") }
24
+
25
+ def to_h
26
+ { login:, period: { from: period.from.iso8601, to: period.to.iso8601 }, source:,
27
+ **count_keys.to_h { |key| [key, public_send(key)] },
28
+ by_repository: by_repository.map(&:to_h), warnings: }
29
+ end
30
+
31
+ private
32
+
33
+ def count_keys = %i[pull_requests commits issues reviews]
34
+ end
35
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kusameter
4
+ module Sources
5
+ class GraphQL
6
+ QUERY = <<~GRAPHQL.freeze
7
+ query($login: String!, $from: DateTime!, $to: DateTime!) {
8
+ user(login: $login) {
9
+ contributionsCollection(from: $from, to: $to) {
10
+ totalCommitContributions
11
+ totalIssueContributions
12
+ totalPullRequestContributions
13
+ totalPullRequestReviewContributions
14
+ commitContributionsByRepository(maxRepositories: 100) {
15
+ repository { ...Repo }
16
+ contributions { totalCount }
17
+ }
18
+ issueContributionsByRepository(maxRepositories: 100) {
19
+ repository { ...Repo }
20
+ contributions { totalCount }
21
+ }
22
+ pullRequestContributionsByRepository(maxRepositories: 100) {
23
+ repository { ...Repo }
24
+ contributions { totalCount }
25
+ }
26
+ pullRequestReviewContributionsByRepository(maxRepositories: 100) {
27
+ repository { ...Repo }
28
+ contributions { totalCount }
29
+ }
30
+ }
31
+ }
32
+ }
33
+ fragment Repo on Repository {
34
+ nameWithOwner
35
+ isPrivate
36
+ isFork
37
+ owner { login }
38
+ }
39
+ GRAPHQL
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kusameter
4
+ module Sources
5
+ class GraphQL
6
+ FIELDS = {
7
+ pull_requests: %w[pullRequestContributionsByRepository totalPullRequestContributions],
8
+ commits: %w[commitContributionsByRepository totalCommitContributions],
9
+ issues: %w[issueContributionsByRepository totalIssueContributions],
10
+ reviews: %w[pullRequestReviewContributionsByRepository totalPullRequestReviewContributions]
11
+ }.freeze
12
+
13
+ def initialize(client:, configuration:)
14
+ @client = client
15
+ @configuration = configuration
16
+ end
17
+
18
+ def max_period = :year
19
+
20
+ def fetch(login:, period:, filter:)
21
+ variables = { login:, from: @configuration.start_of(period.from), to: @configuration.end_of(period.to) }
22
+ response = @client.graphql(QUERY, variables)
23
+ errors = response["errors"] || []
24
+ raise UserNotFoundError, "GitHub user not found" if errors.any? { |error| error["type"] == "NOT_FOUND" }
25
+ raise APIError, "GitHub GraphQL request failed" if errors.any?
26
+
27
+ user = response.dig("data", "user")
28
+ raise UserNotFoundError, "GitHub user not found" unless user
29
+
30
+ collection = user.fetch("contributionsCollection")
31
+ counts = {}
32
+ repositories = {}
33
+ warnings = []
34
+ limited = false
35
+ FIELDS.each do |metric, (field, total_field)|
36
+ entries = collection.fetch(field)
37
+ visible = entries.sum { |entry| entry.dig("contributions", "totalCount") }
38
+ if visible < collection.fetch(total_field)
39
+ at_limit = entries.length == 100
40
+ reason = at_limit ? "100 repository limit" : "repository totals differ"
41
+ warnings << "Truncated #{metric} contributions (#{reason})"
42
+ limited ||= at_limit
43
+ end
44
+ counts[metric] = 0
45
+ entries.each do |entry|
46
+ repo = entry.fetch("repository")
47
+ next unless filter.match?(repo, login:)
48
+
49
+ count = entry.dig("contributions", "totalCount")
50
+ counts[metric] += count
51
+ name = repo.fetch("nameWithOwner")
52
+ repositories[name] ||= { pull_requests: 0, commits: 0, issues: 0, reviews: 0 }
53
+ repositories[name][metric] += count
54
+ end
55
+ end
56
+
57
+ breakdown = repositories.map { |name, values| RepositoryStat.new(name_with_owner: name, **values) }
58
+ result = Result.new(login:, period:, source: :graphql, **counts,
59
+ by_repository: breakdown.sort_by(&:name_with_owner), warnings:)
60
+ return result unless limited && period.from < period.to
61
+
62
+ middle = period.from + ((period.to - period.from).to_i / 2)
63
+ first = Period.new(from: period.from, to: middle)
64
+ second = Period.new(from: middle + 1, to: period.to)
65
+ fetch(login:, period: first, filter:).merge(fetch(login:, period: second, filter:))
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ require_relative "graphql/query"
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kusameter
4
+ module Sources
5
+ class Search
6
+ class QueryBuilder
7
+ def build(metric:, login:, period:, filter:, owner: nil, owner_type: nil)
8
+ names = [login, owner, *filter.exclude_orgs].compact
9
+ raise ArgumentError, "invalid GitHub login or owner" unless names.all? { |name| name.match?(/\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\z/) }
10
+
11
+ range = "#{period.from.iso8601}..#{period.to.iso8601}"
12
+ terms = case metric
13
+ when :pull_requests then ["type:pr", "author:#{login}", "created:#{range}"]
14
+ when :issues then ["type:issue", "author:#{login}", "created:#{range}"]
15
+ when :reviews then ["type:pr", "reviewed-by:#{login}", "-author:#{login}", "created:#{range}"]
16
+ when :commits then ["author:#{login}", "author-date:#{range}"]
17
+ else raise ArgumentError, "unknown metric"
18
+ end
19
+ terms << "is:public"
20
+ terms << "is:merged" if metric == :pull_requests && filter.merged_only
21
+ terms << "-user:#{login}" if filter.exclude_own
22
+ filter.exclude_orgs.each do |name|
23
+ terms.concat(["-org:#{name}", "-user:#{name}"])
24
+ end
25
+ terms << "#{owner_type == "Organization" ? "org" : "user"}:#{owner}" if owner
26
+ terms.join(" ")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kusameter
4
+ module Sources
5
+ class Search
6
+ PATHS = { pull_requests: "/search/issues", issues: "/search/issues",
7
+ reviews: "/search/issues", commits: "/search/commits" }.freeze
8
+
9
+ def initialize(client:, interval: 2, sleeper: Kernel.method(:sleep))
10
+ @client = client
11
+ @interval = interval
12
+ @sleeper = sleeper
13
+ @query_builder = QueryBuilder.new
14
+ end
15
+
16
+ def max_period = nil
17
+
18
+ def fetch(login:, period:, filter:)
19
+ owners = if filter.include_orgs.empty?
20
+ [[nil, nil]]
21
+ else
22
+ filter.include_orgs.uniq { |name| name.downcase }.map do |name|
23
+ [name, @client.get("/users/#{name}").fetch("type")]
24
+ end
25
+ end
26
+ counts = {}
27
+ warnings = ["Search reviews count reviewed PRs by PR creation date, not submitted reviews"]
28
+ warnings << "Search cannot exclude forks; exclude_forks was ignored" if filter.exclude_forks
29
+ requested = false
30
+ PATHS.each do |metric, path|
31
+ counts[metric] = 0
32
+ owners.each do |owner, owner_type|
33
+ @sleeper.call(@interval) if requested && @interval.positive?
34
+ query = @query_builder.build(metric:, login:, period:, filter:, owner:, owner_type:)
35
+ response = @client.get(path, q: query, per_page: 1)
36
+ requested = true
37
+ counts[metric] += response.fetch("total_count")
38
+ warnings << "Search results may be incomplete" if response["incomplete_results"]
39
+ end
40
+ end
41
+ Result.new(login:, period:, source: :search, **counts, by_repository: [], warnings: warnings.uniq)
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ require_relative "search/query_builder"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kusameter
4
+ VERSION = "0.1.0"
5
+ end
data/lib/kusameter.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "kusameter/version"
4
+ require_relative "kusameter/errors"
5
+ require_relative "kusameter/period"
6
+ require_relative "kusameter/filter"
7
+ require_relative "kusameter/result"
8
+ require_relative "kusameter/configuration"
9
+ require_relative "kusameter/calculator"
10
+ require_relative "kusameter/http/client"
11
+ require_relative "kusameter/sources/graphql"
12
+ require_relative "kusameter/sources/search"
13
+ require_relative "kusameter/formatters/table"
14
+ require_relative "kusameter/formatters/json"
15
+ require_relative "kusameter/formatters/csv"
16
+ require_relative "kusameter/cli"
17
+
18
+ module Kusameter
19
+ def self.configuration = (@configuration ||= Configuration.new)
20
+
21
+ def self.configure
22
+ yield configuration
23
+ end
24
+
25
+ def self.stats(login, from: nil, to: nil, source: nil, **filter_options)
26
+ raise ArgumentError, "invalid GitHub login" unless login.is_a?(String) && login.match?(/\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\z/)
27
+
28
+ source ||= configuration.source
29
+ raise ConfigurationError, "source must be graphql or search" unless %i[graphql search].include?(source)
30
+
31
+ filter = Filter.new(**filter_options)
32
+ raise ConfigurationError, "merged_only requires the search source" if filter.merged_only && source == :graphql
33
+
34
+ today = configuration.today
35
+ finish = to ? Period.new(from: to, to: to).to : today
36
+ finish = [finish, today].min
37
+ start = from || (finish.prev_year + 1)
38
+ period = Period.new(from: start, to: finish)
39
+ client = HTTP::Client.new(configuration)
40
+ adapter = source == :graphql ? Sources::GraphQL.new(client:, configuration:) : Sources::Search.new(client:)
41
+ Calculator.new(source: adapter).call(login:, period:, filter:)
42
+ end
43
+
44
+ def self.stats_for(logins, **options)
45
+ logins.map { |login| stats(login, **options) }
46
+ end
47
+ end
data/sig/internal.rbs ADDED
@@ -0,0 +1,22 @@
1
+ module Kusameter
2
+ class Calculator
3
+ def initialize: (source: Sources::GraphQL | Sources::Search) -> void
4
+ def call: (login: String, period: Period, filter: Filter) -> Result
5
+ end
6
+
7
+ module HTTP
8
+ class Client
9
+ def initialize: (Configuration) -> void
10
+ end
11
+ end
12
+
13
+ module Sources
14
+ class GraphQL
15
+ def initialize: (client: HTTP::Client, configuration: Configuration) -> void
16
+ end
17
+
18
+ class Search
19
+ def initialize: (client: HTTP::Client) -> void
20
+ end
21
+ end
22
+ end
data/sig/kusameter.rbs ADDED
@@ -0,0 +1,103 @@
1
+ module Kusameter
2
+ VERSION: String
3
+
4
+ type date_input = Date | Time | String
5
+ type source = :graphql | :search
6
+
7
+ def self.configuration: () -> Configuration
8
+ def self.configure: () { (Configuration) -> void } -> void
9
+ def self.stats: (String login, ?from: date_input?, ?to: date_input?, ?source: source?,
10
+ ?exclude_own: bool, ?exclude_forks: bool, ?exclude_orgs: Array[String],
11
+ ?include_orgs: Array[String], ?merged_only: bool) -> Result
12
+ def self.stats_for: (Array[String] logins, **untyped options) -> Array[Result]
13
+
14
+ class Error < StandardError
15
+ end
16
+ class ConfigurationError < Error
17
+ end
18
+ class InvalidPeriodError < Error
19
+ end
20
+ class AuthenticationError < Error
21
+ end
22
+ class UserNotFoundError < Error
23
+ end
24
+ class RateLimitError < Error
25
+ attr_reader reset_at: Time?
26
+ def initialize: (?String message, ?reset_at: Time?) -> void
27
+ end
28
+ class APIError < Error
29
+ attr_reader status: Integer?
30
+ attr_reader body: String?
31
+ def initialize: (?String message, ?status: Integer?, ?body: String?) -> void
32
+ end
33
+
34
+ class Configuration
35
+ attr_accessor token: String?
36
+ attr_accessor api_endpoint: String
37
+ attr_accessor graphql_endpoint: String
38
+ attr_accessor source: source
39
+ attr_accessor time_zone: String
40
+ attr_accessor timeout: Integer
41
+ attr_accessor max_retries: Integer
42
+ attr_accessor max_rate_limit_wait: Integer
43
+ attr_accessor logger: untyped
44
+ def initialize: () -> void
45
+ def resolved_token: () -> String
46
+ def today: () -> Date
47
+ def start_of: (Date date) -> String
48
+ def end_of: (Date date) -> String
49
+ end
50
+
51
+ class Period
52
+ attr_reader from: Date
53
+ attr_reader to: Date
54
+ def initialize: (from: date_input, to: date_input) -> void
55
+ def split: () -> Array[Period]
56
+ def to_h: () -> Hash[Symbol, Date]
57
+ def with: (**untyped changes) -> Period
58
+ end
59
+
60
+ class Filter
61
+ attr_reader exclude_own: bool
62
+ attr_reader exclude_forks: bool
63
+ attr_reader exclude_orgs: Array[String]
64
+ attr_reader include_orgs: Array[String]
65
+ attr_reader merged_only: bool
66
+ def initialize: (?exclude_own: bool, ?exclude_forks: bool, ?exclude_orgs: Array[String],
67
+ ?include_orgs: Array[String], ?merged_only: bool) -> void
68
+ def match?: (Hash[String, untyped] repo, login: String) -> bool
69
+ def with: (**untyped changes) -> Filter
70
+ end
71
+
72
+ class RepositoryStat
73
+ attr_reader name_with_owner: String
74
+ attr_reader pull_requests: Integer
75
+ attr_reader commits: Integer
76
+ attr_reader issues: Integer
77
+ attr_reader reviews: Integer
78
+ def initialize: (name_with_owner: String, pull_requests: Integer, commits: Integer,
79
+ issues: Integer, reviews: Integer) -> void
80
+ def to_h: () -> Hash[Symbol, String | Integer]
81
+ def with: (**untyped changes) -> RepositoryStat
82
+ end
83
+
84
+ class Result
85
+ attr_reader login: String
86
+ attr_reader period: Period
87
+ attr_reader source: source
88
+ attr_reader pull_requests: Integer
89
+ attr_reader commits: Integer
90
+ attr_reader issues: Integer
91
+ attr_reader reviews: Integer
92
+ attr_reader by_repository: Array[RepositoryStat]
93
+ attr_reader warnings: Array[String]
94
+ def initialize: (login: String, period: Period, source: source,
95
+ pull_requests: Integer, commits: Integer, issues: Integer, reviews: Integer,
96
+ by_repository: Array[RepositoryStat], warnings: Array[String]) -> void
97
+ def merge: (Result other) -> Result
98
+ def total: () -> Integer
99
+ def truncated?: () -> bool
100
+ def to_h: () -> Hash[Symbol, untyped]
101
+ def with: (**untyped changes) -> Result
102
+ end
103
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kusameter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Count public pull requests, commits, issues, and reviews with a Ruby
13
+ API and CLI.
14
+ email:
15
+ - t.yudai92@gmail.com
16
+ executables:
17
+ - kusameter
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - ".rubocop.yml"
22
+ - CHANGELOG.md
23
+ - LICENSE.txt
24
+ - README.md
25
+ - Rakefile
26
+ - Steepfile
27
+ - exe/kusameter
28
+ - lib/kusameter.rb
29
+ - lib/kusameter/calculator.rb
30
+ - lib/kusameter/cli.rb
31
+ - lib/kusameter/configuration.rb
32
+ - lib/kusameter/errors.rb
33
+ - lib/kusameter/filter.rb
34
+ - lib/kusameter/formatters/csv.rb
35
+ - lib/kusameter/formatters/json.rb
36
+ - lib/kusameter/formatters/table.rb
37
+ - lib/kusameter/http/client.rb
38
+ - lib/kusameter/period.rb
39
+ - lib/kusameter/result.rb
40
+ - lib/kusameter/sources/graphql.rb
41
+ - lib/kusameter/sources/graphql/query.rb
42
+ - lib/kusameter/sources/search.rb
43
+ - lib/kusameter/sources/search/query_builder.rb
44
+ - lib/kusameter/version.rb
45
+ - sig/internal.rbs
46
+ - sig/kusameter.rbs
47
+ homepage: https://github.com/ydah/kusameter
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ rubygems_mfa_required: 'true'
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: 3.3.0
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 4.0.16
67
+ specification_version: 4
68
+ summary: Measure GitHub open source contributions by user and date range
69
+ test_files: []