codeowners 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []