codeowners 0.0.3

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 790787fbbbface03dca93e7ee9fb70818d736b55b5b46feba832bb5d1d1cacde
4
+ data.tar.gz: 5cf3d0d5b9612527ac8e3d5416442dcb0442e1d69180b85df1b25752871e9d9e
5
+ SHA512:
6
+ metadata.gz: 9728df04569e092a74db19feec631cb0f4031da24ab1bf7e127b6a728206c21026fc7a2988ff0c81f66b894a895350521a9f956236859e682eaa8be2f79194a3
7
+ data.tar.gz: 594590f13fe02ea9c8e7f5831e2d716a48779e7309f063caeb194c884a42d69037fed8ca64b6703ad88db7765f25258fa58d9d45e12b3eabb00875a016b1fb3a
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ Gemfile.lock
10
+ .rubocop-*
11
+ codeowners.json
@@ -0,0 +1 @@
1
+ inherit_from: https://raw.githubusercontent.com/jodosha/dotfiles/master/rubocop.yml
@@ -0,0 +1,17 @@
1
+ # Codeowners
2
+ Simple CLI to interact with GitHub CODEOWNERS
3
+
4
+ ## v0.0.3 - 2020-07-22
5
+ ### Added
6
+ - [Luca Guidi] Added `codeowners guess`
7
+ - [Luca Guidi] Added `codeowners import org`
8
+
9
+ ## v0.0.2 - 2020-06-27
10
+ ### Added
11
+ - [Luca Guidi] Added pattern support to `codeowners contributors`
12
+
13
+ ## v0.0.1 - 2020-06-12
14
+ ### Added
15
+ - [Luca Guidi] Added `codeowners contributors`
16
+ - [Luca Guidi] Added `codeowners list`
17
+ - [Luca Guidi] Added `codeowners --help`
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in codeowners.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 12.0"
@@ -0,0 +1,75 @@
1
+ # Codeowners
2
+
3
+ Simple CLI to interact with GitHub CODEOWNERS.
4
+
5
+ ## Installation
6
+
7
+ Install as:
8
+
9
+ ```shell
10
+ $ gem install codeowners
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### List
16
+
17
+ List code owners for a file, if any.
18
+
19
+ ```shell
20
+ $ codeowners list path/to/file
21
+ @company/team-a @company/team-b
22
+ ```
23
+
24
+ ### Contributors
25
+
26
+ List code contributors for a file.
27
+ This is useful to guess who can be a candidate to own a file.
28
+
29
+ ```shell
30
+ $ codeowners contributors path/to/file
31
+ path/to/file
32
+
33
+ Person One <person.one@company.com> / +106, -0
34
+ Person Two <person.two@company.com> / +12, -2
35
+ ```
36
+
37
+ The command accepts also a pattern to match files in bulk.
38
+
39
+ ```shell
40
+ $ codeowners contributors 'path/to/**/*.rb'
41
+ path/to/**/*.rb
42
+
43
+ Person One <person.one@company.com> / +243, -438
44
+ Person Three <person.three@company.com> / +104, -56
45
+ Person Two <person.two@company.com> / +12, -2
46
+ ```
47
+
48
+ ### Help
49
+
50
+ For a complete set of options, please run:
51
+
52
+ ```shell
53
+ $ codeowners --help
54
+ $ codeowners COMMAND --help
55
+ ```
56
+
57
+ ## Development
58
+
59
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
60
+
61
+ To run `codeowners` executable during development:
62
+
63
+ ```shell
64
+ $ bundle exec exe/codeowners contributors path/to/file --base-directory=/path/to/git/repository/to/analyze
65
+ ```
66
+
67
+ To install this gem onto your local machine, run `bundle exec rake install`. 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 tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
68
+
69
+ ## Contributing
70
+
71
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jodosha/codeowners.
72
+
73
+ ## Copyright
74
+
75
+ &copy; 2020 - Luca Guidi - https://lucaguidi.com
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: :spec
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "codeowners"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/codeowners/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "codeowners"
7
+ spec.version = Codeowners::VERSION
8
+ spec.authors = ["Luca Guidi"]
9
+ spec.email = ["me@lucaguidi.com"]
10
+
11
+ spec.summary = "GitHub Codeowners check and guess"
12
+ spec.description = "Check GitHub Codeowners and guess which team should be assigned to a file"
13
+ spec.homepage = "https://lucaguidi.com"
14
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.5.0")
15
+
16
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = "https://github.com/jodosha/codeowners"
20
+ spec.metadata["changelog_uri"] = "https://github.com/jodosha/codeowners/blob/master/CHANGELOG.md"
21
+
22
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
24
+ end
25
+ spec.bindir = "exe"
26
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
27
+ spec.require_paths = ["lib"]
28
+
29
+ spec.add_runtime_dependency "dry-cli", "~> 0.6"
30
+ spec.add_runtime_dependency "excon", "~> 0.75"
31
+ spec.add_development_dependency "rubocop"
32
+ spec.add_development_dependency "byebug"
33
+ end
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "codeowners/cli"
5
+
6
+ Dry::CLI.new(Codeowners::CLI::Commands).call
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Codeowners
4
+ require "codeowners/version"
5
+ require "codeowners/result"
6
+ require "codeowners/storage"
7
+ require "codeowners/list_owners"
8
+ require "codeowners/list_contributors"
9
+ require "codeowners/guess"
10
+ require "codeowners/import/client"
11
+ require "codeowners/import/organization"
12
+
13
+ class Error < StandardError
14
+ end
15
+
16
+ class SystemCallError < Error
17
+ end
18
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "codeowners"
4
+ require "dry/cli"
5
+
6
+ module Codeowners
7
+ module CLI
8
+ module Commands
9
+ extend Dry::CLI::Registry
10
+
11
+ class Command < Dry::CLI::Command
12
+ def initialize(out: $stdout)
13
+ @out = out
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :out
19
+ end
20
+
21
+ class Version < Command
22
+ desc "Print version"
23
+
24
+ def call(*)
25
+ out.puts "v#{Codeowners::VERSION}"
26
+ end
27
+ end
28
+
29
+ class List < Command
30
+ DEFAULT_BASE_DIRECTORY = Dir.pwd.dup.freeze
31
+ private_constant :DEFAULT_BASE_DIRECTORY
32
+
33
+ DEFAULT_CODEOWNERS_PATH = ::File.join(".github", "CODEOWNERS").freeze
34
+ private_constant :DEFAULT_CODEOWNERS_PATH
35
+
36
+ desc "List code owners for a file, if any"
37
+
38
+ argument :file, required: true, desc: "File to check"
39
+
40
+ option :base_directory, type: :string, default: DEFAULT_BASE_DIRECTORY, desc: "Base directory"
41
+ option :codeowners, type: :string, default: DEFAULT_CODEOWNERS_PATH, desc: "Path to CODEOWNERS file"
42
+
43
+ def call(file:, base_directory:, codeowners:, **)
44
+ result = Codeowners::ListOwners.new(base_directory, codeowners).call(file)
45
+ exit(1) unless result.successful?
46
+
47
+ out.puts result.to_s
48
+ end
49
+ end
50
+
51
+ class Contributors < Command
52
+ DEFAULT_BASE_DIRECTORY = Dir.pwd.dup.freeze
53
+ private_constant :DEFAULT_BASE_DIRECTORY
54
+
55
+ DEFAULT_CODEOWNERS_PATH = ::File.join(".github", "CODEOWNERS").freeze
56
+ private_constant :DEFAULT_CODEOWNERS_PATH
57
+
58
+ FORMAT_MAPPING = { "string" => "to_s", "csv" => "to_csv" }.freeze
59
+ private_constant :FORMAT_MAPPING
60
+
61
+ FORMAT_VALUES = FORMAT_MAPPING.keys.freeze
62
+ private_constant :FORMAT_VALUES
63
+
64
+ DEFAULT_FORMAT = FORMAT_VALUES.first
65
+ private_constant :DEFAULT_FORMAT
66
+
67
+ DEFAULT_DEBUG = false
68
+ private_constant :DEFAULT_DEBUG
69
+
70
+ desc "List code contributors for a file (or a pattern)"
71
+
72
+ argument :file, required: true, desc: "File (or pattern) to check"
73
+
74
+ option :base_directory, type: :string, default: DEFAULT_BASE_DIRECTORY, desc: "Base directory"
75
+ option :format, type: :string, default: DEFAULT_FORMAT, values: FORMAT_VALUES, desc: "Output format"
76
+ option :debug, type: :boolean, default: DEFAULT_DEBUG, desc: "Print debug information to stdout"
77
+
78
+ example [
79
+ "path/to/file.rb # file",
80
+ "'path/to/**/*.rb' # pattern"
81
+ ]
82
+
83
+ def call(file:, base_directory:, format:, debug:, **)
84
+ result = Codeowners::ListContributors.new(base_directory).call(file, debug)
85
+ exit(1) unless result.successful?
86
+
87
+ out.puts output(result, format)
88
+ end
89
+
90
+ private
91
+
92
+ def output(result, format)
93
+ method_name = FORMAT_MAPPING.fetch(format)
94
+ result.public_send(method_name.to_sym)
95
+ end
96
+ end
97
+
98
+ class Guess < Command
99
+ DEFAULT_BASE_DIRECTORY = Dir.pwd.dup.freeze
100
+ private_constant :DEFAULT_BASE_DIRECTORY
101
+
102
+ DEFAULT_CODEOWNERS_PATH = ::File.join(".github", "CODEOWNERS").freeze
103
+ private_constant :DEFAULT_CODEOWNERS_PATH
104
+
105
+ FORMAT_MAPPING = { "string" => "to_s", "csv" => "to_csv" }.freeze
106
+ private_constant :FORMAT_MAPPING
107
+
108
+ FORMAT_VALUES = FORMAT_MAPPING.keys.freeze
109
+ private_constant :FORMAT_VALUES
110
+
111
+ DEFAULT_FORMAT = FORMAT_VALUES.first
112
+ private_constant :DEFAULT_FORMAT
113
+
114
+ DEFAULT_STORAGE_PATH = ::File.join(Dir.pwd, "codeowners.json").freeze
115
+ private_constant :DEFAULT_STORAGE_PATH
116
+
117
+ DEFAULT_DEBUG = false
118
+ private_constant :DEFAULT_DEBUG
119
+
120
+ desc "Check CODEOWNERS for a file (or a pattern), if missing, it tries to guess, by looking at the git commit history"
121
+
122
+ argument :file, required: true, desc: "File (or pattern) to check"
123
+
124
+ option :base_directory, type: :string, default: DEFAULT_BASE_DIRECTORY, desc: "Base directory"
125
+ option :codeowners, type: :string, default: DEFAULT_CODEOWNERS_PATH, desc: "Path to CODEOWNERS file"
126
+ option :storage, type: :string, default: DEFAULT_STORAGE_PATH, desc: "Storage path (default: #{DEFAULT_STORAGE_PATH})"
127
+
128
+ option :format, type: :string, default: DEFAULT_FORMAT, values: FORMAT_VALUES, desc: "Output format"
129
+ option :debug, type: :boolean, default: DEFAULT_DEBUG, desc: "Print debug information to stdout"
130
+
131
+ example [
132
+ "path/to/file.rb # file",
133
+ "'path/to/**/*.rb' # pattern"
134
+ ]
135
+
136
+ def call(file:, base_directory:, codeowners:, storage:, format:, debug:, **)
137
+ owners = Codeowners::ListOwners.new(base_directory, codeowners)
138
+ contributors = Codeowners::ListContributors.new(base_directory)
139
+ storage = Codeowners::Storage.new(storage)
140
+
141
+ result = Codeowners::Guess.new(owners, contributors, storage, base_directory, out).call(file, debug)
142
+ # exit(1) unless result.successful?
143
+
144
+ out.puts output(result, format)
145
+ end
146
+
147
+ private
148
+
149
+ def output(result, format)
150
+ # method_name = FORMAT_MAPPING.fetch(format)
151
+ return unless format == "csv"
152
+
153
+ result.map do |file, data|
154
+ "#{file},#{data.fetch(:teams).join('/')},#{data.fetch(:codeowners)}"
155
+ end.join("\n")
156
+ end
157
+ end
158
+
159
+ module Import
160
+ class Org < Command
161
+ DEFAULT_STORAGE_PATH = ::File.join(Dir.pwd, "codeowners.json").freeze
162
+ private_constant :DEFAULT_STORAGE_PATH
163
+
164
+ DEFAULT_DEBUG = false
165
+ private_constant :DEFAULT_DEBUG
166
+
167
+ desc "Import teams and members for a GitHub organization"
168
+
169
+ argument :org, required: true, desc: "GitHub organization login"
170
+ argument :token, required: true, desc: "GitHub APIv3 token"
171
+
172
+ option :storage, type: :string, default: DEFAULT_STORAGE_PATH, desc: "Storage path (default: #{DEFAULT_STORAGE_PATH})"
173
+ option :debug, type: :boolean, default: DEFAULT_DEBUG, desc: "Print debug information to stdout"
174
+
175
+ example [
176
+ "hanami s3cr374p1t0k3n"
177
+ ]
178
+
179
+ def call(org:, token:, storage:, debug:, **)
180
+ client = Codeowners::Import::Client.new(token, out)
181
+ storage = Codeowners::Storage.new(storage)
182
+
183
+ Codeowners::Import::Organization.new(client, storage).call(org, debug)
184
+ end
185
+ end
186
+ end
187
+
188
+ register "version", Version, aliases: ["v", "-v", "--version"]
189
+ register "list", List
190
+ register "contributors", Contributors
191
+ register "guess", Guess
192
+
193
+ register "import" do |prefix|
194
+ prefix.register "org", Import::Org
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "shellwords"
5
+
6
+ module Codeowners
7
+ class Git
8
+ def initialize(base_directory)
9
+ @base_directory = Pathname.new(::File.expand_path(base_directory))
10
+ end
11
+
12
+ def contributors(file, debug = false)
13
+ require "codeowners/git/contributors"
14
+ output = git(["log", "--numstat", %(--pretty=format:"author:%aN email:%ae"), "--no-color", "--", escape(file)])
15
+ print_debug(output, debug)
16
+
17
+ Contributors.call(file, output)
18
+ end
19
+
20
+ private
21
+
22
+ def git(command_and_args)
23
+ execute(["git", "--git-dir=#{git_directory}", "--work-tree=#{work_tree}", "-c", "'color.ui=false'"] + command_and_args)
24
+ end
25
+
26
+ def work_tree
27
+ escape(@base_directory.to_s)
28
+ end
29
+
30
+ def git_directory
31
+ escape(@base_directory.join(".git").to_s)
32
+ end
33
+
34
+ def escape(string)
35
+ Shellwords.shellescape(string)
36
+ end
37
+
38
+ def execute(command, env: {}, error: ->(err) { raise Codeowners::SystemCallError.new(err) })
39
+ require "open3"
40
+
41
+ Open3.popen3(env, command.join(" ")) do |_, stdout, stderr, wait_thr|
42
+ error.call(stderr.read) unless wait_thr.value.success?
43
+ return stdout.read
44
+ end
45
+ end
46
+
47
+ def print_debug(output, debug)
48
+ return unless debug
49
+
50
+ puts output
51
+ puts "\n" * 10
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Codeowners
4
+ class Git
5
+ class Contributor
6
+ attr_reader :email, :name, :file, :insertions, :deletions
7
+
8
+ def initialize(email, name, file, insertions, deletions)
9
+ @email = email
10
+ @name = name
11
+ @file = file
12
+ @insertions = insertions
13
+ @deletions = deletions
14
+
15
+ freeze
16
+ end
17
+
18
+ def to_s
19
+ "#{name} <#{email}> / +#{insertions}, -#{deletions}"
20
+ end
21
+
22
+ def to_csv
23
+ "#{name}, #{email}, #{insertions}, #{deletions}"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "codeowners/git/contributor"
5
+
6
+ module Codeowners
7
+ class Git
8
+ class Contributors
9
+ # rubocop:disable Metrics/AbcSize
10
+ # rubocop:disable Metrics/MethodLength
11
+ def self.call(file, output)
12
+ lines = output.split($INPUT_RECORD_SEPARATOR)
13
+
14
+ result = {}
15
+ each_commit(lines) do |authors, insertions, deletions|
16
+ authors.each do |author|
17
+ author_email = author.fetch("email")
18
+ author_name = author.fetch("name")
19
+
20
+ result[author_email] ||= {}
21
+ result[author_email]["name"] = author_name
22
+ result[author_email]["file"] = file
23
+ result[author_email]["insertions"] ||= 0
24
+ result[author_email]["deletions"] ||= 0
25
+ result[author_email]["insertions"] += insertions
26
+ result[author_email]["deletions"] += deletions
27
+ end
28
+ end
29
+
30
+ new(result)
31
+ end
32
+ # rubocop:enable Metrics/MethodLength
33
+ # rubocop:enable Metrics/AbcSize
34
+
35
+ def self.each_commit(lines)
36
+ while lines.any?
37
+ commit = lines.take_while { |line| line != "" }
38
+ yield parse(commit.dup) unless commit.empty?
39
+ lines.shift(commit.size + 1)
40
+ end
41
+ end
42
+
43
+ def self.parse(commit)
44
+ authors, stats = commit.partition { |line| line.match?(/author:/) }
45
+
46
+ [extract_authors(authors), *calculate_stats(stats)]
47
+ end
48
+
49
+ def self.extract_authors(authors)
50
+ authors.map do |author|
51
+ {
52
+ "name" => scan(author, /author:(.*)email:/).chop,
53
+ "email" => scan(author, /email:(.*)/)
54
+ }
55
+ end.uniq
56
+ end
57
+
58
+ def self.calculate_stats(stats)
59
+ stats.each_with_object([0, 0]) do |stat, result|
60
+ stat = stat.split(/[[:space:]]+/)
61
+
62
+ insertions, deletions, = *stat
63
+ result[0] += Integer(insertions)
64
+ result[1] += Integer(deletions)
65
+ end
66
+ end
67
+
68
+ def self.scan(string, pattern)
69
+ string.scan(pattern).flatten.first
70
+ end
71
+
72
+ def initialize(data)
73
+ @contributors = data.map do |email, stats|
74
+ Contributor.new(email, *stats.values)
75
+ end
76
+ end
77
+
78
+ def each(&blk)
79
+ return enum_for(:each) unless block_given?
80
+
81
+ @contributors.each(&blk)
82
+ end
83
+
84
+ def empty?
85
+ @contributors.empty?
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Codeowners
4
+ class Guess
5
+ def initialize(owners, contributors, storage, base_directory, out)
6
+ @owners = owners
7
+ @contributors = contributors
8
+ @storage = storage
9
+ @base_directory = ::File.expand_path(base_directory)
10
+ @out = out
11
+ end
12
+
13
+ def call(file, debug)
14
+ result = {}
15
+
16
+ Dir.chdir(base_directory) do
17
+ Dir.glob(file).sort.each do |f|
18
+ *teams, codeowners = list_code_owners(f, debug)
19
+ *teams, codeowners = guess_code_owners(f, debug) unless codeowners
20
+ teams ||= []
21
+
22
+ result[f] = { teams: teams, codeowners: codeowners }
23
+ end
24
+ end
25
+
26
+ result
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :owners
32
+ attr_reader :contributors
33
+ attr_reader :storage
34
+ attr_reader :base_directory
35
+ attr_reader :out
36
+
37
+ def list_code_owners(file, _debug)
38
+ result = owners.call(file)
39
+
40
+ if result.successful?
41
+ teams = result.to_a.find_all do |team|
42
+ storage.team_exist?(team) &&
43
+ !storage.blacklisted_team?(team)
44
+ end
45
+
46
+ return [result, true] if teams.any?
47
+ end
48
+
49
+ [nil, false]
50
+ end
51
+
52
+ def guess_code_owners(file, debug)
53
+ result = contributors.call(file, debug)
54
+ return [nil, false] unless result.successful?
55
+
56
+ contributors = result.to_a
57
+ result = contributors.each_with_object({}) do |contributor, memo|
58
+ teams = storage.teams_for(contributor)
59
+ teams.each do |team|
60
+ slug = team.fetch("slug")
61
+
62
+ memo[slug] ||= {}
63
+ memo[slug][:insertions] ||= 0
64
+ memo[slug][:deletions] ||= 0
65
+ memo[slug][:insertions] += contributor.insertions
66
+ memo[slug][:deletions] += contributor.deletions
67
+ end
68
+ end
69
+
70
+ team = result.sort do |a, b|
71
+ -a.last.fetch(:insertions) <=> -b.last.fetch(:insertions)
72
+ end&.first&.first
73
+
74
+ [team, false]
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "excon"
5
+
6
+ module Codeowners
7
+ module Import
8
+ class Client
9
+ BASE_URL = "https://api.github.com"
10
+ private_constant :BASE_URL
11
+
12
+ USER_AGENT = "codeowners v#{Codeowners::VERSION}"
13
+ private_constant :USER_AGENT
14
+
15
+ def initialize(token, out, base_url = BASE_URL, user_agent = USER_AGENT, client = Excon, sleep_time: 3)
16
+ @base_url = base_url
17
+ @user_agent = user_agent
18
+ @token = token
19
+ @client = client
20
+ @out = out
21
+ @sleep_time = sleep_time
22
+ end
23
+
24
+ def org(login, debug = false)
25
+ result = get("/orgs/#{login}", debug: debug)
26
+
27
+ {
28
+ id: result.fetch("id"),
29
+ login: result.fetch("login")
30
+ }
31
+ end
32
+
33
+ def org_members(org, debug = false)
34
+ result = get_paginated("/orgs/#{org.fetch(:login)}/members", debug: debug)
35
+ result.map do |user|
36
+ {
37
+ id: user.fetch("id"),
38
+ login: user.fetch("login")
39
+ }
40
+ end
41
+ end
42
+
43
+ def teams(org, debug = false)
44
+ result = get_paginated("/orgs/#{org.fetch(:login)}/teams", debug: debug)
45
+ result.map do |team|
46
+ {
47
+ id: team.fetch("id"),
48
+ org_id: org.fetch(:id),
49
+ name: team.fetch("name"),
50
+ slug: team.fetch("slug")
51
+ }
52
+ end
53
+ end
54
+
55
+ def team_members(org, teams, debug = false)
56
+ teams.each_with_object([]) do |team, memo|
57
+ result = get_paginated("/orgs/#{org.fetch(:login)}/teams/#{team.fetch(:slug)}/members", debug: debug)
58
+ result.each do |member|
59
+ team_id = team.fetch(:id)
60
+ user_id = member.fetch("id")
61
+
62
+ memo << {
63
+ id: [team_id, user_id],
64
+ team_id: team_id,
65
+ user_id: user_id
66
+ }
67
+ end
68
+
69
+ sleep_for_a_while
70
+ end
71
+ end
72
+
73
+ def users(users, debug)
74
+ users.each do |user|
75
+ remote_user = get("/users/#{user.fetch(:login)}", debug: debug)
76
+ user.merge!(
77
+ name: remote_user.fetch("name"),
78
+ email: remote_user.fetch("email")
79
+ )
80
+
81
+ sleep_for_a_while
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ attr_reader :base_url
88
+ attr_reader :user_agent
89
+ attr_reader :token
90
+ attr_reader :client
91
+ attr_reader :out
92
+ attr_reader :sleep_time
93
+
94
+ def get(path, debug: false)
95
+ out.puts "requesting GET #{path}" if debug
96
+
97
+ response = client.get(base_url + path, query: query, headers: headers)
98
+ return {} unless response.status == 200
99
+
100
+ JSON.parse(response.body)
101
+ end
102
+
103
+ def get_paginated(path, result = [], debug: false, page: 1)
104
+ out.puts "requesting GET #{path}, page: #{page}" if debug
105
+
106
+ response = client.get(base_url + path, query: query(page: page), headers: headers)
107
+ return [] unless response.status == 200
108
+
109
+ parsed = JSON.parse(response.body)
110
+ result.push(parsed)
111
+
112
+ if parsed.any?
113
+ sleep_for_a_while
114
+ get_paginated(path, result, debug: debug, page: page + 1)
115
+ else
116
+ result.flatten
117
+ end
118
+ end
119
+
120
+ def query(options = {})
121
+ { page: 1, per_page: 100 }.merge(options)
122
+ end
123
+
124
+ def headers
125
+ {
126
+ "Authorization" => "token #{token}",
127
+ "User-Agent" => user_agent
128
+ }
129
+ end
130
+
131
+ def sleep_for_a_while
132
+ sleep(sleep_time)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Codeowners
4
+ module Import
5
+ class Organization
6
+ def initialize(client, storage)
7
+ @client = client
8
+ @storage = storage
9
+ end
10
+
11
+ def call(org, debug)
12
+ org = client.org(org, debug)
13
+ users = client.org_members(org, debug)
14
+ users = client.users(users, debug)
15
+ teams = client.teams(org, debug)
16
+ memberships = client.team_members(org, teams, debug)
17
+
18
+ storage.transaction do |db|
19
+ db[:orgs].upsert(org)
20
+ db[:users].upsert(users)
21
+ db[:teams].upsert(teams)
22
+ db[:memberships].upsert(memberships)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :client
29
+ attr_reader :storage
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "codeowners/git"
4
+
5
+ module Codeowners
6
+ class ListContributors
7
+ class Result < ::Codeowners::Result
8
+ def initialize(file = nil, contributors = [])
9
+ @file = file
10
+ @contributors = contributors
11
+ end
12
+
13
+ def successful?
14
+ !@file.nil?
15
+ end
16
+
17
+ def to_s
18
+ [@file, "", *@contributors.map(&:to_s)].join("\n")
19
+ end
20
+
21
+ def to_a
22
+ @contributors.dup
23
+ end
24
+
25
+ def to_csv
26
+ @contributors.map(&:to_csv).join("\n")
27
+ end
28
+ end
29
+
30
+ def initialize(base_directory, git: Git.new(base_directory))
31
+ @git = git
32
+ end
33
+
34
+ def call(file, debug = false)
35
+ contributors = @git.contributors(file, debug)
36
+ return Result.new if contributors.empty?
37
+
38
+ contributors = contributors.each.lazy.sort_by { |c| -c.insertions }
39
+
40
+ Result.new(file, contributors)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Codeowners
6
+ class ListOwners
7
+ def initialize(base_directory, codeowners)
8
+ @base_directory = Pathname.new(::File.expand_path(base_directory))
9
+ @codeowners = @base_directory.join(codeowners)
10
+ end
11
+
12
+ def call(file)
13
+ ::File.open(@codeowners, "r").each_line do |line|
14
+ line = line.chomp
15
+ next if line.empty? || line.match?(/[[:space:]]*#/)
16
+
17
+ pattern, *owners = line.split(/[[:space:]]+/)
18
+
19
+ return Result.new(pattern, owners) if File.fnmatch(pattern, file)
20
+ end
21
+
22
+ Result.new
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Codeowners
4
+ class Result
5
+ attr_reader :owners
6
+
7
+ def initialize(pattern = nil, owners = [])
8
+ @pattern = pattern
9
+ @owners = owners
10
+ end
11
+
12
+ def successful?
13
+ !@pattern.nil?
14
+ end
15
+
16
+ def to_s
17
+ "#{@pattern}\n\n#{@owners.join('\n')}"
18
+ end
19
+
20
+ def to_a
21
+ @owners.dup.flatten
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tempfile"
5
+ require "fileutils"
6
+
7
+ module Codeowners
8
+ class Storage
9
+ require "codeowners/storage/data"
10
+
11
+ def initialize(path)
12
+ @path = path
13
+ @data = Data.new(load_data)
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def transaction
18
+ @mutex.synchronize do
19
+ tmp_data = data.dup
20
+ yield tmp_data
21
+
22
+ Tempfile.open("codeowners-storage") do |tmp|
23
+ tmp.binmode
24
+ tmp.write(JSON.generate(data.dump))
25
+ tmp.close
26
+
27
+ FileUtils.mv(tmp.path, path, force: true)
28
+ end
29
+ end
30
+ end
31
+
32
+ def team_exist?(team)
33
+ data[:teams].find do |record|
34
+ record.fetch("slug") == team
35
+ end
36
+ end
37
+
38
+ def blacklisted_team?(team)
39
+ data[:teams].find do |record|
40
+ record.fetch("slug") == team &&
41
+ record.fetch("blacklisted")
42
+ end
43
+ end
44
+
45
+ def teams_for(user)
46
+ found = data[:users].find do |record|
47
+ record.fetch("email") == user.email ||
48
+ record.fetch("name") == user.name
49
+ end
50
+
51
+ return [] unless found
52
+
53
+ memberships = data[:memberships].find_all do |record|
54
+ record.fetch("user_id") == found.fetch("id")
55
+ end
56
+ memberships.map! { |hash| hash.fetch("team_id") }
57
+
58
+ return [] if memberships.empty?
59
+
60
+ teams = data[:teams].find_all do |record|
61
+ !record.fetch("blacklisted") &&
62
+ memberships.include?(record.fetch("id"))
63
+ end.flatten
64
+
65
+ teams
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :path
71
+ attr_reader :data
72
+
73
+ def load_data
74
+ return {} unless File.exist?(path)
75
+
76
+ JSON.parse(File.read(path))
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Codeowners
4
+ class Storage
5
+ class Collection
6
+ def initialize(collection)
7
+ @collection = collection.each_with_object({}) do |record, memo|
8
+ memo[record.fetch("id")] = record
9
+ end
10
+ end
11
+
12
+ def find(&blk)
13
+ collection.values.find(&blk)
14
+ end
15
+
16
+ def find_all(&blk)
17
+ collection.values.find_all(&blk)
18
+ end
19
+
20
+ def upsert(*records)
21
+ records = Array(records).flatten
22
+
23
+ records.each do |record|
24
+ collection[record.fetch(:id)] = record
25
+ end
26
+ end
27
+
28
+ def dump
29
+ collection.values.dup
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :collection
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "codeowners/storage/collection"
4
+
5
+ module Codeowners
6
+ class Storage
7
+ class Data
8
+ COLLECTIONS = %w[orgs users teams memberships].freeze
9
+ private_constant :COLLECTIONS
10
+
11
+ def initialize(data, collections: COLLECTIONS)
12
+ @data = collections.each_with_object({}) do |name, memo|
13
+ memo[name] = Collection.new(data.fetch(name, []))
14
+ end
15
+ end
16
+
17
+ def [](name)
18
+ data.fetch(name.to_s)
19
+ end
20
+
21
+ def dump
22
+ data.transform_values(&:dump)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :data
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Codeowners
4
+ VERSION = "0.0.3"
5
+ end
metadata ADDED
@@ -0,0 +1,129 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: codeowners
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Luca Guidi
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2020-07-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-cli
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: excon
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.75'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.75'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
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: byebug
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description: Check GitHub Codeowners and guess which team should be assigned to a
70
+ file
71
+ email:
72
+ - me@lucaguidi.com
73
+ executables:
74
+ - codeowners
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - ".gitignore"
79
+ - ".rubocop.yml"
80
+ - CHANGELOG.md
81
+ - Gemfile
82
+ - README.md
83
+ - Rakefile
84
+ - bin/console
85
+ - bin/setup
86
+ - codeowners.gemspec
87
+ - exe/codeowners
88
+ - lib/codeowners.rb
89
+ - lib/codeowners/cli.rb
90
+ - lib/codeowners/git.rb
91
+ - lib/codeowners/git/contributor.rb
92
+ - lib/codeowners/git/contributors.rb
93
+ - lib/codeowners/guess.rb
94
+ - lib/codeowners/import/client.rb
95
+ - lib/codeowners/import/organization.rb
96
+ - lib/codeowners/list_contributors.rb
97
+ - lib/codeowners/list_owners.rb
98
+ - lib/codeowners/result.rb
99
+ - lib/codeowners/storage.rb
100
+ - lib/codeowners/storage/collection.rb
101
+ - lib/codeowners/storage/data.rb
102
+ - lib/codeowners/version.rb
103
+ homepage: https://lucaguidi.com
104
+ licenses: []
105
+ metadata:
106
+ allowed_push_host: https://rubygems.org
107
+ homepage_uri: https://lucaguidi.com
108
+ source_code_uri: https://github.com/jodosha/codeowners
109
+ changelog_uri: https://github.com/jodosha/codeowners/blob/master/CHANGELOG.md
110
+ post_install_message:
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: 2.5.0
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubygems_version: 3.1.3
126
+ signing_key:
127
+ specification_version: 4
128
+ summary: GitHub Codeowners check and guess
129
+ test_files: []