libcodeowners 0.0.1

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.
data/Cargo.toml ADDED
@@ -0,0 +1,7 @@
1
+ # This Cargo.toml is here to let externals tools (IDEs, etc.) know that this is
2
+ # a Rust project. Your extensions dependencies should be added to the Cargo.toml
3
+ # in the ext/ directory.
4
+
5
+ [workspace]
6
+ members = ["./ext/libcodeowners"]
7
+ resolver = "2"
data/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # Libcodeowners
2
+
3
+ A thin Ruby wrapper around [codeowners-rs](https://github.com/rubyatscale/codeowners-rs)
4
+
5
+ ## Why?
6
+
7
+ The [codeowners-rs](https://github.com/rubyatscale/codeowners-rs) CLI is a fast alternative to the Ruby gem [code_ownership](https://github.com/rubyatscale/code_ownership). However, since codeowners-rs is written in Rust, it can't provide direct Ruby APIs.
8
+
9
+ **libcodeowners** provides Ruby APIs that delegate to codeowners-rs. Much of this code was lifted from [code_ownership](https://github.com/rubyatscale/code_ownership).
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ gem install libcodeowners
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```ruby
20
+ require 'libcodeowners'
21
+
22
+ team = Libcodeowners.for_file('path/to/file.rb')
23
+ ```
24
+
25
+ ## Contributing
26
+
27
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rubyatscale/libcodeowners.
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rb_sys/extensiontask'
9
+
10
+ task build: :compile
11
+
12
+ GEMSPEC = Gem::Specification.load('libcodeowners.gemspec')
13
+
14
+ RbSys::ExtensionTask.new('libcodeowners', GEMSPEC) do |ext|
15
+ ext.lib_dir = 'lib/libcodeowners'
16
+ ext.cross_compile = true
17
+ end
18
+
19
+ task default: %i[compile spec]
@@ -0,0 +1,15 @@
1
+ [package]
2
+ name = "libcodeowners"
3
+ version = "0.1.0"
4
+ edition = "2024"
5
+ authors = ["Perry Hertler <perry.hertler@gusto.com>"]
6
+ publish = false
7
+
8
+ [lib]
9
+ crate-type = ["cdylib"]
10
+
11
+ [dependencies]
12
+ magnus = { version = "0.7.1" }
13
+ serde = { version = "1.0.218", features = ["derive"] }
14
+ serde_magnus = "0.9.0"
15
+ codeowners = { git = "https://github.com/rubyatscale/codeowners-rs.git", tag = "v0.2.4" }
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mkmf'
4
+ require 'rb_sys/mkmf'
5
+
6
+ create_rust_makefile('libcodeowners/libcodeowners')
@@ -0,0 +1,76 @@
1
+ use std::{env, path::PathBuf};
2
+
3
+ use codeowners::runner::{self, RunConfig};
4
+ use magnus::{Error, Ruby, Value, function, prelude::*};
5
+ use serde::{Deserialize, Serialize};
6
+ use serde_magnus::serialize;
7
+
8
+ #[derive(Clone, Debug, Serialize, Deserialize)]
9
+ pub struct Team {
10
+ pub team_name: String,
11
+ pub team_config_yml: String,
12
+ }
13
+
14
+ fn for_file(file_path: String) -> Result<Option<Value>, Error> {
15
+ let run_config = build_run_config();
16
+
17
+ match runner::team_for_file_from_codeowners(&run_config, &file_path) {
18
+ Ok(Some(team_rs)) => {
19
+ let team = Team {
20
+ team_name: team_rs.name,
21
+ team_config_yml: team_rs.path.to_string_lossy().to_string(),
22
+ };
23
+ let serialized: Value = serialize(&team)?;
24
+ Ok(Some(serialized))
25
+ }
26
+ Ok(None) => Ok(None),
27
+ Err(e) => Err(Error::new(
28
+ magnus::exception::runtime_error(),
29
+ e.to_string(),
30
+ )),
31
+ }
32
+ }
33
+
34
+ fn generate_and_validate() -> Result<Value, Error> {
35
+ let run_config = build_run_config();
36
+ let run_result = runner::generate_and_validate(&run_config, vec![]);
37
+ if run_result.validation_errors.len() > 0 {
38
+ Err(Error::new(
39
+ magnus::exception::runtime_error(),
40
+ run_result.validation_errors.join("\n"),
41
+ ))
42
+ } else if run_result.io_errors.len() > 0 {
43
+ Err(Error::new(
44
+ magnus::exception::runtime_error(),
45
+ run_result.io_errors.join("\n"),
46
+ ))
47
+ } else {
48
+ let serialized: Value = serialize(&run_result.info_messages)?;
49
+ Ok(serialized)
50
+ }
51
+ }
52
+
53
+ fn build_run_config() -> RunConfig {
54
+ let project_root = match env::current_dir() {
55
+ Ok(path) => path,
56
+ _ => PathBuf::from("."),
57
+ };
58
+ let codeowners_file_path = project_root.join(".github/CODEOWNERS");
59
+ let config_path = project_root.join("config/code_ownership.yml");
60
+
61
+ RunConfig {
62
+ project_root,
63
+ codeowners_file_path,
64
+ config_path,
65
+ no_cache: false,
66
+ }
67
+ }
68
+
69
+ #[magnus::init]
70
+ fn init(ruby: &Ruby) -> Result<(), Error> {
71
+ let module = ruby.define_module("RustCodeOwners")?;
72
+ module.define_singleton_method("for_file", function!(for_file, 1))?;
73
+ module.define_singleton_method("generate_and_validate", function!(generate_and_validate, 0))?;
74
+
75
+ Ok(())
76
+ }
@@ -0,0 +1,5 @@
1
+ name: Bar
2
+ github:
3
+ team: '@BarTeam'
4
+ owned_globs:
5
+ - ruby/app/bar/**/*
@@ -0,0 +1,5 @@
1
+ name: Foo
2
+ github:
3
+ team: '@FooTeam'
4
+ owned_globs:
5
+ - ruby/app/foo/**/*
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Libcodeowners
6
+ module FilePathFinder
7
+ module_function
8
+
9
+ extend T::Sig
10
+ extend T::Helpers
11
+
12
+ # Returns a string version of the relative path to a Rails constant,
13
+ # or nil if it can't find anything
14
+ sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(String)) }
15
+ def path_from_klass(klass)
16
+ if klass
17
+ path = Object.const_source_location(klass.to_s)&.first
18
+ (path && Pathname.new(path).relative_path_from(Pathname.pwd).to_s) || nil
19
+ else
20
+ nil
21
+ end
22
+ rescue NameError
23
+ nil
24
+ end
25
+
26
+ sig { params(backtrace: T.nilable(T::Array[String])).returns(T::Enumerable[String]) }
27
+ def from_backtrace(backtrace)
28
+ return [] unless backtrace
29
+
30
+ # The pattern for a backtrace hasn't changed in forever and is considered
31
+ # stable: https://github.com/ruby/ruby/blob/trunk/vm_backtrace.c#L303-L317
32
+ #
33
+ # This pattern matches a line like the following:
34
+ #
35
+ # ./app/controllers/some_controller.rb:43:in `block (3 levels) in create'
36
+ #
37
+ backtrace_line = if RUBY_VERSION >= '3.4.0'
38
+ %r{\A(#{Pathname.pwd}/|\./)?
39
+ (?<file>.+) # Matches 'app/controllers/some_controller.rb'
40
+ :
41
+ (?<line>\d+) # Matches '43'
42
+ :in\s
43
+ '(?<function>.*)' # Matches "`block (3 levels) in create'"
44
+ \z}x
45
+ else
46
+ %r{\A(#{Pathname.pwd}/|\./)?
47
+ (?<file>.+) # Matches 'app/controllers/some_controller.rb'
48
+ :
49
+ (?<line>\d+) # Matches '43'
50
+ :in\s
51
+ `(?<function>.*)' # Matches "`block (3 levels) in create'"
52
+ \z}x
53
+ end
54
+
55
+ backtrace.lazy.filter_map do |line|
56
+ match = line.match(backtrace_line)
57
+ next unless match
58
+
59
+ T.must(match[:file])
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Libcodeowners
6
+ module FilePathTeamCache
7
+ module_function
8
+
9
+ extend T::Sig
10
+ extend T::Helpers
11
+
12
+ sig { params(file_path: String).returns(T.nilable(CodeTeams::Team)) }
13
+ def get(file_path)
14
+ cache[file_path]
15
+ end
16
+
17
+ sig { params(file_path: String, team: T.nilable(CodeTeams::Team)).void }
18
+ def set(file_path, team)
19
+ cache[file_path] = team
20
+ end
21
+
22
+ sig { params(file_path: String).returns(T::Boolean) }
23
+ def cached?(file_path)
24
+ cache.key?(file_path)
25
+ end
26
+
27
+ sig { void }
28
+ def bust_cache!
29
+ @cache = nil
30
+ end
31
+
32
+ sig { returns(T::Hash[String, T.nilable(CodeTeams::Team)]) }
33
+ def cache
34
+ @cache ||= T.let(@cache,
35
+ T.nilable(T::Hash[String, T.nilable(CodeTeams::Team)]))
36
+ @cache ||= {}
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Libcodeowners
6
+ module TeamFinder
7
+ module_function
8
+
9
+ extend T::Sig
10
+ extend T::Helpers
11
+
12
+ requires_ancestor { Kernel }
13
+
14
+ sig { params(file_path: String).returns(T.nilable(CodeTeams::Team)) }
15
+ def for_file(file_path)
16
+ return nil if file_path.start_with?('./')
17
+ return FilePathTeamCache.get(file_path) if FilePathTeamCache.cached?(file_path)
18
+
19
+ result = T.let(RustCodeOwners.for_file(file_path), T.nilable(T::Hash[Symbol, String]))
20
+ return if result.nil?
21
+
22
+ if result[:team_name].nil?
23
+ FilePathTeamCache.set(file_path, nil)
24
+ else
25
+ FilePathTeamCache.set(file_path, T.let(find_team!(T.must(result[:team_name])), T.nilable(CodeTeams::Team)))
26
+ end
27
+
28
+ FilePathTeamCache.get(file_path)
29
+ end
30
+
31
+ sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(::CodeTeams::Team)) }
32
+ def for_class(klass)
33
+ file_path = FilePathFinder.path_from_klass(klass)
34
+ return nil if file_path.nil?
35
+
36
+ for_file(file_path)
37
+ end
38
+
39
+ sig { params(package: Packs::Pack).returns(T.nilable(::CodeTeams::Team)) }
40
+ def for_package(package)
41
+ owner_name = package.raw_hash['owner'] || package.metadata['owner']
42
+ return nil if owner_name.nil?
43
+
44
+ find_team!(owner_name)
45
+ end
46
+
47
+ sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable(::CodeTeams::Team)) }
48
+ def for_backtrace(backtrace, excluded_teams: [])
49
+ first_owned_file_for_backtrace(backtrace, excluded_teams: excluded_teams)&.first
50
+ end
51
+
52
+ sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable([::CodeTeams::Team, String])) }
53
+ def first_owned_file_for_backtrace(backtrace, excluded_teams: [])
54
+ FilePathFinder.from_backtrace(backtrace).each do |file|
55
+ team = for_file(file)
56
+ if team && !excluded_teams.include?(team)
57
+ return [team, file]
58
+ end
59
+ end
60
+
61
+ nil
62
+ end
63
+
64
+ sig { params(team_name: String).returns(CodeTeams::Team) }
65
+ def find_team!(team_name)
66
+ CodeTeams.find(team_name) ||
67
+ raise(StandardError, "Could not find team with name: `#{team_name}`. Make sure the team is one of `#{CodeTeams.all.map(&:name).sort}`")
68
+ end
69
+
70
+ private_class_method(:find_team!)
71
+ end
72
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Libcodeowners
4
+ VERSION = '0.0.1'
5
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require 'code_teams'
6
+ require 'packs-specification'
7
+ require 'sorbet-runtime'
8
+ require_relative 'libcodeowners/file_path_team_cache'
9
+ require_relative 'libcodeowners/team_finder'
10
+ require_relative 'libcodeowners/version'
11
+ require_relative 'libcodeowners/libcodeowners'
12
+ require_relative 'libcodeowners/file_path_finder'
13
+ module Libcodeowners
14
+ module_function
15
+
16
+ extend T::Sig
17
+ extend T::Helpers
18
+ requires_ancestor { Kernel }
19
+
20
+ sig { params(file_path: String).returns(T.nilable(CodeTeams::Team)) }
21
+ def for_file(file_path)
22
+ TeamFinder.for_file(file_path)
23
+ end
24
+
25
+ sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(::CodeTeams::Team)) }
26
+ def for_class(klass)
27
+ TeamFinder.for_class(klass)
28
+ end
29
+
30
+ sig { params(package: Packs::Pack).returns(T.nilable(::CodeTeams::Team)) }
31
+ def for_package(package)
32
+ TeamFinder.for_package(package)
33
+ end
34
+
35
+ # Given a backtrace from either `Exception#backtrace` or `caller`, find the
36
+ # first line that corresponds to a file with assigned ownership
37
+ sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable(::CodeTeams::Team)) }
38
+ def for_backtrace(backtrace, excluded_teams: [])
39
+ TeamFinder.for_backtrace(backtrace, excluded_teams: excluded_teams)
40
+ end
41
+
42
+ # Given a backtrace from either `Exception#backtrace` or `caller`, find the
43
+ # first owned file in it, useful for figuring out which file is being blamed.
44
+ sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable([::CodeTeams::Team, String])) }
45
+ def first_owned_file_for_backtrace(backtrace, excluded_teams: [])
46
+ TeamFinder.first_owned_file_for_backtrace(backtrace, excluded_teams: excluded_teams)
47
+ end
48
+
49
+ sig { void }
50
+ def bust_cache!
51
+ FilePathTeamCache.bust_cache!
52
+ end
53
+ end
@@ -0,0 +1,4 @@
1
+ module Libcodeowners
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
data/sorbet/config ADDED
@@ -0,0 +1,6 @@
1
+ --dir
2
+ .
3
+ --ignore=/spec
4
+ --ignore=/tmp
5
+ --ignore=/vendor/bundle
6
+ --enable-experimental-requires-ancestor
@@ -0,0 +1,120 @@
1
+ # typed: true
2
+
3
+ # DO NOT EDIT MANUALLY
4
+ # This is an autogenerated file for types exported from the `code_teams` gem.
5
+ # Please instead update this file by running `bin/tapioca gem code_teams`.
6
+
7
+ module CodeTeams
8
+ class << self
9
+ sig { returns(T::Array[::CodeTeams::Team]) }
10
+ def all; end
11
+
12
+ sig { void }
13
+ def bust_caches!; end
14
+
15
+ sig { params(name: ::String).returns(T.nilable(::CodeTeams::Team)) }
16
+ def find(name); end
17
+
18
+ sig { params(dir: ::String).returns(T::Array[::CodeTeams::Team]) }
19
+ def for_directory(dir); end
20
+
21
+ sig { params(string: ::String).returns(::String) }
22
+ def tag_value_for(string); end
23
+
24
+ sig { params(teams: T::Array[::CodeTeams::Team]).returns(T::Array[::String]) }
25
+ def validation_errors(teams); end
26
+ end
27
+ end
28
+
29
+ class CodeTeams::IncorrectPublicApiUsageError < ::StandardError; end
30
+
31
+ class CodeTeams::Plugin
32
+ abstract!
33
+
34
+ sig { params(team: ::CodeTeams::Team).void }
35
+ def initialize(team); end
36
+
37
+ class << self
38
+ sig { returns(T::Array[T.class_of(CodeTeams::Plugin)]) }
39
+ def all_plugins; end
40
+
41
+ sig { params(team: ::CodeTeams::Team).returns(T.attached_class) }
42
+ def for(team); end
43
+
44
+ sig { params(base: T.untyped).void }
45
+ def inherited(base); end
46
+
47
+ sig { params(team: ::CodeTeams::Team, key: ::String).returns(::String) }
48
+ def missing_key_error_message(team, key); end
49
+
50
+ sig { params(teams: T::Array[::CodeTeams::Team]).returns(T::Array[::String]) }
51
+ def validation_errors(teams); end
52
+
53
+ private
54
+
55
+ sig { params(team: ::CodeTeams::Team).returns(T.attached_class) }
56
+ def register_team(team); end
57
+
58
+ sig { returns(T::Hash[T.nilable(::String), T::Hash[::Class, ::CodeTeams::Plugin]]) }
59
+ def registry; end
60
+ end
61
+ end
62
+
63
+ module CodeTeams::Plugins; end
64
+
65
+ class CodeTeams::Plugins::Identity < ::CodeTeams::Plugin
66
+ sig { returns(::CodeTeams::Plugins::Identity::IdentityStruct) }
67
+ def identity; end
68
+
69
+ class << self
70
+ sig { override.params(teams: T::Array[::CodeTeams::Team]).returns(T::Array[::String]) }
71
+ def validation_errors(teams); end
72
+ end
73
+ end
74
+
75
+ class CodeTeams::Plugins::Identity::IdentityStruct < ::Struct
76
+ def name; end
77
+ def name=(_); end
78
+
79
+ class << self
80
+ def [](*_arg0); end
81
+ def inspect; end
82
+ def members; end
83
+ def new(*_arg0); end
84
+ end
85
+ end
86
+
87
+ class CodeTeams::Team
88
+ sig { params(config_yml: T.nilable(::String), raw_hash: T::Hash[T.untyped, T.untyped]).void }
89
+ def initialize(config_yml:, raw_hash:); end
90
+
91
+ sig { params(other: ::Object).returns(T::Boolean) }
92
+ def ==(other); end
93
+
94
+ sig { returns(T.nilable(::String)) }
95
+ def config_yml; end
96
+
97
+ def eql?(*args, &blk); end
98
+
99
+ sig { returns(::Integer) }
100
+ def hash; end
101
+
102
+ sig { returns(::String) }
103
+ def name; end
104
+
105
+ sig { returns(T::Hash[T.untyped, T.untyped]) }
106
+ def raw_hash; end
107
+
108
+ sig { returns(::String) }
109
+ def to_tag; end
110
+
111
+ class << self
112
+ sig { params(raw_hash: T::Hash[T.untyped, T.untyped]).returns(::CodeTeams::Team) }
113
+ def from_hash(raw_hash); end
114
+
115
+ sig { params(config_yml: ::String).returns(::CodeTeams::Team) }
116
+ def from_yml(config_yml); end
117
+ end
118
+ end
119
+
120
+ CodeTeams::UNKNOWN_TEAM_STRING = T.let(T.unsafe(nil), String)
@@ -0,0 +1,80 @@
1
+ # typed: true
2
+
3
+ # DO NOT EDIT MANUALLY
4
+ # This is an autogenerated file for types exported from the `packs` gem.
5
+ # Please instead update this file by running `bin/tapioca gem packs`.
6
+
7
+ module Packs
8
+ class << self
9
+ sig { returns(T::Array[::Packs::Pack]) }
10
+ def all; end
11
+
12
+ sig { void }
13
+ def bust_cache!; end
14
+
15
+ sig { returns(::Packs::Configuration) }
16
+ def config; end
17
+
18
+ sig { params(blk: T.proc.params(arg0: ::Packs::Configuration).void).void }
19
+ def configure(&blk); end
20
+
21
+ sig { params(name: ::String).returns(T.nilable(::Packs::Pack)) }
22
+ def find(name); end
23
+
24
+ sig { params(file_path: T.any(::Pathname, ::String)).returns(T.nilable(::Packs::Pack)) }
25
+ def for_file(file_path); end
26
+
27
+ private
28
+
29
+ sig { returns(T::Array[::Pathname]) }
30
+ def package_glob_patterns; end
31
+
32
+ sig { returns(T::Hash[::String, ::Packs::Pack]) }
33
+ def packs_by_name; end
34
+ end
35
+ end
36
+
37
+ class Packs::Configuration
38
+ sig { void }
39
+ def initialize; end
40
+
41
+ sig { returns(T::Array[::Pathname]) }
42
+ def roots; end
43
+
44
+ sig { params(roots: T::Array[::String]).void }
45
+ def roots=(roots); end
46
+ end
47
+
48
+ Packs::PACKAGE_FILE = T.let(T.unsafe(nil), String)
49
+
50
+ class Packs::Pack < ::T::Struct
51
+ const :name, ::String
52
+ const :path, ::Pathname
53
+ const :raw_hash, T::Hash[T.untyped, T.untyped]
54
+ const :relative_path, ::Pathname
55
+
56
+ sig { returns(::String) }
57
+ def last_name; end
58
+
59
+ sig { returns(T::Hash[T.untyped, T.untyped]) }
60
+ def metadata; end
61
+
62
+ sig { returns(::Pathname) }
63
+ def yml; end
64
+
65
+ class << self
66
+ sig { params(package_yml_absolute_path: ::Pathname).returns(::Packs::Pack) }
67
+ def from(package_yml_absolute_path); end
68
+
69
+ def inherited(s); end
70
+ end
71
+ end
72
+
73
+ module Packs::Private
74
+ class << self
75
+ sig { returns(::Pathname) }
76
+ def root; end
77
+ end
78
+ end
79
+
80
+ Packs::ROOTS = T.let(T.unsafe(nil), Array)