diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..3a753df --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,69 @@ +--- +version: 2.1 +orbs: + samvera: samvera/circleci-orb@1.0 +jobs: + bundle_lint_test: + parameters: + ruby_version: + type: string + bundler_version: + type: string + default: 2.3.14 + executor: + name: 'samvera/ruby' + ruby_version: << parameters.ruby_version >> + environment: + NOKOGIRI_USE_SYSTEM_LIBRARIES: true + steps: + - samvera/cached_checkout + - run: + name: Check for a branch named 'master' + command: | + git fetch --all --quiet --prune --prune-tags + if [[ -n "$(git branch --all --list master */master)" ]]; then + echo "A branch named 'master' was found. Please remove it." + echo "$(git branch --all --list master */master)" + fi + [[ -z "$(git branch --all --list master */master)" ]] + - samvera/bundle: + ruby_version: << parameters.ruby_version >> + bundler_version: << parameters.bundler_version >> + - samvera/rubocop + - run: + name: 'Lint the source code files using RuboCop' + command: 'bundle exec rubocop --config=./.rubocop.yml --parallel' + - samvera/parallel_rspec + +workflows: + version: 2 + ci: + jobs: + - bundle_lint_test: + name: bundle_ruby3-1 + ruby_version: 3.1.2 + - bundle_lint_test: + name: bundle_ruby3-0 + ruby_version: 3.0.4 + - bundle_lint_test: + name: bundle_ruby2-7 + ruby_version: 2.7.6 + + nightly: + triggers: + - schedule: + cron: "0 0 * * *" + filters: + branches: + only: + - main + jobs: + - bundle_lint_test: + name: bundle_ruby3-1 + ruby_version: 3.1.2 + - bundle_lint_test: + name: bundle_ruby3-0 + ruby_version: 3.0.4 + - bundle_lint_test: + name: bundle_ruby2-7 + ruby_version: 2.7.6 diff --git a/.gitignore b/.gitignore index b844b14..9fac69c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ Gemfile.lock +.yardoc +coverage +doc diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..918c2ad --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,40 @@ +inherit_gem: + bixby: bixby_default.yml + +AllCops: + Exclude: + - 'Rakefile' + - 'script/**/*' + - 'vendor/**/*' + +Layout/LineLength: + Exclude: + - 'lib/samvera/git_hub.rb' + - 'lib/samvera/org.rb' + - 'spec/system/cli_spec.rb' + +Metrics/AbcSize: + Exclude: + - 'lib/samvera/gem_query_service.rb' + - 'lib/samvera/org.rb' + +Metrics/BlockLength: + Exclude: + - 'Rakefile' + - 'lib/samvera/gem_query_service.rb' + - 'spec/system/cli_spec.rb' + +Metrics/MethodLength: + Exclude: + - 'lib/samvera/gem_query_service.rb' + - 'lib/samvera/org.rb' + +Style/Documentation: + Exclude: + - 'lib/samvera.rb' + - 'lib/samvera/gem_query_service.rb' + - 'lib/samvera/git_hub.rb' + - 'lib/samvera/org.rb' + - 'lib/samvera/repository_query_service.rb' + - 'lib/samvera/ruby_gems.rb' + diff --git a/CLI.md b/CLI.md new file mode 100644 index 0000000..c44e470 --- /dev/null +++ b/CLI.md @@ -0,0 +1,63 @@ +# Component Maintenance Interest Group +## Command-Line Interface (CLI) Utility + +### Getting Started + +1. Get a personal access token from GitHub (https://github.com/settings/tokens) with the following scopes enabled: + * `public_repo` + * `read:org` + * `user:email` +1. Set an ENV variable named `GITHUB_SAMVERA_TOKEN` containing your token + +The CLI commands may then be listed using the following: + +```bash +$ bundle exec thor list +``` + +...yielding: + +```bash +samvera +------- +thor samvera:org:add_owners # Ensure that all members of the administrator GitHub Team are Gem Owners for RubyGems entries +thor samvera:org:admins # list members of the GitHub administrative Team +thor samvera:org:contributors # list members of the GitHub administrative Team +thor samvera:org:remove_owners # Ensure that all Gem Owners for RubyGems entries which are *not* members of the administrator ... +thor samvera:org:repositories # list Samvera repositories +``` + +### Queries + +One may retrieve the current GitHub administrators using the following: + +```bash +$ bundle exec thor samvera:org:admins +``` + +One may retrieve the current GitHub contributors using the following: + +```bash +$ bundle exec thor samvera:org:contributors +``` + +One may also retrieve the current GitHub repositories managing Ruby Gem projects using the following: + +```bash +$ bundle exec thor samvera:org:repositories +``` + +### Gem Management + +As an administrator, one may grant ownership privileges for RubyGems entries to all GitHub contributors using the following: + +```bash +$ bundle exec thor samvera:org:add_owners +``` + +As an administrator, one may revoke ownership privileges for RubyGems entries to owners who are neither GitHub contributors nor GitHub administrators using the following: + +```bash +$ bundle exec thor samvera:org:remove_owners +``` + diff --git a/Gemfile b/Gemfile index a84e562..a6923c7 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,18 @@ +# frozen_string_literal: true + source 'https://rubygems.org' + gem 'github_api' gem 'huborg' -gem 'rake' \ No newline at end of file +gem 'rake' +gem 'thor' + +group :development do + gem 'bixby' + gem 'pry-byebug' + gem 'rspec' + gem 'rspec_junit_formatter' + gem 'simplecov' + gem 'webmock' + gem 'yard' +end diff --git a/README.md b/README.md index 3b0cc3a..3a3e340 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ This template is something to push to all samvera repositories. The goal in applying a common mailmap is to help understand contributions as people move and change roles/functions/laptops. +## Command-Line Interface (CLI) Utility + +There exists a command-line interface utility which may be used to query for GitHub Team data and in order to perform RubyGems administrative tasks. [Please reference the CLI documentation for an overview of the usage for this](./CLI.md). + ## Ruby Scripts There exist Ruby scripts in this repository that can be used to propagate some of these templates: diff --git a/Rakefile b/Rakefile index 2ea63cd..e2c43e7 100644 --- a/Rakefile +++ b/Rakefile @@ -1,31 +1,33 @@ +# frozen_string_literal: true + namespace :templates do desc "Push CODE_OF_CONDUCT.md to all repositories, this requires ENV['GITHUB_ACCESS_TOKEN']" task :code_of_conduct do require 'huborg' - client = Huborg::Client.new(org_names: ["samvera", "samvera-labs"]) + client = Huborg::Client.new(org_names: %w[samvera samvera-labs]) client.push_template!( - template: File.expand_path("./templates/CODE_OF_CONDUCT.md"), - filename: "CODE_OF_CONDUCT.md", + template: File.expand_path('./templates/CODE_OF_CONDUCT.md'), + filename: 'CODE_OF_CONDUCT.md', overwrite: true ) end desc "Push CONTRIBUTING.md to all repositories, this requires ENV['GITHUB_ACCESS_TOKEN']" task :contributing do require 'huborg' - client = Huborg::Client.new(org_names: ["samvera", "samvera-labs"]) + client = Huborg::Client.new(org_names: %w[samvera samvera-labs]) client.push_template!( - template: File.expand_path("./templates/CONTRIBUTING.md"), - filename: "CONTRIBUTING.md", + template: File.expand_path('./templates/CONTRIBUTING.md'), + filename: 'CONTRIBUTING.md', overwrite: true ) end desc "Push SUPPORT.md to all repositories, this requires ENV['GITHUB_ACCESS_TOKEN']" task :support do require 'huborg' - client = Huborg::Client.new(org_names: ["samvera", "samvera-labs"]) + client = Huborg::Client.new(org_names: %w[samvera samvera-labs]) client.push_template!( - template: File.expand_path("./templates/SUPPORT.md"), - filename: "SUPPORT.md", + template: File.expand_path('./templates/SUPPORT.md'), + filename: 'SUPPORT.md', overwrite: true ) end diff --git a/cli.thor b/cli.thor new file mode 100644 index 0000000..64ec581 --- /dev/null +++ b/cli.thor @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative 'lib/samvera' + +Samvera::Org.new diff --git a/config/github.yaml b/config/github.yaml new file mode 100644 index 0000000..8ac0207 --- /dev/null +++ b/config/github.yaml @@ -0,0 +1,89 @@ +--- +rubygems: + gems: + # [GitHub Repository]: [RubyGems Gem] + active_fedora: active-fedora + fcrepo-admin: fcrepo_admin + questioning_authority: qa +repositories: + invalid: + # samvera + - cla-bot + - cla-bot-old + - hyku + - maintenance + - samvera-circleci-orb + - samvera.github.io + # samvera-labs + - ActiveTriples + - starter-react-component-npm + - stencil-test-components + - sufia.io + - samvera-connect + - samvera-labs.github.io + - samvera-persona + - samvera-virtual-connect + - samvera.org + - samvera_docs + - serverless-iiif + - sessionizer + - uri_selection_wg + - ValkyrieAPI + - repository_utils + - power-steering + - openseadragon-react-viewer + - nurax-pg + - node-iiif + - nectar-iiif + - browse-everything-components + - browse-everything-redux-react + - iiif-image-api + - iiif-react-media-player + - hyku-api + - hyku.github.io + - docker-fcrepo + - fcrepo-charts + - fcrepo3 + - fcrepo3-ldp + - core-dependency-report + - digital_collections_elixir_example + - branch-renaming-wg + - bloom-iiif + - AdvancingHyku + - ansible-sufia7-playbook + - clover-iiif + - houndstooth + - image-downloader + - allinson_flex + - analytics_nurax + - avalon-bundle + - book_concerns + - doi_registrar + - hybox-ideas + - hydra-admin-collections + - hydra-shibboleth + - hydra_controlled_vocabularies + - hydra_documentation_wg + - hydra_file_sets_wg + - hydra_plugins_wg + - hyrax-batch_ingest.. + - hyrax-google_analytics + - hyrax-hirmeos + - hyrax-json_fields + - hyrax-orcid + - hyrax-speed_test + - karmabot + - open_annotation_models + - rdf-proxy_list + - ruby-oai + - samvera-external_storage + - samvera-shibboleth + - sipity + - speedy_af + - storage_proxy_api + - valkyrie-cloud_search + - valkyrie-derivatives + - valkyrie-dynamodb + - valkyrie-redis + - valkyrie_pg_demo + - hyrax-batch_ingest diff --git a/config/rubygems.yaml b/config/rubygems.yaml new file mode 100644 index 0000000..4ff67cf --- /dev/null +++ b/config/rubygems.yaml @@ -0,0 +1,16 @@ +--- +github: + owners: + # [GitHub Account]: [RubyGems Account] + aaron-collier: acollier + billdueber: BillDueber + carolyncole: cam156 + hackartisan: HackmasterA + jrgriffiniii: jrgriffiniii + julesies: geekycoder + no-reply: no_reply +owners: + invalid: + - kelynch + - mlooney + - orangewolf diff --git a/lib/samvera.rb b/lib/samvera.rb new file mode 100644 index 0000000..fc420f6 --- /dev/null +++ b/lib/samvera.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Samvera + def self.relative_file_path + File.dirname(__FILE__) + end + + def self.absolute_file_path + File.absolute_path(relative_file_path) + end + + def self.root_path + Pathname.new(absolute_file_path) + end + + autoload(:GemQueryService, root_path.join('samvera', 'gem_query_service')) + autoload(:RubyGems, root_path.join('samvera', 'ruby_gems')) + autoload(:RepositoryQueryService, root_path.join('samvera', 'repository_query_service')) + autoload(:GitHub, root_path.join('samvera', 'git_hub')) + autoload(:Org, root_path.join('samvera', 'org')) +end diff --git a/lib/samvera/gem_query_service.rb b/lib/samvera/gem_query_service.rb new file mode 100644 index 0000000..addde6e --- /dev/null +++ b/lib/samvera/gem_query_service.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +require 'rubygems/commands/owner_command' +require 'rubygems/commands/search_command' + +module Samvera + class GemQueryService + class ShowOwnersCommand < ::Gem::Commands::OwnerCommand + def show_owners(name) + ::Gem.load_yaml + + sleep(1.1) + response = rubygems_api_request :get, "api/v1/gems/#{name}/owners.yaml" do |request| + request.add_field 'Authorization', api_key + end + + owners = [] + with_response response do |resp| + cleaned = clean_text(resp.body) + owners = ::Gem::SafeYAML.load(cleaned) + end + owners + end + + def execute + @host = options[:host] + + sign_in(scope: nil) + name = get_one_gem_name + show_owners(name) + end + end + + class ManageOwnersCommand < ShowOwnersCommand + def with_response(response, error_prefix = nil) + case response + when Net::HTTPSuccess + clean_text(response.body) + else + message = response.body + message = "#{error_prefix}: #{message}" if error_prefix + cleaned = clean_text(message) + raise(StandardError, cleaned) + end + end + + def manage_owners(method, name, owners) + owners.each do |owner| + sleep(1.1) + response = send_owner_request(method, name, owner) + action = method == :delete ? 'Removing' : 'Adding' + + say("#{action} the Gem owner #{owner} for #{name}...") + with_response response, "Error raised while #{action.downcase} the Gem owner #{owner} for #{name}" + end + end + end + + class AddOwnersCommand < ManageOwnersCommand + def execute + @host = options[:host] + + sign_in(scope: :add_owner) + name = get_one_gem_name + owners = options[:add] + add_owners(name, owners) + end + end + + class RemoveOwnersCommand < ManageOwnersCommand + def execute + @host = options[:host] + + sign_in(scope: :add_owner) + name = get_one_gem_name + owners = options[:remove] + remove_owners(name, owners) + end + end + + class ShowGemsCommand < ::Gem::Commands::SearchCommand + def show_remote_gems(name) + fetcher = ::Gem::SpecFetcher.fetcher + sleep(1.1) + + fetcher.detect(specs_type) do |name_tuple| + # rubocop: disable Style/CaseEquality + name === name_tuple.name + # rubocop: enable Style/CaseEquality + end + end + + # @return [Array] + def execute + gem_names = Array(options[:name]) + + gem_names.map do |n| + values = show_gems(n) + values.flatten + end + end + end + + def self.find_gem_name(repository_name:) + Gem.find_name(repository_name) + end + + def self.find(name:) + search_command = ShowGemsCommand.new + gem_name = find_gem_name(repository_name: name) + name_option = /^#{gem_name}$/ + + search_command.options[:name] = name_option + search_command.options[:domain] = :remote + results = search_command.execute + results.flatten + end + + def self.exists?(name:) + results = find(name: name) + !results.empty? + end + + class Gem + def self.root_path + @root_path ||= begin + relative_file_path = File.dirname(__FILE__) + absolute_file_path = File.absolute_path(relative_file_path) + Pathname.new(absolute_file_path) + end + end + + def self.config_file_path + root_path.join('..', '..', 'config', 'github.yaml') + end + + def self.config + content = File.read(config_file_path) + loaded = YAML.safe_load(content) + loaded.symbolize_keys + end + + def self.rubygems_config + loaded = config[:rubygems] + loaded.symbolize_keys + end + + def self.config_gems + rubygems_config[:gems] + end + + def self.find_name(value) + return value unless config_gems.key?(value) + + config_gems[value] + end + end + + class Owner + def self.root_path + @root_path ||= begin + relative_file_path = File.dirname(__FILE__) + absolute_file_path = File.absolute_path(relative_file_path) + Pathname.new(absolute_file_path) + end + end + + def self.config_file_path + root_path.join('..', '..', 'config', 'rubygems.yaml') + end + + def self.config + content = File.read(config_file_path) + loaded = YAML.safe_load(content) + loaded.symbolize_keys + end + + def self.github_config + loaded = config[:github] + loaded.symbolize_keys + end + + def self.config_owners + github_config[:owners] + end + + def self.find_login(handle:) + return handle unless config_owners.key?(handle) + + config_owners[handle] + end + + def self.owners_config + loaded = config[:owners] + loaded.symbolize_keys + end + + def self.invalid_owners + owners_config[:invalid] + end + + def initialize(id:, handle:) + @id = id + @handle = handle + end + + def login + self.class.find_login(handle: @handle) + end + end + + # Constructor + # @param gem_name + def initialize(gem_name:) + @gem_name = gem_name + end + + def find_owners + owner_command = ShowOwnersCommand.new + owner_command.options[:args] = [@gem_name] + + results = owner_command.execute + results.map { |r| Owner.new(**r.symbolize_keys) } + end + + def add(owners:) + add_command = AddOwnersCommand.new + add_command.options[:args] = [@gem_name] + owner_logins = owners.map(&:login) + add_command.options[:add] = owner_logins + + add_command.execute + end + + def remove(owners:) + add_command = RemoveOwnersCommand.new + add_command.options[:args] = [@gem_name] + owner_logins = owners.map(&:login) + add_command.options[:add] = [] + add_command.options[:remove] = owner_logins + + add_command.execute + end + end +end diff --git a/lib/samvera/git_hub.rb b/lib/samvera/git_hub.rb new file mode 100644 index 0000000..aca4000 --- /dev/null +++ b/lib/samvera/git_hub.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'github_api' + +module Samvera + class GitHub + class << self + def authorization_token + @authorization_token = ENV.fetch('GITHUB_SAMVERA_TOKEN', nil) || raise( + ArgumentError, + 'GitHub authorization token was not found in the GITHUB_SAMVERA_TOKEN environment variable' + ) + end + + def orgs + @orgs = github.orgs + end + + def teams + @teams = orgs.teams + end + + def users + @users = github.users(auto_pagination: true) + end + + def samvera_repositories + @samvera_repositories = begin + response = github.repos(user: 'samvera') + resolved = response.all.to_a + resolved.reject { |r| RepositoryQueryService.invalid_repositories.include?(r.name) } + end + end + + def labs_repositories + @labs_repositories = begin + response = github.repos(user: 'samvera-labs') + resolved = response.all.to_a + resolved.reject { |r| RepositoryQueryService.invalid_repositories.include?(r.name) } + end + end + + def repositories + @repositories = samvera_repositories + labs_repositories + end + + def samvera_team + @samvera_team = teams.list(org: 'samvera', auto_pagination: true) + end + + def admin_team + @admin_team = samvera_team.find { |team| team.name == 'admins' } + end + + def admin_response + @admin_response = teams.list_members(admin_team.id) + end + + def samvera_admins + @samvera_admins ||= begin + admin_owners = admin_response.map do |u| + resolved = users.get(user: u.login) + GemQueryService::Owner.new(id: nil, handle: resolved.login) + end + + admin_owners.reject do |o| + samvera_contributors.map(&:login).include?(o.login) || GemQueryService::Owner.invalid_owners.include?(o.login) + end + end + end + + def contrib_team + @contrib_team = samvera_team.find { |team| team.name == 'contributors' } + end + + def contrib_response + @contrib_response = teams.list_members(contrib_team.id) + end + + def samvera_contributors + @samvera_contributors ||= begin + contrib_owners = contrib_response.map do |u| + resolved = users.get(user: u.login) + GemQueryService::Owner.new(id: nil, handle: resolved.login) + end + contrib_owners.reject do |o| + GemQueryService::Owner.invalid_owners.include?(o.login) + end + end + end + + def github + # @github ||= ::Github.new(oauth_token: authorization_token, auto_pagination: true) + @github = ::Github.new(oauth_token: authorization_token, auto_pagination: true) + end + end + end +end diff --git a/lib/samvera/org.rb b/lib/samvera/org.rb new file mode 100644 index 0000000..2b82d14 --- /dev/null +++ b/lib/samvera/org.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'thor' + +module Samvera + class Org < Thor + desc('admins', 'list members of the GitHub administrative Team') + def admins + GitHub.samvera_admins.each do |u| + say("#{u.login}\n", :green) + end + end + + desc('contributors', 'list members of the GitHub administrative Team') + def contributors + GitHub.samvera_contributors.each do |u| + say("#{u.login}\n", :green) + end + end + + desc('repositories', 'list Samvera repositories') + def repositories + GitHub.repositories.each do |repo| + say("#{repo.name}: #{repo.html_url}", :green) + end + end + + desc('add_owners', 'Ensure that all members of the administrator GitHub Team are Gem Owners for RubyGems entries') + def add_owners + GitHub.repositories.each do |repo| + if RubyGems.gem_exists?(name: repo.name) + current_owners = RubyGems.show_gem_owners(name: repo.name) + owners = GitHub.samvera_admins.reject { |o| current_owners.map(&:login).include?(o.login) } + + say("Adding the Gem owners for #{repo.name}...", :green) + begin + RubyGems.add_gem_owners(name: repo.name, owners: owners) + rescue StandardError => e + raise(Thor::Error, e.message) + end + else + say("Could not find the Gem for #{repo.name}...", :red) + end + end + end + + desc('remove_owners', + 'Ensure that all Gem Owners for RubyGems entries which are *not* members of the administrator GitHub Team are removed') + def remove_owners + GitHub.repositories.each do |repo| + if RubyGems.gem_exists?(name: repo.name) + current_owners = RubyGems.show_gem_owners(name: repo.name) + owners = GitHub.samvera_admins.reject { |o| current_owners.map(&:login).include?(o.login) } + + say("Removing the Gem owners for #{repo.name}...", :green) + begin + RubyGems.remove_gem_owners(name: repo.name, owners: owners) + rescue StandardError => e + raise(Thor::Error, e.message) + end + else + say("Could not find the Gem for #{repo.name}...", :red) + end + end + end + end +end diff --git a/lib/samvera/repository_query_service.rb b/lib/samvera/repository_query_service.rb new file mode 100644 index 0000000..e750d29 --- /dev/null +++ b/lib/samvera/repository_query_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Samvera + class RepositoryQueryService + def self.root_path + @root_path ||= begin + relative_file_path = File.dirname(__FILE__) + absolute_file_path = File.absolute_path(relative_file_path) + Pathname.new(absolute_file_path) + end + end + + def self.config_file_path + root_path.join('..', '..', 'config', 'github.yaml') + end + + def self.config + content = File.read(config_file_path) + loaded = YAML.safe_load(content) + loaded.symbolize_keys + end + + def self.repository_config + loaded = config[:repositories] + loaded.symbolize_keys + end + + def self.invalid_repositories + repository_config[:invalid] + end + end +end diff --git a/lib/samvera/ruby_gems.rb b/lib/samvera/ruby_gems.rb new file mode 100644 index 0000000..54cea65 --- /dev/null +++ b/lib/samvera/ruby_gems.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'github_api' +require 'rubygems/commands/owner_command' +require 'rubygems/commands/search_command' + +module Samvera + class RubyGems + class << self + def show_gem_owners(name:) + gem_name = GemQueryService.find_gem_name(repository_name: name) + query_service = GemQueryService.new(gem_name: gem_name) + + query_service.find_owners + end + + def add_gem_owners(name:, owners:) + gem_name = GemQueryService.find_gem_name(repository_name: name) + query_service = GemQueryService.new(gem_name: gem_name) + + query_service.add(owners: owners) + end + + def remove_gem_owners(name:, owners:) + gem_name = GemQueryService.find_gem_name(repository_name: name) + query_service = GemQueryService.new(gem_name: gem_name) + + query_service.remove(owners: owners) + end + + def gem_exists?(name:) + GemQueryService.exists?(name: name) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..f46eabb --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'pry-byebug' +require 'simplecov' +require 'webmock/rspec' + +require_relative '../lib/samvera' + +SimpleCov.start do + add_filter 'spec/' +end + +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + + # The settings below are suggested to provide a good initial experience + # with RSpec, but feel free to customize to your heart's content. + # # This allows you to limit a spec run to individual examples or groups + # # you care about by tagging them with `:focus` metadata. When nothing + # # is tagged with `:focus`, all examples get run. RSpec also provides + # # aliases for `it`, `describe`, and `context` that include `:focus` + # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + # config.filter_run_when_matching :focus + # + # # Allows RSpec to persist some state between runs in order to support + # # the `--only-failures` and `--next-failure` CLI options. We recommend + # # you configure your source control system to ignore this file. + # config.example_status_persistence_file_path = "spec/examples.txt" + # + # # Limits the available syntax to the non-monkey patched syntax that is + # # recommended. For more details, see: + # # https://relishapp.com/rspec/rspec-core/docs/configuration/zero-monkey-patching-mode + # config.disable_monkey_patching! + # + # # This setting enables warnings. It's recommended, but in some cases may + # # be too noisy due to issues in dependencies. + # config.warnings = true + # + # # Many RSpec users commonly either run the entire suite or an individual + # # file, and it's useful to allow more verbose output when running an + # # individual spec file. + # if config.files_to_run.one? + # # Use the documentation formatter for detailed output, + # # unless a formatter has already been configured + # # (e.g. via a command-line flag). + # config.default_formatter = "doc" + # end + # + # # Print the 10 slowest examples and example groups at the + # # end of the spec run, to help surface which specs are running + # # particularly slow. + # config.profile_examples = 10 + # + # # Run specs in random order to surface order dependencies. If you find an + # # order dependency and want to debug it, you can fix the order by providing + # # the seed, which is printed after each run. + # # --seed 1234 + # config.order = :random + # + # # Seed global randomization in this process using the `--seed` CLI option. + # # Setting this allows you to use `--seed` to deterministically reproduce + # # test failures related to randomization by passing the same `--seed` value + # # as the one that triggered the failure. + # Kernel.srand config.seed +end diff --git a/spec/system/cli_spec.rb b/spec/system/cli_spec.rb new file mode 100644 index 0000000..fc54fa6 --- /dev/null +++ b/spec/system/cli_spec.rb @@ -0,0 +1,400 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'github_api' + +describe Samvera::Org, type: :system do + subject(:cli) { Samvera::Org.new } + + # GitHub Repository responses + let(:github_repo_object1) do + double + end + let(:github_repo_object2) do + double + end + let(:github_repo_objects) do + [ + github_repo_object1, + github_repo_object2 + ] + end + + # GitHub User responses + let(:github_admin1) do + double + end + let(:github_admin2) do + double + end + let(:github_user1) do + double + end + let(:github_user2) do + double + end + + # Team Responses + let(:github_team_object1) do + double + end + let(:github_team_object2) do + double + end + let(:github_team_objects) do + [ + github_team_object1, + github_team_object2 + ] + end + let(:github_user_objects) do + [ + github_admin1, + github_admin2, + github_user1, + github_user2 + ] + end + + # GitHub API Responses + let(:github_admin_team_response) do + instance_double(::Github::ResponseWrapper) + end + let(:github_repos_response) do + instance_double(::Github::ResponseWrapper) + end + let(:github_users_response) do + instance_double(::Github::ResponseWrapper) + end + + # GitHub API Client + let(:github_client) do + instance_double(::Github::Client) + end + # GitHub Organizations + let(:github_orgs) do + instance_double(::Github::Client::Orgs) + end + # GitHub Teams for a given Organization + let(:github_orgs_teams) do + instance_double(::Github::Client::Orgs::Teams) + end + # GitHub Repositories for samvera + let(:github_samvera_repos) do + instance_double(::Github::Client::Repos) + end + # GitHub Repositories for samvera-labs + let(:github_samvera_labs_repos) do + instance_double(::Github::Client::Repos) + end + # GitHub Users + let(:github_users) do + instance_double(::Github::Client::Users) + end + + # RubyGems API client and responses + let(:fetcher) do + double + end + let(:spec_tuple1) do + double + end + let(:spec_tuple2) do + double + end + let(:spec_tuples) do + [ + spec_tuple1, + spec_tuple2 + ] + end + let(:repo1_owners_yaml) do + [ + { + id: '10001', + handle: 'user1' + }, + { + id: '10003', + handle: 'user3' + } + ].to_yaml + end + let(:repo2_owners_yaml) do + [ + { + id: '10002', + handle: 'user2' + }, + { + id: '10004', + handle: 'jrgriffiniii' + } + ].to_yaml + end + + before(:all) do + ENV["GEM_HOST_API_KEY"] = 'secret' + ENV['GITHUB_SAMVERA_TOKEN'] = 'secret' + end + + before do + allow(::Github).to receive(:new).and_return(github_client) + allow(github_client).to receive(:orgs).and_return(github_orgs) + allow(github_client).to receive(:repos).with(user: 'samvera').and_return(github_samvera_repos) + allow(github_client).to receive(:repos).with(user: 'samvera-labs').and_return(github_samvera_labs_repos) + allow(github_client).to receive(:users).and_return(github_users) + allow(github_orgs).to receive(:teams).and_return(github_orgs_teams) + + stub_request(:get, 'https://rubygems.org/api/v1/gems/active-fedora/owners.yaml').to_return( + status: 200, + body: repo1_owners_yaml, + headers: { + 'Content-Type': 'application/json' + } + ) + stub_request(:get, 'https://rubygems.org/api/v1/gems/repository-2/owners.yaml').to_return( + status: 200, + body: repo2_owners_yaml, + headers: { + 'Content-Type': 'application/json' + } + ) + + allow(spec_tuple2).to receive(:name).and_return('repository-2') + allow(spec_tuple1).to receive(:name).and_return('active_fedora') + allow(fetcher).to receive(:detect).and_yield(spec_tuple1).and_yield(spec_tuple2).and_return(spec_tuples) + allow(::Gem::SpecFetcher).to receive(:fetcher).and_return(fetcher) + + allow(github_repo_object1).to receive(:name).and_return('active_fedora') + allow(github_repo_object1).to receive(:html_url).and_return('https://github.com/samvera/active_fedora') + allow(github_repo_object2).to receive(:name).and_return('repository-2') + allow(github_repo_object2).to receive(:html_url).and_return('https://github.com/samvera/repository-2') + allow(github_repos_response).to receive(:to_a).and_return(github_repo_objects) + + allow(github_samvera_repos).to receive(:all).and_return(github_repos_response) + allow(github_samvera_labs_repos).to receive(:all).and_return([]) + + allow(github_team_object1).to receive(:id).and_return('contributors') + allow(github_team_object1).to receive(:login).and_return('contributors') + allow(github_team_object1).to receive(:name).and_return('contributors') + + allow(github_team_object2).to receive(:id).and_return('admin') + allow(github_team_object2).to receive(:login).and_return('admin') + allow(github_team_object2).to receive(:name).and_return('admins') + + allow(github_admin1).to receive(:login).and_return('admin1') + allow(github_users).to receive(:get).with(user: 'admin1').and_return(github_admin1) + + allow(github_admin2).to receive(:login).and_return('admin2') + allow(github_users).to receive(:get).with(user: 'admin2').and_return(github_admin2) + + allow(github_user1).to receive(:login).and_return('user1') + allow(github_users).to receive(:get).with(user: 'user1').and_return(github_user1) + + allow(github_user2).to receive(:login).and_return('user2') + allow(github_users).to receive(:get).with(user: 'user2').and_return(github_user2) + + allow(github_orgs_teams).to receive(:list_members).with('admin').and_return([github_admin1, github_admin2]) + allow(github_orgs_teams).to receive(:list_members).with('contributors').and_return([github_user1, github_user2]) + allow(github_orgs_teams).to receive(:list).and_return(github_team_objects) + + stub_request(:post, 'https://rubygems.org/api/v1/gems/active-fedora/owners') + stub_request(:post, 'https://rubygems.org/api/v1/gems/repository-2/owners') + + stub_request(:delete, 'https://rubygems.org/api/v1/gems/active-fedora/owners') + stub_request(:delete, 'https://rubygems.org/api/v1/gems/repository-2/owners') + + cli + end + + after(:all) do + ENV["GEM_HOST_API_KEY"] = nil + ENV['GITHUB_SAMVERA_TOKEN'] = nil + end + + describe '#admins' do + it 'lists all of the GitHub users who have administrative privileges for the Samvera Community' do + expect { cli.admins }.to output(/admin1/).to_stdout + expect { cli.admins }.to output(/admin2/).to_stdout + end + end + + describe '#contributors' do + it 'lists all of the GitHub users who have contributor privileges for the Samvera Community' do + expect { cli.contributors }.to output(/user1/).to_stdout + expect { cli.contributors }.to output(/user2/).to_stdout + end + end + + describe '#repositories' do + it 'lists all of the GitHub repositories managed by the Samvera Community' do + expect { cli.repositories }.to output(/active_fedora/).to_stdout + expect { cli.repositories }.to output(/repository-2/).to_stdout + end + end + + describe '#add_owners' do + it 'adds all of the necessary GitHub users as RubyGems owners to each Gem' do + cli.add_owners + + expect(a_request(:post, 'https://rubygems.org/api/v1/gems/active-fedora/owners')).to have_been_made.times(2) + expect(a_request(:post, 'https://rubygems.org/api/v1/gems/repository-2/owners')).to have_been_made.times(2) + end + + context 'when the Samvera GitHub repository does not have a corresponding RubyGem' do + let(:github_repo_object3) do + double + end + let(:github_repo_objects) do + [ + github_repo_object3, + github_repo_object1, + github_repo_object2 + ] + end + let(:spec_tuples) do + [] + end + + before do + allow(github_repo_object3).to receive(:name).and_return('repository-3') + allow(github_repo_object3).to receive(:html_url).and_return('https://github.com/samvera/repository-3') + + stub_request(:get, 'https://rubygems.org/api/v1/gems/repository-3/owners.yaml').to_return( + status: 404, + body: 'This rubygem could not be found.' + ) + end + + it 'prints a warning' do + expect { cli.add_owners }.to output(/Could not find the Gem for repository-3/).to_stdout + expect(a_request(:post, 'https://rubygems.org/api/v1/gems/repository-3/owners')).not_to have_been_made.once + end + end + + context 'when an error is encountered when requesting to modify the RubyGem Owners' do + let(:github_repo_object3) do + double + end + let(:github_repo_objects) do + [ + github_repo_object1, + github_repo_object2, + github_repo_object3 + ] + end + let(:spec_tuple3) do + double + end + let(:spec_tuples) do + [ + spec_tuple1, + spec_tuple2, + spec_tuple3 + ] + end + + before do + allow(github_repo_object3).to receive(:name).and_return('repository-3') + allow(github_repo_object3).to receive(:html_url).and_return('https://github.com/samvera/repository-3') + + allow(spec_tuple3).to receive(:name).and_return('repository-3') + stub_request(:get, 'https://rubygems.org/api/v1/gems/repository-3/owners.yaml').to_return( + status: 200, + body: repo1_owners_yaml, + headers: { + 'Content-Type': 'application/json' + } + ) + stub_request(:post, 'https://rubygems.org/api/v1/gems/repository-3/owners').to_return(status: 500, body: 'error') + end + + it 'raises a Thor::Error' do + expect { cli.add_owners }.to raise_error(Thor::Error, /Error raised while adding the Gem owner admin1 for repository-3/) + end + end + end + + describe '#remove_owners' do + it 'removes all of the necessary GitHub users as RubyGems owners to each Gem' do + cli.remove_owners + + expect(a_request(:delete, 'https://rubygems.org/api/v1/gems/active-fedora/owners')).to have_been_made.times(2) + expect(a_request(:delete, 'https://rubygems.org/api/v1/gems/repository-2/owners')).to have_been_made.times(2) + end + + context 'when the Samvera GitHub repository does not have a corresponding RubyGem' do + let(:github_repo_object3) do + double + end + let(:github_repo_objects) do + [ + github_repo_object3, + github_repo_object1, + github_repo_object2 + ] + end + let(:spec_tuples) do + [] + end + + before do + allow(github_repo_object3).to receive(:name).and_return('repository-3') + allow(github_repo_object3).to receive(:html_url).and_return('https://github.com/samvera/repository-3') + + stub_request(:get, 'https://rubygems.org/api/v1/gems/repository-3/owners.yaml').to_return( + status: 404, + body: 'This rubygem could not be found.' + ) + end + + it 'prints a warning' do + expect { cli.remove_owners }.to output(/Could not find the Gem for repository-3/).to_stdout + expect(a_request(:delete, 'https://rubygems.org/api/v1/gems/repository-3/owners')).not_to have_been_made.once + end + end + + context 'when an error is encountered when requesting to modify the RubyGem Owners' do + let(:github_repo_object3) do + double + end + let(:github_repo_objects) do + [ + github_repo_object1, + github_repo_object2, + github_repo_object3 + ] + end + let(:spec_tuple3) do + double + end + let(:spec_tuples) do + [ + spec_tuple1, + spec_tuple2, + spec_tuple3 + ] + end + + before do + allow(github_repo_object3).to receive(:name).and_return('repository-3') + allow(github_repo_object3).to receive(:html_url).and_return('https://github.com/samvera/repository-3') + + allow(spec_tuple3).to receive(:name).and_return('repository-3') + stub_request(:get, 'https://rubygems.org/api/v1/gems/repository-3/owners.yaml').to_return( + status: 200, + body: repo1_owners_yaml, + headers: { + 'Content-Type': 'application/json' + } + ) + stub_request(:delete, 'https://rubygems.org/api/v1/gems/repository-3/owners').to_return(status: 500, body: 'error') + end + + it 'raises a Thor::Error' do + expect { cli.remove_owners }.to raise_error(Thor::Error, /Error raised while removing the Gem owner admin1 for repository-3/) + end + end + end +end