codeowners 0.0.2 → 0.0.3

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: 66eab678e31ae2c7c45bf32e8b8a92163bb3cef3147475a95775ece36081dce2
4
- data.tar.gz: e188c85670d6cc4d1a0bb1d2f2fb6aee9085f1ad3b3ef978027d55ec4afc311d
3
+ metadata.gz: 790787fbbbface03dca93e7ee9fb70818d736b55b5b46feba832bb5d1d1cacde
4
+ data.tar.gz: 5cf3d0d5b9612527ac8e3d5416442dcb0442e1d69180b85df1b25752871e9d9e
5
5
  SHA512:
6
- metadata.gz: 0c4e4381b4cdd046065ec83a785d742b058768d9b27cb78b765ecfe230e76e4a1cc608a3f395496196be051601ae7e9f22a3050f64e97e70a0f3761db481a204
7
- data.tar.gz: 4037d4d9ba417ffe5ae65c3e50051f245c111f80e3880366b1bde609d936f15cef994a38a1438c64842447dbb2bb2f5685a6fd0c46268a065187699357541b9a
6
+ metadata.gz: 9728df04569e092a74db19feec631cb0f4031da24ab1bf7e127b6a728206c21026fc7a2988ff0c81f66b894a895350521a9f956236859e682eaa8be2f79194a3
7
+ data.tar.gz: 594590f13fe02ea9c8e7f5831e2d716a48779e7309f063caeb194c884a42d69037fed8ca64b6703ad88db7765f25258fa58d9d45e12b3eabb00875a016b1fb3a
data/.gitignore CHANGED
@@ -8,3 +8,4 @@
8
8
  /tmp/
9
9
  Gemfile.lock
10
10
  .rubocop-*
11
+ codeowners.json
@@ -1,6 +1,11 @@
1
1
  # Codeowners
2
2
  Simple CLI to interact with GitHub CODEOWNERS
3
3
 
4
+ ## v0.0.3 - 2020-07-22
5
+ ### Added
6
+ - [Luca Guidi] Added `codeowners guess`
7
+ - [Luca Guidi] Added `codeowners import org`
8
+
4
9
  ## v0.0.2 - 2020-06-27
5
10
  ### Added
6
11
  - [Luca Guidi] Added pattern support to `codeowners contributors`
@@ -27,6 +27,7 @@ Gem::Specification.new do |spec|
27
27
  spec.require_paths = ["lib"]
28
28
 
29
29
  spec.add_runtime_dependency "dry-cli", "~> 0.6"
30
+ spec.add_runtime_dependency "excon", "~> 0.75"
30
31
  spec.add_development_dependency "rubocop"
31
32
  spec.add_development_dependency "byebug"
32
33
  end
@@ -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
@@ -95,9 +95,104 @@ module Codeowners
95
95
  end
96
96
  end
97
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
+
98
188
  register "version", Version, aliases: ["v", "-v", "--version"]
99
189
  register "list", List
100
190
  register "contributors", Contributors
191
+ register "guess", Guess
192
+
193
+ register "import" do |prefix|
194
+ prefix.register "org", Import::Org
195
+ end
101
196
  end
102
197
  end
103
198
  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
@@ -18,6 +18,10 @@ module Codeowners
18
18
  [@file, "", *@contributors.map(&:to_s)].join("\n")
19
19
  end
20
20
 
21
+ def to_a
22
+ @contributors.dup
23
+ end
24
+
21
25
  def to_csv
22
26
  @contributors.map(&:to_csv).join("\n")
23
27
  end
@@ -16,5 +16,9 @@ module Codeowners
16
16
  def to_s
17
17
  "#{@pattern}\n\n#{@owners.join('\n')}"
18
18
  end
19
+
20
+ def to_a
21
+ @owners.dup.flatten
22
+ end
19
23
  end
20
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Codeowners
4
- VERSION = "0.0.2"
4
+ VERSION = "0.0.3"
5
5
  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.2
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luca Guidi
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-06-27 00:00:00.000000000 Z
11
+ date: 2020-07-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-cli
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
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'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: rubocop
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -76,9 +90,15 @@ files:
76
90
  - lib/codeowners/git.rb
77
91
  - lib/codeowners/git/contributor.rb
78
92
  - lib/codeowners/git/contributors.rb
93
+ - lib/codeowners/guess.rb
94
+ - lib/codeowners/import/client.rb
95
+ - lib/codeowners/import/organization.rb
79
96
  - lib/codeowners/list_contributors.rb
80
97
  - lib/codeowners/list_owners.rb
81
98
  - lib/codeowners/result.rb
99
+ - lib/codeowners/storage.rb
100
+ - lib/codeowners/storage/collection.rb
101
+ - lib/codeowners/storage/data.rb
82
102
  - lib/codeowners/version.rb
83
103
  homepage: https://lucaguidi.com
84
104
  licenses: []