diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0ccf36..e664298 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,31 +16,28 @@ jobs: uses: oxidize-rb/actions/fetch-ci-data@v1 with: stable-ruby-versions: | - # See https://github.com/bytecodealliance/wasmtime-rb/issues/286 - # for details. - exclude: [head] + # See https://github.com/bytecodealliance/wasmtime-rb/issues/286 + # for details. + exclude: [head] rspec: - runs-on: ${{ matrix.os }} - needs: ci-data - strategy: - fail-fast: false - matrix: - os: ["ubuntu-latest", "macos-latest"] - ruby: ${{ fromJSON(needs.ci-data.outputs.result).stable-ruby-versions }} - steps: - - uses: actions/checkout@v4 - - uses: oxidize-rb/actions/setup-ruby-and-rust@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - cargo-cache: true - cache-version: v5 - - - name: Compile rust ext - run: bundle exec rake compile:release - - - name: Run ruby tests - run: bundle exec rake spec + runs-on: ${{ matrix.os }} + needs: ci-data + strategy: + fail-fast: false + matrix: + os: ["ubuntu-latest", "macos-latest"] + ruby: ${{ fromJSON(needs.ci-data.outputs.result).stable-ruby-versions }} + steps: + - uses: actions/checkout@v4 + - uses: oxidize-rb/actions/setup-ruby-and-rust@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + cargo-cache: true + - name: Compile andRun ruby tests + run: bundle exec rake + - name: Compile rust ext + run: bundle exec rake compile:release static_type_check: name: "Type Check" runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 76b4352..c46776e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,7 +58,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.60.2", + "windows-sys", ] [[package]] @@ -69,7 +69,7 @@ checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys", ] [[package]] @@ -200,8 +200,8 @@ dependencies = [ [[package]] name = "codeowners" -version = "0.2.14" -source = "git+https://github.com/rubyatscale/codeowners-rs.git?tag=v0.2.14#55832fc2bc34d961571fdf14e1a02761590aa2be" +version = "0.2.17" +source = "git+https://github.com/rubyatscale/codeowners-rs.git?tag=v0.2.17#cc9a3878918a795f982421263c1dc9e4f9cb860b" dependencies = [ "clap", "clap_derive", @@ -296,7 +296,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys", ] [[package]] @@ -349,9 +349,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" @@ -405,9 +405,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" dependencies = [ "equivalent", "hashbrown 0.15.5", @@ -635,9 +635,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -645,9 +645,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -758,7 +758,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys", ] [[package]] @@ -810,9 +810,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.142" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "itoa", "memchr", @@ -907,15 +907,15 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys", ] [[package]] @@ -1061,11 +1061,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys", ] [[package]] @@ -1080,15 +1080,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" diff --git a/ext/code_ownership/Cargo.toml b/ext/code_ownership/Cargo.toml index 5ec1f48..c1f640b 100644 --- a/ext/code_ownership/Cargo.toml +++ b/ext/code_ownership/Cargo.toml @@ -10,22 +10,22 @@ crate-type = ["cdylib"] [dependencies] rb-sys = { version = "0.9.111", features = [ - "bindgen-rbimpls", - "bindgen-deprecated-types", - "stable-api-compiled-fallback", + "bindgen-rbimpls", + "bindgen-deprecated-types", + "stable-api-compiled-fallback", ] } magnus = { version = "0.7.1" } serde = { version = "1.0.219", features = ["derive"] } serde_magnus = "0.9.0" -codeowners = { git = "https://github.com/rubyatscale/codeowners-rs.git", tag = "v0.2.14" } +codeowners = { git = "https://github.com/rubyatscale/codeowners-rs.git", tag = "v0.2.17" } [dev-dependencies] rb-sys = { version = "0.9.117", features = [ - "link-ruby", - "bindgen-rbimpls", - "bindgen-deprecated-types", - "stable-api-compiled-fallback", + "link-ruby", + "bindgen-rbimpls", + "bindgen-deprecated-types", + "stable-api-compiled-fallback", ] } [build-dependencies] -rb-sys-env = { version = "0.2.2" } \ No newline at end of file +rb-sys-env = { version = "0.2.2" } diff --git a/ext/code_ownership/src/lib.rs b/ext/code_ownership/src/lib.rs index c44297b..aa40e88 100644 --- a/ext/code_ownership/src/lib.rs +++ b/ext/code_ownership/src/lib.rs @@ -1,4 +1,4 @@ -use std::{env, path::PathBuf}; +use std::{collections::HashMap, env, path::PathBuf}; use codeowners::runner::{self, RunConfig}; use magnus::{Error, Ruby, Value, function, prelude::*}; @@ -18,6 +18,30 @@ fn for_team(team_name: String) -> Result { validate_result(&team) } +fn teams_for_files(file_paths: Vec) -> Result { + let run_config = build_run_config(); + let path_teams = runner::teams_for_files_from_codeowners(&run_config, &file_paths); + match path_teams { + Ok(path_teams) => { + let mut teams_map: HashMap> = HashMap::new(); + for (path, team) in path_teams { + if let Some(found_team) = team { + teams_map.insert(path, Some(Team { + team_name: found_team.name.to_string(), + team_config_yml: found_team.name.to_string(), + reasons: vec![], + })); + } else { + teams_map.insert(path, None); + } + } + let serialized: Value = serialize(&teams_map)?; + Ok(serialized) + } + Err(e) => Err(Error::new(magnus::exception::runtime_error(), e.to_string())), + } +} + fn for_file(file_path: String) -> Result, Error> { let run_config = build_run_config(); @@ -102,6 +126,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> { module.define_singleton_method("validate", function!(validate, 0))?; module.define_singleton_method("for_team", function!(for_team, 1))?; module.define_singleton_method("version", function!(version, 0))?; + module.define_singleton_method("teams_for_files", function!(teams_for_files, 1))?; Ok(()) } diff --git a/lib/code_ownership.rb b/lib/code_ownership.rb index 44b91fa..8a9cb04 100644 --- a/lib/code_ownership.rb +++ b/lib/code_ownership.rb @@ -34,17 +34,132 @@ module CodeOwnership requires_ancestor { Kernel } GlobsToOwningTeamMap = T.type_alias { T::Hash[String, CodeTeams::Team] } + # Returns the version of the code_ownership gem and the codeowners-rs gem. sig { returns(T::Array[String]) } def version ["code_ownership version: #{VERSION}", "codeowners-rs version: #{::RustCodeOwners.version}"] end - sig { params(file: String).returns(T.nilable(CodeTeams::Team)) } - def for_file(file) - Private::TeamFinder.for_file(file) + # Returns the owning team for a given file path. + # + # @param file [String] The path to the file to find ownership for. Can be relative or absolute. + # @param from_codeowners [Boolean] (default: true) When true, uses CODEOWNERS file to determine ownership. + # When false, uses alternative team finding strategies (e.g., package ownership). + # from_codeowners true is faster because it simply matches the provided file to the generate CODEOWNERS file. This is a safe option when you can trust the CODEOWNERS file to be up to date. + # @param allow_raise [Boolean] (default: false) When true, raises an exception if ownership cannot be determined. + # When false, returns nil for files without ownership. + # + # @return [CodeTeams::Team, nil] The team that owns the file, or nil if no owner is found + # (unless allow_raise is true, in which case an exception is raised). + # + # @example Find owner for a file using CODEOWNERS + # team = CodeOwnership.for_file('app/models/user.rb') + # # => # + # + # @example Find owner without using CODEOWNERS + # team = CodeOwnership.for_file('app/models/user.rb', from_codeowners: false) + # # => # + # + # @example Raise if no owner is found + # team = CodeOwnership.for_file('unknown_file.rb', allow_raise: true) + # # => raises exception if no owner found + # + sig { params(file: String, from_codeowners: T::Boolean, allow_raise: T::Boolean).returns(T.nilable(CodeTeams::Team)) } + def for_file(file, from_codeowners: true, allow_raise: false) + if from_codeowners + teams_for_files_from_codeowners([file], allow_raise: allow_raise).values.first + else + Private::TeamFinder.for_file(file, allow_raise: allow_raise) + end + end + + # Returns the owning teams for multiple file paths using the CODEOWNERS file. + # + # This method efficiently determines ownership for multiple files in a single operation + # by leveraging the generated CODEOWNERS file. It's more performant than calling + # `for_file` multiple times when you need to check ownership for many files. + # + # @param files [Array] An array of file paths to find ownership for. + # Paths can be relative to the project root or absolute. + # @param allow_raise [Boolean] (default: false) When true, raises an exception if a team + # name in CODEOWNERS cannot be resolved to an actual team. + # When false, returns nil for files with unresolvable teams. + # + # @return [T::Hash[String, T.nilable(CodeTeams::Team)]] A hash mapping each file path to its + # owning team. Files without ownership + # or with unresolvable teams will map to nil. + # + # @example Get owners for multiple files + # files = ['app/models/user.rb', 'app/controllers/users_controller.rb', 'config/routes.rb'] + # owners = CodeOwnership.teams_for_files_from_codeowners(files) + # # => { + # # 'app/models/user.rb' => #, + # # 'app/controllers/users_controller.rb' => #, + # # 'config/routes.rb' => # + # # } + # + # @example Handle files without owners + # files = ['owned_file.rb', 'unowned_file.txt'] + # owners = CodeOwnership.teams_for_files_from_codeowners(files) + # # => { + # # 'owned_file.rb' => #, + # # 'unowned_file.txt' => nil + # # } + # + # @note This method uses caching internally for performance. The cache is populated + # as files are processed and reused for subsequent lookups. + # + # @note This method relies on the CODEOWNERS file being up-to-date. Run + # `CodeOwnership.validate!` to ensure the CODEOWNERS file is current. + # + # @see #for_file for single file ownership lookup + # @see #validate! for ensuring CODEOWNERS file is up-to-date + # + sig { params(files: T::Array[String], allow_raise: T::Boolean).returns(T::Hash[String, T.nilable(CodeTeams::Team)]) } + def teams_for_files_from_codeowners(files, allow_raise: false) + Private::TeamFinder.teams_for_files(files, allow_raise: allow_raise) end + # Returns detailed ownership information for a given file path. + # + # This method provides verbose ownership details including the team name, + # team configuration file path, and the reasons/sources for ownership assignment. + # It's particularly useful for debugging ownership assignments and understanding + # why a file is owned by a specific team. + # + # @param file [String] The path to the file to find ownership for. Can be relative or absolute. + # + # @return [T::Hash[Symbol, String], nil] A hash containing detailed ownership information, + # or nil if no owner is found. + # + # The returned hash contains the following keys when an owner is found: + # - :team_name [String] - The name of the owning team + # - :team_config_yml [String] - Path to the team's configuration YAML file + # - :reasons [Array] - List of reasons/sources explaining why this team owns the file + # (e.g., "CODEOWNERS pattern: /app/models/**", "Package ownership") + # + # @example Get verbose ownership details + # details = CodeOwnership.for_file_verbose('app/models/user.rb') + # # => { + # # team_name: "platform", + # # team_config_yml: "config/teams/platform.yml", + # # reasons: ["Matched pattern '/app/models/**' in CODEOWNERS"] + # # } + # + # @example Handle unowned files + # details = CodeOwnership.for_file_verbose('unowned_file.txt') + # # => nil + # + # @note This method is primarily used by the CLI tool when the --verbose flag is provided, + # allowing users to understand the ownership assignment logic. + # + # @note Unlike `for_file`, this method always uses the CODEOWNERS file and other ownership + # sources to determine ownership, providing complete context about the ownership decision. + # + # @see #for_file for a simpler ownership lookup that returns just the team + # @see CLI#for_file for the command-line interface that uses this method + # sig { params(file: String).returns(T.nilable(T::Hash[Symbol, String])) } def for_file_verbose(file) ::RustCodeOwners.for_file(file) @@ -56,9 +171,55 @@ def for_team(team) ::RustCodeOwners.for_team(team.name) end - class InvalidCodeOwnershipConfigurationError < StandardError - end - + # Validates code ownership configuration and optionally corrects issues. + # + # This method performs comprehensive validation of the code ownership setup, ensuring: + # 1. Only one ownership mechanism is defined per file (no conflicts between annotations, packages, or globs) + # 2. All referenced teams are valid (exist in CodeTeams configuration) + # 3. All files have ownership (unless explicitly listed in unowned_globs) + # 4. The .github/CODEOWNERS file is up-to-date and properly formatted + # + # When autocorrect is enabled, the method will automatically: + # - Generate or update the CODEOWNERS file based on current ownership rules + # - Fix any formatting issues in the CODEOWNERS file + # - Stage the corrected CODEOWNERS file (unless stage_changes is false) + # + # @param autocorrect [Boolean] Whether to automatically fix correctable issues (default: true) + # When true, regenerates and updates the CODEOWNERS file + # When false, only validates without making changes + # + # @param stage_changes [Boolean] Whether to stage the CODEOWNERS file after autocorrection (default: true) + # Only applies when autocorrect is true + # When false, changes are written but not staged with git + # + # @param files [Array, nil] Ignored. This is a legacy parameter that is no longer used. + # + # @return [void] + # + # @raise [RuntimeError] Raises an error if validation fails with details about: + # - Files with conflicting ownership definitions + # - References to non-existent teams + # - Files without ownership (not in unowned_globs) + # - CODEOWNERS file inconsistencies + # + # @example Basic validation with autocorrection + # CodeOwnership.validate! + # # Validates all files and auto-corrects/stages CODEOWNERS if needed + # + # @example Validation without making changes + # CodeOwnership.validate!(autocorrect: false) + # # Only checks for issues without updating CODEOWNERS + # + # @example Validate and fix but don't stage changes + # CodeOwnership.validate!(autocorrect: true, stage_changes: false) + # # Fixes CODEOWNERS but doesn't stage it with git + # + # @note This method is called by the CLI command: bin/codeownership validate + # @note The validation can be disabled for CODEOWNERS by setting skip_codeowners_validation: true in config/code_ownership.yml + # + # @see CLI.validate! for the command-line interface + # @see https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners for CODEOWNERS format + # sig do params( autocorrect: T::Boolean, diff --git a/lib/code_ownership/code_ownership.bundle b/lib/code_ownership/code_ownership.bundle index c51e6bc..ba0933a 100755 Binary files a/lib/code_ownership/code_ownership.bundle and b/lib/code_ownership/code_ownership.bundle differ diff --git a/lib/code_ownership/private/for_file_output_builder.rb b/lib/code_ownership/private/for_file_output_builder.rb index d667091..7fa75d1 100644 --- a/lib/code_ownership/private/for_file_output_builder.rb +++ b/lib/code_ownership/private/for_file_output_builder.rb @@ -53,7 +53,7 @@ def build_verbose sig { returns(T::Hash[Symbol, T.untyped]) } def build_terse - team = CodeOwnership.for_file(@file_path) + team = CodeOwnership.for_file(@file_path, from_codeowners: false, allow_raise: true) if team.nil? UNOWNED_OUTPUT diff --git a/lib/code_ownership/private/team_finder.rb b/lib/code_ownership/private/team_finder.rb index d65aa07..2dd4931 100644 --- a/lib/code_ownership/private/team_finder.rb +++ b/lib/code_ownership/private/team_finder.rb @@ -12,8 +12,8 @@ module TeamFinder requires_ancestor { Kernel } - sig { params(file_path: String).returns(T.nilable(CodeTeams::Team)) } - def for_file(file_path) + sig { params(file_path: String, allow_raise: T::Boolean).returns(T.nilable(CodeTeams::Team)) } + def for_file(file_path, allow_raise: false) return nil if file_path.start_with?('./') return FilePathTeamCache.get(file_path) if FilePathTeamCache.cached?(file_path) @@ -24,12 +24,22 @@ def for_file(file_path) if result[:team_name].nil? FilePathTeamCache.set(file_path, nil) else - FilePathTeamCache.set(file_path, T.let(find_team!(T.must(result[:team_name])), T.nilable(CodeTeams::Team))) + FilePathTeamCache.set(file_path, T.let(find_team!(T.must(result[:team_name]), allow_raise: allow_raise), T.nilable(CodeTeams::Team))) end FilePathTeamCache.get(file_path) end + sig { params(files: T::Array[String], allow_raise: T::Boolean).returns(T::Hash[String, T.nilable(CodeTeams::Team)]) } + def teams_for_files(files, allow_raise: false) + ::RustCodeOwners.teams_for_files(files).each_with_object({}) do |path_team, hash| + file_path, team = path_team + found_team = team ? find_team!(team[:team_name], allow_raise: allow_raise) : nil + FilePathTeamCache.set(file_path, found_team) + hash[file_path] = found_team + end + end + sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(::CodeTeams::Team)) } def for_class(klass) file_path = FilePathFinder.path_from_klass(klass) @@ -43,7 +53,7 @@ def for_package(package) owner_name = package.raw_hash['owner'] || package.metadata['owner'] return nil if owner_name.nil? - find_team!(owner_name) + find_team!(owner_name, allow_raise: true) end sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable(::CodeTeams::Team)) } @@ -63,10 +73,14 @@ def first_owned_file_for_backtrace(backtrace, excluded_teams: []) nil end - sig { params(team_name: String).returns(CodeTeams::Team) } - def find_team!(team_name) - CodeTeams.find(team_name) || + sig { params(team_name: String, allow_raise: T::Boolean).returns(T.nilable(CodeTeams::Team)) } + def find_team!(team_name, allow_raise: false) + team = CodeTeams.find(team_name) + if team.nil? && allow_raise raise(StandardError, "Could not find team with name: `#{team_name}`. Make sure the team is one of `#{CodeTeams.all.map(&:name).sort}`") + end + + team end private_class_method(:find_team!) diff --git a/lib/code_ownership/version.rb b/lib/code_ownership/version.rb index b68d517..635a2c7 100644 --- a/lib/code_ownership/version.rb +++ b/lib/code_ownership/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module CodeOwnership - VERSION = '2.0.0-2' + VERSION = '2.0.0-3' end diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..ff1a27d --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.89.0" +components = ["clippy", "rustfmt"] +targets = ["x86_64-apple-darwin", "aarch64-apple-darwin", "x86_64-unknown-linux-gnu"] diff --git a/sorbet/rbi/manual.rbi b/sorbet/rbi/manual.rbi index c865d0f..1f0a301 100644 --- a/sorbet/rbi/manual.rbi +++ b/sorbet/rbi/manual.rbi @@ -18,5 +18,8 @@ module RustCodeOwners def version end + + def teams_for_files(files) + end end end diff --git a/spec/lib/code_ownership_spec.rb b/spec/lib/code_ownership_spec.rb index 8eabecc..aed33b6 100644 --- a/spec/lib/code_ownership_spec.rb +++ b/spec/lib/code_ownership_spec.rb @@ -5,51 +5,209 @@ expect(CodeOwnership::VERSION).not_to be nil end - describe '.for_file' do - subject { CodeOwnership.for_file(file_path) } - context 'rust codeowners' do - context 'when config is not found' do - let(:file_path) { 'app/javascript/[test]/test.js' } - it 'raises an error' do - expect { subject }.to raise_error(RuntimeError, /Can't open config file:/) + context 'teams_for_files_from_codeowners' do + subject { CodeOwnership.teams_for_files_from_codeowners(files) } + let(:files) { ['app/services/my_file.rb'] } + + context 'when config is not found' do + let(:files) { ['app/javascript/[test]/test.js'] } + it 'raises an error' do + expect { subject }.to raise_error(RuntimeError, /Can't open config file:/) + end + end + + context 'with non-empty application' do + before do + create_non_empty_application + # codeowners-rs is matching files against the codeowners file + RustCodeOwners.generate_and_validate(false) + end + + context 'when no ownership is found' do + let(:files) { ['app/madeup/file.rb'] } + it 'properly assigns ownership' do + expect(subject).to eq({ 'app/madeup/file.rb' => nil }) end end - context 'with non-empty application' do - before do - create_non_empty_application + context 'when file path starts with ./' do + let(:files) { ['./app/javascript/[test]/test.js'] } + it 'properly assigns ownership' do + expect(subject).to eq({ './app/javascript/[test]/test.js' => nil }) end + end - context 'when no ownership is found' do - let(:file_path) { 'app/madeup/file.rb' } - it 'properly assigns ownership' do - expect(subject).to be_nil - end + context 'when ownership is found' do + let(:files) { ['packs/my_pack/owned_file.rb'] } + it 'returns the correct team' do + expect(subject).to eq({ 'packs/my_pack/owned_file.rb' => CodeTeams.find('Bar') }) end - context 'when file path starts with ./' do - let(:file_path) { './app/javascript/[test]/test.js' } - it 'properly assigns ownership' do - expect(subject).to be_nil + context 'subsequent for_file utilizes cached team' do + let(:files) { ['packs/my_pack/owned_file.rb', 'packs/my_pack/owned_file2.rb'] } + it 'returns the correct team' do + subject # caches paths -> teams + allow(RustCodeOwners).to receive(:for_file) + expect(described_class.for_file('packs/my_pack/owned_file.rb')).to eq(CodeTeams.find('Bar')) + expect(RustCodeOwners).to_not have_received(:for_file) end end + end + + context 'when ownership is found but team is not found' do + let(:file_path) { ['packs/my_pack/owned_file.rb'] } + before do + allow(RustCodeOwners).to receive(:teams_for_files).and_return({ file_path.first => { team_name: 'Made Up Team' } }) + end + + it 'returns nil' do + expect(subject).to eq({ 'packs/my_pack/owned_file.rb' => nil }) + end + end + + context 'when ownership is found but team is not found and allow_raise is true' do + let(:files) { ['packs/my_pack/owned_file.rb'] } + before do + allow(RustCodeOwners).to receive(:teams_for_files).and_return({ files.first => { team_name: 'Made Up Team' } }) + end + + it 'raises an error' do + expect { CodeOwnership.teams_for_files_from_codeowners(files, allow_raise: true) }.to raise_error(StandardError, /Could not find team with name:/) + end + end + end + end + + describe '.for_file_from_codeowners' do + subject { CodeOwnership.for_file(file_path, from_codeowners: true) } + + context 'when config is not found' do + let(:file_path) { 'app/javascript/[test]/test.js' } + it 'raises an error' do + expect { subject }.to raise_error(RuntimeError, /Can't open config file:/) + end + end + + context 'with non-empty application' do + before do + create_non_empty_application + # codeowners-rs is matching files against the codeowners file + RustCodeOwners.generate_and_validate(false) + end + + context 'when no ownership is found' do + let(:file_path) { 'app/madeup/file.rb' } + it 'properly assigns ownership' do + expect(subject).to be_nil + end + end + + context 'when file path starts with ./' do + let(:file_path) { './app/javascript/[test]/test.js' } + it 'properly assigns ownership' do + expect(subject).to be_nil + end + end + + context 'when ownership is found' do + let(:file_path) { 'packs/my_pack/owned_file.rb' } + it 'returns the correct team' do + expect(subject).to eq CodeTeams.find('Bar') + end - context 'when ownership is found' do - let(:file_path) { 'packs/my_pack/owned_file.rb' } + context 'subsequent for_file utilizes cached team' do it 'returns the correct team' do - expect(subject).to eq CodeTeams.find('Bar') + subject # caches path -> team + allow(RustCodeOwners).to receive(:for_file) + expect(described_class.for_file(file_path)).to eq(CodeTeams.find('Bar')) + expect(RustCodeOwners).to_not have_received(:for_file) end end + end - context 'when ownership is found but team is not found' do - let(:file_path) { 'packs/my_pack/owned_file.rb' } - before do - allow(RustCodeOwners).to receive(:for_file).and_return({ team_name: 'Made Up Team' }) - end + context 'when ownership is found but team is not found' do + let(:file_path) { 'packs/my_pack/owned_file.rb' } + before do + allow(RustCodeOwners).to receive(:teams_for_files).and_return({ file_path => { team_name: 'Made Up Team' } }) + end - it 'raises an error' do - expect { subject }.to raise_error(StandardError, /Could not find team with name: `Made Up Team`. Make sure the team is one of/) - end + it 'returns nil' do + expect(subject).to be_nil + end + end + + context 'when ownership is found but team is not found and allow_raise is true' do + let(:file_path) { 'packs/my_pack/owned_file.rb' } + before do + allow(RustCodeOwners).to receive(:teams_for_files).and_return({ file_path => { team_name: 'Made Up Team' } }) + end + + it 'raises an error' do + expect { CodeOwnership.for_file(file_path, from_codeowners: true, allow_raise: true) }.to raise_error(StandardError, /Could not find team with name:/) + end + end + end + end + + describe '.for_file' do + subject { CodeOwnership.for_file(file_path) } + context 'when config is not found' do + let(:file_path) { 'app/javascript/[test]/test.js' } + it 'raises an error' do + expect { subject }.to raise_error(RuntimeError, /Can't open config file:/) + end + end + + context 'with non-empty application' do + before do + create_non_empty_application + # codeowners-rs is matching files against the codeowners file for default path + RustCodeOwners.generate_and_validate(false) + end + + context 'when no ownership is found' do + let(:file_path) { 'app/madeup/file.rb' } + it 'properly assigns ownership' do + expect(subject).to be_nil + end + end + + context 'when file path starts with ./' do + let(:file_path) { './app/javascript/[test]/test.js' } + it 'properly assigns ownership' do + expect(subject).to be_nil + end + end + + context 'when ownership is found' do + let(:file_path) { 'packs/my_pack/owned_file.rb' } + it 'returns the correct team' do + expect(subject).to eq CodeTeams.find('Bar') + end + end + + context 'when ownership is found but team is not found' do + let(:file_path) { 'packs/my_pack/owned_file.rb' } + before do + allow(RustCodeOwners).to receive(:teams_for_files).and_return({ file_path => { team_name: 'Made Up Team' } }) + end + + it 'returns nil by default' do + expect(subject).to be_nil + end + end + + context 'when ownership is found but team is not found and allow_raise is true' do + let(:file_path) { 'packs/my_pack/owned_file.rb' } + + it 'raises an error when using from_codeowners path' do + allow(RustCodeOwners).to receive(:teams_for_files).and_return({ file_path => { team_name: 'Made Up Team' } }) + expect { CodeOwnership.for_file(file_path, allow_raise: true) }.to raise_error(StandardError, /Could not find team with name:/) + end + + it 'raises an error when using single-file path' do + allow(RustCodeOwners).to receive(:for_file).and_return({ team_name: 'Made Up Team' }) + expect { CodeOwnership.for_file(file_path, from_codeowners: false, allow_raise: true) }.to raise_error(StandardError, /Could not find team with name:/) end end end @@ -215,7 +373,7 @@ describe '.version' do it 'returns the version' do - expect(described_class.version).to eq ["code_ownership version: #{CodeOwnership::VERSION}", "codeowners-rs version: #{::RustCodeOwners.version}"] + expect(described_class.version).to eq ["code_ownership version: #{CodeOwnership::VERSION}", "codeowners-rs version: #{RustCodeOwners.version}"] end end end