code_review_leaderboard 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: 3ea19e0fe90de7406b88709534c96a763242d73b1261da1a6a537d8cfad02177
4
+ data.tar.gz: 1cdb5d628849d5efd3fca8239877ffd5b35697c1ff5372f3b8d5bf0ef7f6fdfd
5
+ SHA512:
6
+ metadata.gz: e9f929c12080ad9225cc22355acc68d0c9e307280cde01a852c0f53548608c905e5bcd19996473100c8ebc741adac629b566508714a7c8667bd006ff05cfff67
7
+ data.tar.gz: 73d9e6e9d18e66e93319c1cd96a1ac95a04eef267b495ef67cef585ddce5df87bc8b244d98713eaacfdda1978be634473ed8db9e3070a7471f8d42e25c9303c3
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-02-10
4
+
5
+ - Initial release
data/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # Code Review Leaderboard
2
+
3
+ Create a leaderboard of code reviewers for your repository or organization.
4
+
5
+ Find out who is the most active reviewer in your team, who is the most thorough, and who is the most critical.
6
+
7
+ ## Examples
8
+
9
+ <div align="center">
10
+ <img width="750" alt="Screenshot 2024-02-10 at 21 08 05" src="https://github.com/markokajzer/code-review-leaderboard/assets/9379317/4a774c51-5fc3-4c2d-bf0d-3d784dd4b04a">
11
+ </div>
12
+
13
+ Reviews are counted by the following rules:
14
+ 1. Completed within the last 30 days
15
+ 2. Multiple comments only count once.
16
+ 3. If a reviewer both approves/rejects a pull request _and_ comments, the comments do not count extra.
17
+ 4. Approvals and rejections always count.
18
+
19
+ For a repository of 718 repositories and 526 reviews, the script takes roughly 1 minute.
20
+
21
+ ## Installation
22
+
23
+ Install the gem:
24
+
25
+ $ gem install code_review_leaderboard
26
+
27
+ ## Usage
28
+
29
+ To use the gem, you will need to provide a GitHub access token. You can create a new access token by following the instructions [here](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line). If you want to inspect the repositories of an organization, make sure the organization in question allows access to their repositories from personal access tokens. You can check that from `https://github.com/organizations/<name>/settings/personal-access-tokens`.
30
+
31
+ To inspect the reviews of a single repository, run:
32
+
33
+ $ code_review_leaderboard --access_token ACCESS_TOKEN --repository REPOSITORY
34
+
35
+ You can also provide multiple repositories:
36
+
37
+ $ code_review_leaderboard --access_token ACCESS_TOKEN --repository REPOSITORY1 REPOSITORY2
38
+
39
+ Finally, you can also inspect the repositories of an organization:
40
+
41
+ $ code_review_leaderboard --access_token ACCESS_TOKEN --organization ORGANIZATION
42
+
43
+ ## Configuration
44
+
45
+ You can either configure the gem via command line arguments or by setting environment variables. Command line arguments take precedence.
46
+
47
+ ## Development
48
+
49
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
50
+
51
+ To install this gem onto your local machine, run `bundle exec rake install`. You can then run it with `bundle exec <executable>`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
52
+
53
+ ## Contributing
54
+
55
+ Bug reports and pull requests are welcome on GitHub at https://github.com/markokajzer/code-review-leaderboard.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ require "standard/rake"
7
+
8
+ task default: %i[spec standard]
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ base_path = File.expand_path("../lib", __dir__)
4
+ $LOAD_PATH.unshift(base_path)
5
+
6
+ require "active_support/core_ext/object/blank"
7
+
8
+ require "code_review_leaderboard"
9
+
10
+ CodeReviewLeaderboard.initialize!
11
+ CodeReviewLeaderboard.start
@@ -0,0 +1,25 @@
1
+ require "active_support"
2
+ require "active_support/core_ext/module/delegation"
3
+
4
+ require "octokit"
5
+
6
+ module CodeReviewLeaderboard
7
+ module Adapters
8
+ module Github
9
+ extend self
10
+
11
+ delegate :organization_repositories,
12
+ :pull_requests,
13
+ :pull_request_reviews,
14
+ to: :client
15
+
16
+ def client
17
+ @client ||= Octokit::Client.new(access_token: Config.access_token)
18
+ end
19
+
20
+ def per_page
21
+ client.per_page || 30
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,9 @@
1
+ require "async/barrier"
2
+
3
+ def WaitAll(&block)
4
+ barrier = Async::Barrier.new
5
+
6
+ yield barrier
7
+
8
+ barrier.wait
9
+ end
@@ -0,0 +1,23 @@
1
+ require "async"
2
+ require "async/barrier"
3
+ require "async/semaphore"
4
+
5
+ require_relative "wait_all"
6
+
7
+ def WaitAllThrottled(array, concurrency: 5, &block)
8
+ result = []
9
+
10
+ WaitAll do |barrier|
11
+ semaphore = Async::Semaphore.new(concurrency, parent: barrier)
12
+
13
+ Async do
14
+ array.each do |item|
15
+ semaphore.async(parent: barrier) do
16
+ result << yield(item)
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ result.flatten
23
+ end
@@ -0,0 +1,25 @@
1
+ require "optparse"
2
+
3
+ module CodeReviewLeaderboard
4
+ module ArgumentParser
5
+ extend self
6
+
7
+ def parse!(args: ARGV)
8
+ {}.tap do |options|
9
+ OptionParser.new do |opts|
10
+ opts.banner = "Usage: leaderboard [options]"
11
+
12
+ opts.on("-t", "--access-token ACCESS_TOKEN", "Specify the access token") { |token| options[:access_token] = token }
13
+ opts.on("-r", "--repo", "--repository repository,repository", Array, "Specify the repository") { |repositories| options[:repositories] = repositories }
14
+ opts.on("-o", "--org", "--organization organization", "Specify the organization") { |organization| options[:organization] = organization }
15
+ opts.on("-v", "--verbose", "Run verbosely") { options[:log_level] = :debug }
16
+
17
+ opts.on("-h", "--help", "Show this message") do
18
+ puts opts
19
+ exit
20
+ end
21
+ end.parse!(args)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,41 @@
1
+ require "active_support"
2
+ require "active_support/configurable"
3
+ require "active_support/core_ext/module/delegation"
4
+
5
+ require_relative "argument_parser"
6
+
7
+ module CodeReviewLeaderboard
8
+ ConfigurationError = Class.new(StandardError)
9
+
10
+ module Config
11
+ extend self
12
+
13
+ include ActiveSupport::Configurable
14
+
15
+ config_accessor :access_token
16
+ config_accessor :repositories, default: []
17
+ config_accessor :organization
18
+ config_accessor :log_level, default: :info
19
+
20
+ def initialize!
21
+ initialize_from_args!
22
+ initialize_from_env!
23
+ end
24
+
25
+ def initialize_from_args!
26
+ options = ArgumentParser.parse!
27
+
28
+ self.access_token = options[:access_token]
29
+ self.organization = options[:organization]
30
+ self.repositories = options[:repositories] if options[:repositories].present?
31
+ self.log_level = options[:log_level]
32
+ end
33
+
34
+ def initialize_from_env!
35
+ self.access_token ||= ENV["ACCESS_TOKEN"]
36
+ self.organization ||= ENV["ORGANIZATION"]
37
+ self.repositories = repositories.presence || ENV["REPOSITORY"].to_s.split(",")
38
+ self.log_level ||= ENV["LOG_LEVEL"]
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,35 @@
1
+ require "terminal-table"
2
+
3
+ module CodeReviewLeaderboard
4
+ class Formatter
5
+ def initialize(tally)
6
+ @tally = tally
7
+ end
8
+
9
+ def to_table
10
+ Terminal::Table.new(headings:) do |rows|
11
+ totals.each do |user, reviews|
12
+ rows << [user, reviews[:total], reviews[:approved], reviews[:changes_requested], reviews[:commented]]
13
+ end
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :tally
20
+
21
+ def totals
22
+ tally.each_with_object({}) do |review, totals|
23
+ totals[review[:user].to_sym] ||= Hash.new(0)
24
+ totals[review[:user].to_sym][review[:state]] += 1
25
+ totals[review[:user].to_sym][:total] += 1
26
+ end
27
+ .sort_by { _2[:total] }
28
+ .reverse
29
+ end
30
+
31
+ def headings
32
+ ["Login", "Total", "Approved", "Changes Requested", "Commented"]
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ require "active_support"
2
+ require "active_support/core_ext/numeric/time"
3
+
4
+ require "adapters/github"
5
+ require_relative "config"
6
+
7
+ require_relative "repository"
8
+
9
+ module CodeReviewLeaderboard
10
+ class Organization
11
+ attr_reader :name
12
+
13
+ def initialize(name:)
14
+ @name = name
15
+ end
16
+
17
+ def repos(since: 30.days.ago)
18
+ fetch_repos(since:).map { Repository.new(name: _1.full_name) }
19
+ end
20
+
21
+ private
22
+
23
+ def fetch_repos(since:)
24
+ (1..).each_with_object([]) do |page, repos|
25
+ repo_chunk =
26
+ Adapters::Github
27
+ .organization_repositories(name, type: "sources", sort: "pushed", page:)
28
+ .filter { _1.pushed_at > since }
29
+ repos.concat(repo_chunk)
30
+
31
+ return repos if repo_chunk.size < Adapters::Github.per_page
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,42 @@
1
+ require "active_support"
2
+ require "active_support/core_ext/numeric/time"
3
+
4
+ require "adapters/github"
5
+ require_relative "config"
6
+
7
+ module CodeReviewLeaderboard
8
+ class Pulls
9
+ PER_PAGE = 100
10
+
11
+ class << self
12
+ def for(repository:, since: 30.days.ago)
13
+ new(repository:, since:).pulls
14
+ end
15
+ end
16
+
17
+ def initialize(repository:, since:)
18
+ @repository = repository
19
+ @since = since
20
+ end
21
+
22
+ def pulls
23
+ fetch_pulls
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :repository, :since
29
+
30
+ def fetch_pulls
31
+ (1..).each_with_object([]) do |page, pulls|
32
+ pulls_chunk =
33
+ Adapters::Github
34
+ .pull_requests(repository.name, state: "all", per_page: PER_PAGE, page:)
35
+ .filter { _1.created_at > since }
36
+ pulls.concat(pulls_chunk)
37
+
38
+ return pulls if pulls_chunk.size < PER_PAGE
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "pulls"
2
+
3
+ module CodeReviewLeaderboard
4
+ class Repository
5
+ attr_reader :name
6
+
7
+ def initialize(name:)
8
+ @name = name
9
+ end
10
+
11
+ def pulls
12
+ Pulls.for(repository: self)
13
+ end
14
+
15
+ def ==(other)
16
+ name == other.name
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,46 @@
1
+ require_relative "config"
2
+
3
+ module CodeReviewLeaderboard
4
+ class Reviews
5
+ class << self
6
+ def for(pull:, since: 30.days.ago)
7
+ new(pull:, since:).reviews
8
+ end
9
+ end
10
+
11
+ def initialize(pull:, since: 30.days.ago)
12
+ @pull = pull
13
+ @since = since
14
+ end
15
+
16
+ def reviews
17
+ puts "Fetching reviews for #{repository}##{pull.number}..." if Config.log_level == :debug
18
+
19
+ comments, reviews =
20
+ fetch_reviews
21
+ .map { {user: _1.user.login, state: _1.state.downcase.to_sym} }
22
+ .uniq
23
+ .partition { _1[:state] == :commented }
24
+
25
+ # Do not count comments if already otherwise reviewed
26
+ comments.filter! do |commenter|
27
+ reviews.none? { |reviewer| reviewer[:user] == commenter[:user] }
28
+ end
29
+
30
+ reviews + comments
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :pull, :since
36
+
37
+ def fetch_reviews
38
+ Adapters::Github.pull_request_reviews(repository, pull.number)
39
+ .filter { _1.submitted_at > since }
40
+ end
41
+
42
+ def repository
43
+ pull.base.repo.full_name
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,19 @@
1
+ require "whirly"
2
+
3
+ module CodeReviewLeaderboard
4
+ module Spinner
5
+ extend self
6
+
7
+ delegate :status=, to: Whirly
8
+
9
+ def start
10
+ result = nil
11
+
12
+ Whirly.start(spinner: "dots", stop: "✔") do
13
+ result = yield
14
+ end
15
+
16
+ result
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,3 @@
1
+ module CodeReviewLeaderboard
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,64 @@
1
+ require_relative "code_review_leaderboard/version"
2
+
3
+ require_relative "async/wait_all_throttled"
4
+
5
+ require "code_review_leaderboard/config"
6
+ require "code_review_leaderboard/formatter"
7
+ require "code_review_leaderboard/organization"
8
+ require "code_review_leaderboard/pulls"
9
+ require "code_review_leaderboard/repository"
10
+ require "code_review_leaderboard/reviews"
11
+ require "code_review_leaderboard/spinner"
12
+
13
+ module CodeReviewLeaderboard
14
+ extend self
15
+
16
+ def initialize!
17
+ Config.initialize!
18
+
19
+ raise ConfigurationError, "Access token is required" if Config.access_token.nil?
20
+ raise ConfigurationError, "Repository or owner is required" if Config.repositories.empty? && Config.organization.blank?
21
+ end
22
+
23
+ def start
24
+ puts "Found #{repositories.size} repositories" if Config.organization.present? && Config.log_level == :debug
25
+ puts "Found #{pulls.size} pull requests."
26
+ puts "Found #{reviews.size} reviews."
27
+ puts
28
+
29
+ puts Formatter.new(reviews).to_table
30
+ end
31
+
32
+ private
33
+
34
+ def repositories
35
+ @@repositories ||= if Config.repositories.present?
36
+ Config.repositories.map { Repository.new(name: _1) }
37
+ else
38
+ Spinner.start do
39
+ Spinner.status = "Fetching repos for #{Config.organization}..."
40
+ Organization.new(name: Config.organization).repos
41
+ end
42
+ end
43
+ end
44
+ alias_method :load_repositories, :repositories
45
+
46
+ def pulls
47
+ # Load before we enter the next spinner so we do not end up nesting them
48
+ load_repositories
49
+
50
+ @@pulls ||= Spinner.start do
51
+ Spinner.status = "Fetching pull requests..."
52
+
53
+ WaitAllThrottled(repositories) { _1.pulls }
54
+ end
55
+ end
56
+
57
+ def reviews
58
+ @@reviews ||= Spinner.start do
59
+ Spinner.status = "Fetching reviews..."
60
+
61
+ WaitAllThrottled(pulls) { Reviews.for(pull: _1) }
62
+ end
63
+ end
64
+ end
metadata ADDED
@@ -0,0 +1,161 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: code_review_leaderboard
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - markokajzer
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-02-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: octokit
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-retry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activesupport
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: async
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: whirly
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: paint
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: terminal-table
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ description:
112
+ email:
113
+ - markokajzer91@gmail.com
114
+ executables:
115
+ - code_review_leaderboard
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - CHANGELOG.md
120
+ - README.md
121
+ - Rakefile
122
+ - exe/code_review_leaderboard
123
+ - lib/adapters/github.rb
124
+ - lib/async/wait_all.rb
125
+ - lib/async/wait_all_throttled.rb
126
+ - lib/code_review_leaderboard.rb
127
+ - lib/code_review_leaderboard/argument_parser.rb
128
+ - lib/code_review_leaderboard/config.rb
129
+ - lib/code_review_leaderboard/formatter.rb
130
+ - lib/code_review_leaderboard/organization.rb
131
+ - lib/code_review_leaderboard/pulls.rb
132
+ - lib/code_review_leaderboard/repository.rb
133
+ - lib/code_review_leaderboard/reviews.rb
134
+ - lib/code_review_leaderboard/spinner.rb
135
+ - lib/code_review_leaderboard/version.rb
136
+ homepage: https://github.com/markokajzer/code-review-leaderboard
137
+ licenses: []
138
+ metadata:
139
+ homepage_uri: https://github.com/markokajzer/code-review-leaderboard
140
+ source_code_uri: https://github.com/markokajzer/code-review-leaderboard
141
+ changelog_uri: https://github.com/markokajzer/code-review-leaderboard/blob/main/CHANGELOG.md
142
+ post_install_message:
143
+ rdoc_options: []
144
+ require_paths:
145
+ - lib
146
+ required_ruby_version: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - ">="
149
+ - !ruby/object:Gem::Version
150
+ version: 3.3.0
151
+ required_rubygems_version: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - ">="
154
+ - !ruby/object:Gem::Version
155
+ version: '0'
156
+ requirements: []
157
+ rubygems_version: 3.5.4
158
+ signing_key:
159
+ specification_version: 4
160
+ summary: Create a leaderboard for code review in your repository or organization
161
+ test_files: []