codeowners 0.0.1 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 162d8c21d1f878a18c07817645f62191d2e4996d58533f819e6ebf102e7409fb
4
- data.tar.gz: 4e06893a39070f1c34178f62e31e9afea773096bb121d98cdefea1620825bd2a
3
+ metadata.gz: 96fe4773b399d9f3dc27c5b9bf8689b36e93adcb7a3359275624d55dcb84de0a
4
+ data.tar.gz: e2f6cd844cb20db9f2369c5572bf04972641e64069762977906387861b5d2d1c
5
5
  SHA512:
6
- metadata.gz: 579bbfb6ce368e0407d6d17f644e352b9f9823fd9ed71850189917d272e1ffe6177dd10650f0604172f7a4318d51cd550530c738397f8d90becff8348fbb701a
7
- data.tar.gz: 250bb637dfa66ef6cd4455c489e980e5475b1e67f5684cd70791dba1fe58b8d0e126d11031b3cd3b1f5eb1b6e0b5b5eb870f87e3bc6d7234d9713c1d299bbcd0
6
+ metadata.gz: c4583ed271c4eae4803fea24e590309e42782ac8988661b4180799219fee8c72c9467d353f3858ce109e878c6a868015ef44e95d7520d33ded81434f77bd0aec
7
+ data.tar.gz: 50fd38fea4c0196e57871df6ce6f5132655a8e1abcf814c46a5e7dd87f18262e5f77818bfa9596e214edbfab8a45dedd37abc76a078c93e54b463ddd0b1c95a8
@@ -0,0 +1,42 @@
1
+ name: ci
2
+
3
+ "on":
4
+ push:
5
+ paths:
6
+ - ".github/workflows/ci.yml"
7
+ - "lib/**"
8
+ - "*.gemspec"
9
+ - "spec/**"
10
+ - "Rakefile"
11
+ - "Gemfile"
12
+ - ".rubocop.yml"
13
+ pull_request:
14
+ branches:
15
+ - master
16
+ create:
17
+
18
+ jobs:
19
+ tests:
20
+ runs-on: ubuntu-latest
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ ruby:
25
+ - "3.0"
26
+ - "2.7"
27
+ - "2.6"
28
+ steps:
29
+ - uses: actions/checkout@v1
30
+ - name: Install package dependencies
31
+ run: "[ -e $APT_DEPS ] || sudo apt-get install -y --no-install-recommends $APT_DEPS"
32
+ - name: Set up Ruby
33
+ uses: ruby/setup-ruby@v1
34
+ with:
35
+ ruby-version: ${{matrix.ruby}}
36
+ - name: Install latest bundler
37
+ run: |
38
+ gem install bundler --no-document
39
+ - name: Bundle install
40
+ run: bundle install --jobs 4 --retry 3
41
+ - name: Run all tests
42
+ run: bundle exec rake
data/.gitignore CHANGED
@@ -8,3 +8,4 @@
8
8
  /tmp/
9
9
  Gemfile.lock
10
10
  .rubocop-*
11
+ codeowners.json
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/CHANGELOG.md CHANGED
@@ -1,6 +1,23 @@
1
1
  # Codeowners
2
2
  Simple CLI to interact with GitHub CODEOWNERS
3
3
 
4
+ ## v0.0.5 - 2021-12-18
5
+ ### Fixed
6
+ - [Luca Guidi] Added missing `require` of `Codeowners::Result` from `lib/codeowners/list_owners.rb`
7
+
8
+ ## v0.0.4 - 2021-05-21
9
+ ### Fixed
10
+ - [Luca Guidi] Make `codeowners list` compatible with [CODEOWNERS spec](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/creating-a-repository-on-github/about-code-owners#codeowners-syntax)
11
+
12
+ ## v0.0.3 - 2020-07-22
13
+ ### Added
14
+ - [Luca Guidi] Added `codeowners guess`
15
+ - [Luca Guidi] Added `codeowners import org`
16
+
17
+ ## v0.0.2 - 2020-06-27
18
+ ### Added
19
+ - [Luca Guidi] Added pattern support to `codeowners contributors`
20
+
4
21
  ## v0.0.1 - 2020-06-12
5
22
  ### Added
6
23
  - [Luca Guidi] Added `codeowners contributors`
data/README.md CHANGED
@@ -34,6 +34,17 @@ Person One <person.one@company.com> / +106, -0
34
34
  Person Two <person.two@company.com> / +12, -2
35
35
  ```
36
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
+
37
48
  ### Help
38
49
 
39
50
  For a complete set of options, please run:
data/Rakefile CHANGED
@@ -1,4 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rake"
3
4
  require "bundler/gem_tasks"
4
- task default: :spec
5
+ require "rspec/core/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec) do |task|
8
+ file_list = FileList["spec/**/*_spec.rb"]
9
+
10
+ task.pattern = file_list
11
+ end
12
+
13
+ task default: "spec"
data/codeowners.gemspec CHANGED
@@ -26,7 +26,9 @@ Gem::Specification.new do |spec|
26
26
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
27
27
  spec.require_paths = ["lib"]
28
28
 
29
- spec.add_runtime_dependency "dry-cli", "~> 0.6"
29
+ spec.add_runtime_dependency "dry-cli", "~> 0.7"
30
+ spec.add_runtime_dependency "excon", "~> 0.72"
31
+ spec.add_development_dependency "rspec"
30
32
  spec.add_development_dependency "rubocop"
31
33
  spec.add_development_dependency "byebug"
32
34
  end
@@ -55,23 +55,144 @@ module Codeowners
55
55
  DEFAULT_CODEOWNERS_PATH = ::File.join(".github", "CODEOWNERS").freeze
56
56
  private_constant :DEFAULT_CODEOWNERS_PATH
57
57
 
58
- desc "List code contributors for a file"
58
+ FORMAT_MAPPING = { "string" => "to_s", "csv" => "to_csv" }.freeze
59
+ private_constant :FORMAT_MAPPING
59
60
 
60
- argument :file, required: true, desc: "File to check"
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"
61
73
 
62
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
+ ]
63
82
 
64
- def call(file:, base_directory:, **)
65
- result = Codeowners::ListContributors.new(base_directory).call(file)
83
+ def call(file:, base_directory:, format:, debug:, **)
84
+ result = Codeowners::ListContributors.new(base_directory).call(file, debug)
66
85
  exit(1) unless result.successful?
67
86
 
68
- out.puts result.to_s
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
69
185
  end
70
186
  end
71
187
 
72
188
  register "version", Version, aliases: ["v", "-v", "--version"]
73
189
  register "list", List
74
190
  register "contributors", Contributors
191
+ register "guess", Guess
192
+
193
+ register "import" do |prefix|
194
+ prefix.register "org", Import::Org
195
+ end
75
196
  end
76
197
  end
77
198
  end
@@ -18,6 +18,10 @@ module Codeowners
18
18
  def to_s
19
19
  "#{name} <#{email}> / +#{insertions}, -#{deletions}"
20
20
  end
21
+
22
+ def to_csv
23
+ "#{name}, #{email}, #{insertions}, #{deletions}"
24
+ end
21
25
  end
22
26
  end
23
27
  end
@@ -36,27 +36,37 @@ module Codeowners
36
36
  while lines.any?
37
37
  commit = lines.take_while { |line| line != "" }
38
38
  yield parse(commit.dup) unless commit.empty?
39
- lines -= commit
40
- lines.shift
39
+ lines.shift(commit.size + 1)
41
40
  end
42
41
  end
43
42
 
44
43
  def self.parse(commit)
45
- stats = commit.pop
46
- stats = stats.split(", ")
44
+ authors, stats = commit.partition { |line| line.match?(/author:/) }
47
45
 
48
- _, insertions, deletions = *stats
49
- insertions = insertions.to_i
50
- deletions = deletions.to_i
46
+ [extract_authors(authors), *calculate_stats(stats)]
47
+ end
51
48
 
52
- authors = commit.map do |author|
49
+ def self.extract_authors(authors)
50
+ authors.map do |author|
53
51
  {
54
- "name" => author.scan(/author:(.*)email:/).flatten.first.chop,
55
- "email" => author.scan(/email:(.*)/).flatten.first
52
+ "name" => scan(author, /author:(.*)email:/).chop,
53
+ "email" => scan(author, /email:(.*)/)
56
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)
57
65
  end
66
+ end
58
67
 
59
- [authors.uniq, insertions, deletions]
68
+ def self.scan(string, pattern)
69
+ string.scan(pattern).flatten.first
60
70
  end
61
71
 
62
72
  def initialize(data)
@@ -9,9 +9,10 @@ module Codeowners
9
9
  @base_directory = Pathname.new(::File.expand_path(base_directory))
10
10
  end
11
11
 
12
- def contributors(file)
12
+ def contributors(file, debug = false)
13
13
  require "codeowners/git/contributors"
14
- output = git(["log", "--max-count=500", "--shortstat", %(--pretty=format:"author:%aN email:%ae"), "--no-color", "--", escape(file)])
14
+ output = git(["log", "--numstat", %(--pretty=format:"author:%aN email:%ae"), "--no-color", "--", escape(file)])
15
+ print_debug(output, debug)
15
16
 
16
17
  Contributors.call(file, output)
17
18
  end
@@ -42,5 +43,12 @@ module Codeowners
42
43
  return stdout.read
43
44
  end
44
45
  end
46
+
47
+ def print_debug(output, debug)
48
+ return unless debug
49
+
50
+ puts output
51
+ puts "\n" * 10
52
+ end
45
53
  end
46
54
  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
@@ -17,14 +17,22 @@ module Codeowners
17
17
  def to_s
18
18
  [@file, "", *@contributors.map(&:to_s)].join("\n")
19
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
20
28
  end
21
29
 
22
30
  def initialize(base_directory, git: Git.new(base_directory))
23
31
  @git = git
24
32
  end
25
33
 
26
- def call(file)
27
- contributors = @git.contributors(file)
34
+ def call(file, debug = false)
35
+ contributors = @git.contributors(file, debug)
28
36
  return Result.new if contributors.empty?
29
37
 
30
38
  contributors = contributors.each.lazy.sort_by { |c| -c.insertions }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
+ require_relative "./result"
4
5
 
5
6
  module Codeowners
6
7
  class ListOwners
@@ -10,16 +11,38 @@ module Codeowners
10
11
  end
11
12
 
12
13
  def call(file)
14
+ result = Result.new
15
+
13
16
  ::File.open(@codeowners, "r").each_line do |line|
14
17
  line = line.chomp
15
18
  next if line.empty? || line.match?(/[[:space:]]*#/)
16
19
 
17
20
  pattern, *owners = line.split(/[[:space:]]+/)
18
21
 
19
- return Result.new(pattern, owners) if File.fnmatch(pattern, file)
22
+ result = Result.new(pattern, owners) if match?(pattern, file)
20
23
  end
21
24
 
22
- Result.new
25
+ result
26
+ end
27
+
28
+ private
29
+
30
+ def match?(pattern, file)
31
+ pattern = normalize_pattern(pattern)
32
+ flags = match_flags_for(pattern)
33
+
34
+ File.fnmatch(pattern, file, flags)
35
+ end
36
+
37
+ def normalize_pattern(pattern)
38
+ pattern += "**" if pattern.end_with?(::File::SEPARATOR)
39
+ pattern
40
+ end
41
+
42
+ def match_flags_for(pattern)
43
+ return File::FNM_PATHNAME if pattern.end_with?(::File::SEPARATOR + "*")
44
+
45
+ 0
23
46
  end
24
47
  end
25
48
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Codeowners
4
4
  class Result
5
+ attr_reader :owners
6
+
5
7
  def initialize(pattern = nil, owners = [])
6
8
  @pattern = pattern
7
9
  @owners = owners
@@ -14,5 +16,9 @@ module Codeowners
14
16
  def to_s
15
17
  "#{@pattern}\n\n#{@owners.join('\n')}"
16
18
  end
19
+
20
+ def to_a
21
+ @owners.dup.flatten
22
+ end
17
23
  end
18
24
  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,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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Codeowners
4
- VERSION = "0.0.1"
4
+ VERSION = "0.0.5"
5
5
  end
data/lib/codeowners.rb CHANGED
@@ -3,8 +3,12 @@
3
3
  module Codeowners
4
4
  require "codeowners/version"
5
5
  require "codeowners/result"
6
+ require "codeowners/storage"
6
7
  require "codeowners/list_owners"
7
8
  require "codeowners/list_contributors"
9
+ require "codeowners/guess"
10
+ require "codeowners/import/client"
11
+ require "codeowners/import/organization"
8
12
 
9
13
  class Error < StandardError
10
14
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: codeowners
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luca Guidi
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-06-12 00:00:00.000000000 Z
11
+ date: 2021-12-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-cli
@@ -16,14 +16,42 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0.6'
19
+ version: '0.7'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0.6'
26
+ version: '0.7'
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.72'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.72'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
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'
27
55
  - !ruby/object:Gem::Dependency
28
56
  name: rubocop
29
57
  requirement: !ruby/object:Gem::Requirement
@@ -61,7 +89,9 @@ executables:
61
89
  extensions: []
62
90
  extra_rdoc_files: []
63
91
  files:
92
+ - ".github/workflows/ci.yml"
64
93
  - ".gitignore"
94
+ - ".rspec"
65
95
  - ".rubocop.yml"
66
96
  - CHANGELOG.md
67
97
  - Gemfile
@@ -76,9 +106,15 @@ files:
76
106
  - lib/codeowners/git.rb
77
107
  - lib/codeowners/git/contributor.rb
78
108
  - lib/codeowners/git/contributors.rb
109
+ - lib/codeowners/guess.rb
110
+ - lib/codeowners/import/client.rb
111
+ - lib/codeowners/import/organization.rb
79
112
  - lib/codeowners/list_contributors.rb
80
113
  - lib/codeowners/list_owners.rb
81
114
  - lib/codeowners/result.rb
115
+ - lib/codeowners/storage.rb
116
+ - lib/codeowners/storage/collection.rb
117
+ - lib/codeowners/storage/data.rb
82
118
  - lib/codeowners/version.rb
83
119
  homepage: https://lucaguidi.com
84
120
  licenses: []
@@ -87,7 +123,7 @@ metadata:
87
123
  homepage_uri: https://lucaguidi.com
88
124
  source_code_uri: https://github.com/jodosha/codeowners
89
125
  changelog_uri: https://github.com/jodosha/codeowners/blob/master/CHANGELOG.md
90
- post_install_message:
126
+ post_install_message:
91
127
  rdoc_options: []
92
128
  require_paths:
93
129
  - lib
@@ -102,8 +138,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
102
138
  - !ruby/object:Gem::Version
103
139
  version: '0'
104
140
  requirements: []
105
- rubygems_version: 3.1.3
106
- signing_key:
141
+ rubygems_version: 3.2.29
142
+ signing_key:
107
143
  specification_version: 4
108
144
  summary: GitHub Codeowners check and guess
109
145
  test_files: []