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 +7 -0
- data/.rubocop.yml +3 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +100 -0
- data/Rakefile +15 -0
- data/Steepfile +5 -0
- data/exe/kusameter +6 -0
- data/lib/kusameter/calculator.rb +15 -0
- data/lib/kusameter/cli.rb +111 -0
- data/lib/kusameter/configuration.rb +74 -0
- data/lib/kusameter/errors.rb +28 -0
- data/lib/kusameter/filter.rb +25 -0
- data/lib/kusameter/formatters/csv.rb +17 -0
- data/lib/kusameter/formatters/json.rb +18 -0
- data/lib/kusameter/formatters/table.rb +36 -0
- data/lib/kusameter/http/client.rb +119 -0
- data/lib/kusameter/period.rb +39 -0
- data/lib/kusameter/result.rb +35 -0
- data/lib/kusameter/sources/graphql/query.rb +42 -0
- data/lib/kusameter/sources/graphql.rb +71 -0
- data/lib/kusameter/sources/search/query_builder.rb +31 -0
- data/lib/kusameter/sources/search.rb +47 -0
- data/lib/kusameter/version.rb +5 -0
- data/lib/kusameter.rb +47 -0
- data/sig/internal.rbs +22 -0
- data/sig/kusameter.rbs +103 -0
- metadata +69 -0
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
data/CHANGELOG.md
ADDED
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
data/exe/kusameter
ADDED
|
@@ -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"
|
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: []
|