diff --git a/CHANGELOG.md b/CHANGELOG.md index b4d8c29..4119975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `--all-repos` option to audit all repositories in the organisation (not just the current repo) +- `--all` option to audit all repositories in the organisation (not only the current repo) - `--topic` option to filter repositories by GitHub topics (e.g., way-of-working) +- `--name` option to filter repositories by name (supports multiple names) +- `--public` option to filter to only public repositories - `--fix` option to automatically fix issues where possible (passed to individual rules) ## [1.0.1] - 2025-01-24 diff --git a/README.md b/README.md index 6676a5e..d349762 100644 --- a/README.md +++ b/README.md @@ -38,20 +38,37 @@ Then to run the GitHub audit for your project, use: way_of_working exec audit_github ``` -By default, the audit runs only against repositories that are configured as git remotes in your current project. To audit all repositories in your organisation, use the `--all-repos` flag: +By default, the audit runs only against repositories that are configured as git remotes in your current project. To audit all repositories in your organisation, use the `--all` flag: ```bash -way_of_working exec audit_github --all-repos +way_of_working exec audit_github --all ``` You can filter repositories by topic using the `--topic` flag. This accepts a single topic and will only audit repositories that have that topic: ```bash # Audit all repos with the 'way-of-working' topic -way_of_working exec audit_github --all-repos --topic way-of-working +way_of_working exec audit_github --all --topic way-of-working # Audit all repos with the 'indoor-mapping' topic -way_of_working exec audit_github --all-repos --topic indoor-mapping +way_of_working exec audit_github --all --topic indoor-mapping +``` + +You can filter repositories by name using the `--name` flag. This accepts one or more repository names and automatically audits all repositories in the organisation (you don't need to specify `--all`): + +```bash +# Audit a single repository by name +way_of_working exec audit_github --name structured_store + +# Audit multiple repositories by name +way_of_working exec audit_github --name structured_store other_repo +``` + +You can filter to only public repositories using the `--public` flag: + +```bash +# Audit all public repos +way_of_working exec audit_github --all --public ``` To automatically fix issues where possible, use the `--fix` flag: @@ -61,7 +78,7 @@ To automatically fix issues where possible, use the `--fix` flag: way_of_working exec audit_github --fix # Audit and fix issues in all repos with a specific topic -way_of_working exec audit_github --all-repos --topic way-of-working --fix +way_of_working exec audit_github --all --topic way-of-working --fix ``` Note: The `--fix` flag is passed to individual rules, which may implement automatic fixes for their specific checks. Not all rules support automatic fixing. diff --git a/lib/way_of_working/audit/github/generators/exec.rb b/lib/way_of_working/audit/github/generators/exec.rb index 816e6f5..aa98bfe 100644 --- a/lib/way_of_working/audit/github/generators/exec.rb +++ b/lib/way_of_working/audit/github/generators/exec.rb @@ -12,17 +12,21 @@ module Github module Generators # This generator runs the github audit class Exec < Thor::Group - # argument :all_repos, type: :string, required: false, desc: 'Optional repo to test' + class_option :all, type: :boolean, default: false, + desc: 'Audit all repositories in the organisation (not just this repo)' - class_option :all_repos, type: :boolean, default: false, - desc: 'Audit all repositories in the organisation (not just this repo)' + class_option :fix, type: :boolean, default: false, + desc: 'Attempt to automatically fix issues where possible' + + class_option :name, type: :array, default: nil, + desc: 'Filter repositories by name (e.g., structured_store)' + + class_option :public, type: :boolean, default: false, + desc: 'Filter to only public repositories' class_option :topic, type: :string, default: nil, desc: 'Filter repositories by topic (e.g., way-of-working)' - class_option :fix, type: :boolean, default: false, - desc: 'Attempt to automatically fix issues where possible' - desc 'This runs the github audit on this project' def check_for_github_token_environment_variables @@ -55,20 +59,38 @@ def prep_audit # Loop though all the repos @repositories = @auditor.repositories - unless options[:all_repos] - @repositories = @repositories.select do |repo| - github_organisation_remotes.include?(repo.name) - end + rescue Octokit::Unauthorized + abort(Rainbow("\nGITHUB_TOKEN has expired or does not have sufficient permission").red) + end + + def filter_all_if_specified + return if options[:all] || options[:name] + + @repositories = @repositories.select do |repo| + github_organisation_remotes.include?(repo.name) end + end - # Filter by topic if specified - if options[:topic] - @repositories = @repositories.select do |repo| - repo.topics.include?(options[:topic]) - end + def filter_by_name_array_if_specified + return unless options[:name] + + @repositories = @repositories.select do |repo| + options[:name].include?(repo.name) end - rescue Octokit::Unauthorized - abort(Rainbow("\nGITHUB_TOKEN has expired or does not have sufficient permission").red) + end + + def filter_by_topic_if_specified + return unless options[:topic] + + @repositories = @repositories.select do |repo| + repo.topics.include?(options[:topic]) + end + end + + def filter_by_visibility_if_specified + return unless options[:public] + + @repositories = @repositories.reject(&:private?) end def run_audit diff --git a/test/way_of_working/audit/github/generators/exec_test.rb b/test/way_of_working/audit/github/generators/exec_test.rb index d939aa1..51c065e 100644 --- a/test/way_of_working/audit/github/generators/exec_test.rb +++ b/test/way_of_working/audit/github/generators/exec_test.rb @@ -18,8 +18,8 @@ class ExecTest < Rails::Generators::TestCase # Mock Git operations mock_git = mock mock_git.stubs(:remotes).returns([ - stub(url: 'https://github.com/test_org/test_repo.git') - ]) + stub(url: 'https://github.com/test_org/test_repo.git') + ]) Git.stubs(:open).returns(mock_git) end @@ -29,10 +29,10 @@ class ExecTest < Rails::Generators::TestCase ENV.delete('GITHUB_ORGANISATION') end - test 'generator has all_repos option' do - assert generator_class.class_options.key?(:all_repos) - assert_equal :boolean, generator_class.class_options[:all_repos].type - assert_equal false, generator_class.class_options[:all_repos].default + test 'generator has all option' do + assert generator_class.class_options.key?(:all) + assert_equal :boolean, generator_class.class_options[:all].type + assert_equal false, generator_class.class_options[:all].default end test 'generator has topic option' do @@ -41,12 +41,24 @@ class ExecTest < Rails::Generators::TestCase assert_nil generator_class.class_options[:topic].default end + test 'generator has public option' do + assert generator_class.class_options.key?(:public) + assert_equal :boolean, generator_class.class_options[:public].type + assert_equal false, generator_class.class_options[:public].default + end + test 'generator has fix option' do assert generator_class.class_options.key?(:fix) assert_equal :boolean, generator_class.class_options[:fix].type assert_equal false, generator_class.class_options[:fix].default end + test 'generator has name option' do + assert generator_class.class_options.key?(:name) + assert_equal :array, generator_class.class_options[:name].type + assert_nil generator_class.class_options[:name].default + end + test 'prep_audit passes fix option to auditor when false' do # Mock the auditor mock_auditor = mock @@ -77,7 +89,7 @@ class ExecTest < Rails::Generators::TestCase generator.prep_audit end - test 'prep_audit filters repositories when all_repos is false' do + test 'filter_all_if_specified filters repositories when all is false' do # Mock the auditor mock_repo1 = stub(name: 'test_repo', archived?: false) mock_repo2 = stub(name: 'other_repo', archived?: false) @@ -86,13 +98,14 @@ class ExecTest < Rails::Generators::TestCase Auditor.stubs(:new).returns(mock_auditor) - # Run generator with all_repos=false (default) + # Run generator with all=false (default) generator = generator_class.new([], {}, {}) generator.instance_variable_set(:@github_token, 'test_token') generator.instance_variable_set(:@github_organisation, 'test_org') # Stub the github_organisation_remotes method to return test_repo generator.stubs(:github_organisation_remotes).returns(['test_repo']) generator.prep_audit + generator.filter_all_if_specified repositories = generator.instance_variable_get(:@repositories) # Should only include test_repo (from git remotes) @@ -100,7 +113,7 @@ class ExecTest < Rails::Generators::TestCase assert_equal 'test_repo', repositories.first.name end - test 'prep_audit does not filter repositories when all_repos is true' do + test 'filter_all_if_specified does not filter repositories when all is true' do # Mock the auditor mock_repo1 = stub(name: 'test_repo', archived?: false) mock_repo2 = stub(name: 'other_repo', archived?: false) @@ -109,20 +122,21 @@ class ExecTest < Rails::Generators::TestCase Auditor.stubs(:new).returns(mock_auditor) - # Run generator with all_repos=true - generator = generator_class.new([], { all_repos: true }, {}) + # Run generator with all=true + generator = generator_class.new([], { all: true }, {}) generator.instance_variable_set(:@github_token, 'test_token') generator.instance_variable_set(:@github_organisation, 'test_org') generator.prep_audit + generator.filter_all_if_specified repositories = generator.instance_variable_get(:@repositories) # Should include all repos assert_equal 2, repositories.length end - test 'prep_audit filters repositories by topic when topic is specified' do + test 'filter_by_topic_if_specified filters repositories by topic when topic is specified' do # Mock the auditor - mock_repo1 = stub(name: 'test_repo', archived?: false, topics: ['way-of-working', 'ruby']) + mock_repo1 = stub(name: 'test_repo', archived?: false, topics: %w[way-of-working ruby]) mock_repo2 = stub(name: 'other_repo', archived?: false, topics: ['python']) mock_repo3 = stub(name: 'third_repo', archived?: false, topics: ['way-of-working']) mock_auditor = mock @@ -131,10 +145,11 @@ class ExecTest < Rails::Generators::TestCase Auditor.stubs(:new).returns(mock_auditor) # Run generator with topic filter - generator = generator_class.new([], { all_repos: true, topic: 'way-of-working' }, {}) + generator = generator_class.new([], { all: true, topic: 'way-of-working' }, {}) generator.instance_variable_set(:@github_token, 'test_token') generator.instance_variable_set(:@github_organisation, 'test_org') generator.prep_audit + generator.filter_by_topic_if_specified repositories = generator.instance_variable_get(:@repositories) # Should only include repos with 'way-of-working' topic @@ -142,6 +157,155 @@ class ExecTest < Rails::Generators::TestCase assert_includes repositories.map(&:name), 'test_repo' assert_includes repositories.map(&:name), 'third_repo' end + + test 'filter_by_visibility_if_specified filters repositories to only public when public is true' do + # Mock the auditor + mock_repo1 = stub(name: 'public_repo', archived?: false, private?: false) + mock_repo2 = stub(name: 'private_repo', archived?: false, private?: true) + mock_repo3 = stub(name: 'another_public_repo', archived?: false, private?: false) + mock_auditor = mock + mock_auditor.stubs(:repositories).returns([mock_repo1, mock_repo2, mock_repo3]) + + Auditor.stubs(:new).returns(mock_auditor) + + # Run generator with public filter + generator = generator_class.new([], { all: true, public: true }, {}) + generator.instance_variable_set(:@github_token, 'test_token') + generator.instance_variable_set(:@github_organisation, 'test_org') + generator.prep_audit + generator.filter_by_visibility_if_specified + + repositories = generator.instance_variable_get(:@repositories) + # Should only include public repos + assert_equal 2, repositories.length + assert_includes repositories.map(&:name), 'public_repo' + assert_includes repositories.map(&:name), 'another_public_repo' + end + + test 'filter_by_name_array_if_specified filters repositories by name when name is specified' do + # Mock the auditor + mock_repo1 = stub(name: 'structured_store', archived?: false) + mock_repo2 = stub(name: 'other_repo', archived?: false) + mock_repo3 = stub(name: 'another_repo', archived?: false) + mock_auditor = mock + mock_auditor.stubs(:repositories).returns([mock_repo1, mock_repo2, mock_repo3]) + + Auditor.stubs(:new).returns(mock_auditor) + + # Run generator with name filter + generator = generator_class.new([], { all: true, name: ['structured_store'] }, {}) + generator.instance_variable_set(:@github_token, 'test_token') + generator.instance_variable_set(:@github_organisation, 'test_org') + generator.prep_audit + generator.filter_by_name_array_if_specified + + repositories = generator.instance_variable_get(:@repositories) + # Should only include structured_store + assert_equal 1, repositories.length + assert_equal 'structured_store', repositories.first.name + end + + test 'filter_all_if_specified does not filter when name is specified without all' do + # Mock the auditor + mock_repo1 = stub(name: 'structured_store', archived?: false) + mock_repo2 = stub(name: 'other_repo', archived?: false) + mock_repo3 = stub(name: 'test_repo', archived?: false) + mock_auditor = mock + mock_auditor.stubs(:repositories).returns([mock_repo1, mock_repo2, mock_repo3]) + + Auditor.stubs(:new).returns(mock_auditor) + + # Run generator with name filter only (no --all flag) + generator = generator_class.new([], { name: ['structured_store'] }, {}) + generator.instance_variable_set(:@github_token, 'test_token') + generator.instance_variable_set(:@github_organisation, 'test_org') + # Stub the github_organisation_remotes method + generator.stubs(:github_organisation_remotes).returns(['test_repo']) + generator.prep_audit + generator.filter_all_if_specified + + repositories = generator.instance_variable_get(:@repositories) + # Should include all repos because --name implies --all + assert_equal 3, repositories.length + end + + test 'filter_by_name_array_if_specified filters repositories by multiple names when multiple names are specified' do + # Mock the auditor + mock_repo1 = stub(name: 'structured_store', archived?: false) + mock_repo2 = stub(name: 'other_repo', archived?: false) + mock_repo3 = stub(name: 'another_repo', archived?: false) + mock_auditor = mock + mock_auditor.stubs(:repositories).returns([mock_repo1, mock_repo2, mock_repo3]) + + Auditor.stubs(:new).returns(mock_auditor) + + # Run generator with multiple name filters + generator = generator_class.new([], { all: true, name: %w[structured_store another_repo] }, {}) + generator.instance_variable_set(:@github_token, 'test_token') + generator.instance_variable_set(:@github_organisation, 'test_org') + generator.prep_audit + generator.filter_by_name_array_if_specified + + repositories = generator.instance_variable_get(:@repositories) + # Should only include structured_store and another_repo + assert_equal 2, repositories.length + assert_includes repositories.map(&:name), 'structured_store' + assert_includes repositories.map(&:name), 'another_repo' + end + + test 'combines topic and public filters when both are specified' do + # Mock the auditor + mock_repo1 = stub(name: 'public_with_topic', archived?: false, private?: false, topics: ['way-of-working']) + mock_repo2 = stub(name: 'private_with_topic', archived?: false, private?: true, topics: ['way-of-working']) + mock_repo3 = stub(name: 'public_without_topic', archived?: false, private?: false, topics: ['other']) + mock_repo4 = stub(name: 'private_without_topic', archived?: false, private?: true, topics: ['other']) + mock_auditor = mock + mock_auditor.stubs(:repositories).returns([mock_repo1, mock_repo2, mock_repo3, mock_repo4]) + + Auditor.stubs(:new).returns(mock_auditor) + + # Run generator with both topic and public filters + generator = generator_class.new([], { all: true, topic: 'way-of-working', public: true }, {}) + generator.instance_variable_set(:@github_token, 'test_token') + generator.instance_variable_set(:@github_organisation, 'test_org') + generator.prep_audit + generator.filter_by_topic_if_specified + generator.filter_by_visibility_if_specified + + repositories = generator.instance_variable_get(:@repositories) + # Should only include public repos with 'way-of-working' topic + assert_equal 1, repositories.length + assert_equal 'public_with_topic', repositories.first.name + end + + test 'combines name, topic and public filters when all are specified' do + # Mock the auditor + mock_repo1 = stub(name: 'structured_store', archived?: false, private?: false, + topics: ['way-of-working']) + mock_repo2 = stub(name: 'other_store', archived?: false, private?: true, topics: ['way-of-working']) + mock_repo3 = stub(name: 'structured_store', archived?: false, private?: false, topics: ['other']) + mock_repo4 = stub(name: 'public_with_topic', archived?: false, private?: false, + topics: ['way-of-working']) + mock_auditor = mock + mock_auditor.stubs(:repositories).returns([mock_repo1, mock_repo2, mock_repo3, mock_repo4]) + + Auditor.stubs(:new).returns(mock_auditor) + + # Run generator with name, topic and public filters + generator = generator_class.new([], { all: true, name: ['structured_store'], topic: 'way-of-working', + public: true }, {}) + generator.instance_variable_set(:@github_token, 'test_token') + generator.instance_variable_set(:@github_organisation, 'test_org') + generator.prep_audit + generator.filter_by_name_array_if_specified + generator.filter_by_topic_if_specified + generator.filter_by_visibility_if_specified + + repositories = generator.instance_variable_get(:@repositories) + # Should only include public structured_store with 'way-of-working' topic + assert_equal 1, repositories.length + assert_equal 'structured_store', repositories.first.name + end end end end