Skip to content

Commit

Permalink
Rewrite the project (Ruby)
Browse files Browse the repository at this point in the history
  • Loading branch information
murjax committed Oct 2, 2023
1 parent 8beaa41 commit a585625
Show file tree
Hide file tree
Showing 6 changed files with 390 additions and 0 deletions.
4 changes: 4 additions & 0 deletions ruby/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
source 'https://rubygems.org'

gem 'rspec'
gem 'pry'
32 changes: 32 additions & 0 deletions ruby/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
GEM
remote: https://rubygems.org/
specs:
coderay (1.1.3)
diff-lcs (1.5.0)
method_source (1.0.0)
pry (0.14.2)
coderay (~> 1.1)
method_source (~> 1.0)
rspec (3.12.0)
rspec-core (~> 3.12.0)
rspec-expectations (~> 3.12.0)
rspec-mocks (~> 3.12.0)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-mocks (3.12.6)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-support (3.12.1)

PLATFORMS
x86_64-linux

DEPENDENCIES
pry
rspec

BUNDLED WITH
2.4.7
15 changes: 15 additions & 0 deletions ruby/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Github-Repo-Cloner (Ruby)

Clone any user's public repositories concurrently.

### Requirements
- Ruby 3.2.1 (or greater)

### Usage
Run binary: `./bin/github_repo_cloner <username>`

### Local Setup
1. Clone main project: `git clone [email protected]:murjax/Github-Repo-Cloner.git`
2. Navigate to ruby project: `cd ruby`
3. Install dependencies: `bundle install`
4. Run tests: `bundle exec rspec`
6 changes: 6 additions & 0 deletions ruby/bin/github_repo_cloner
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env ruby

require_relative '../lib/clone_handler'

username = ARGV.first || CloneHandler.get_username
CloneHandler.new(username).clone_all
136 changes: 136 additions & 0 deletions ruby/lib/clone_handler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
require 'json'
require 'net/http'

class CloneHandler
PER_PAGE = 30.freeze
BASE_URL = 'https://api.github.com'.freeze
NETWORK_ERROR_RESPONSE = { 'status' => '999', 'message' => 'Network error' }.freeze

def self.get_username
Kernel.puts 'Please enter your Github username'
STDOUT.flush
STDIN.gets.chomp
end

def initialize(username)
@username = username
@last_repo_info_request_failed = false
@errors = []
end

def clone_all
validate_clonable
if errors?
print_errors
return false
end

Kernel.system(string_to_clone_all_repos_as_bash_job)
end

private

attr_reader :username, :errors
attr_accessor :last_repo_info_request_failed

def string_to_clone_all_repos_as_bash_job
clone_commands.flatten.join(' & ')
end

def clone_commands
repo_info.map { |info| build_repo_clone_command(info) }
end

def repo_info
@repo_info ||= page_count.times.map do |index|
next if last_repo_info_request_failed

info = repo_info_for_page(index + 1)
check_for_repo_info_request_failure(info)
info
end.flatten.compact
end

def build_repo_clone_command(info)
return if info['clone_url'].nil?

"git clone #{info['clone_url']} #{username}/#{info['name']}"
end

def repo_info_for_page(page)
response = Net::HTTP.get_response(URI("#{user_info_url}/repos?page=#{page}"))
info = JSON.parse(response.body)
info.is_a?(Hash) ? info['status'] = response.code : info
info
rescue SocketError => _e
[NETWORK_ERROR_RESPONSE]
end

def check_for_repo_info_request_failure(info)
return unless info.is_a?(Hash) && info['status'].to_i >= 400

self.last_repo_info_request_failed = true
end

def page_count
(user_info&.dig('public_repos').to_i / PER_PAGE.to_f).ceil
end

def user_info
@user_info ||= begin
response = Net::HTTP.get_response(URI(user_info_url))
user_info = JSON.parse(response.body)
user_info['status'] = response.code
user_info
rescue SocketError => _e
@user_info = NETWORK_ERROR_RESPONSE
end
end

def user_info_url
@user_info_url ||= "#{BASE_URL}/users/#{username}"
end

def errors?
errors.length > 0
end

def validate_clonable
validate_username
validate_user_response
validate_repo_presence
validate_repo_responses
end

def validate_username
return if !username.nil? && username.length > 0

errors.push('No username provided')
end

def validate_user_response
return if errors? || user_info.dig('status').to_i < 400

errors.push(user_info.dig('message'))
end

def validate_repo_presence
return if errors? || user_info.dig('public_repos').to_i > 0

errors.push('No repositories found at this account')
end

def validate_repo_responses
return if errors?

repo_info.each do |info|
next if info['status'].to_i < 400

errors.push(info.dig('message'))
end
end

def print_errors
errors.each { |error| Kernel.puts(error) }
end
end
197 changes: 197 additions & 0 deletions ruby/spec/clone_handler_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
require 'clone_handler'
require 'json'
require 'net/http'

RSpec.describe CloneHandler do
describe '.get_username' do
let(:username) { 'murjax' }
let(:prompt) { 'Please enter your Github username' }

subject(:get_username) { described_class.get_username }

it 'gets input from user and chomps new line' do
expect(Kernel).to receive(:puts).with(prompt)
expect(STDIN).to receive_message_chain(:gets, :chomp).and_return(username)
expect(get_username).to eq(username)
end
end

describe '#clone_all' do
let(:username) { 'murjax' }
let(:pages) { 2 }
let(:name1) { 'spring_engine' }
let(:name2) { 'burger_bot' }
let(:name3) { 'wicked_pdf_capybara' }
let(:base_url) { 'https://api.github.com' }
let(:clone_url1) { "#{base_url}/users/#{username}/#{name1}.git" }
let(:clone_url2) { "#{base_url}/users/#{username}/#{name2}.git" }
let(:clone_url3) { "#{base_url}/users/#{username}/#{name3}.git" }
let(:user_info_uri) { URI("#{base_url}/users/#{username}") }
let(:page1_info_uri) { URI("#{base_url}/users/#{username}/repos?page=1") }
let(:page2_info_uri) { URI("#{base_url}/users/#{username}/repos?page=2") }

subject(:clone) { described_class.new(username).clone_all }

before do
stub_const('CloneHandler::PER_PAGE', 2)
end

context 'valid username with 1 page of repos' do
let(:user_info) { { 'public_repos' => 2 } }
let(:user_info_response) { OpenStruct.new(code: '200', body: JSON.generate(user_info)) }
let(:page1_response) { OpenStruct.new(code: '200', body: JSON.generate(page1)) }
let(:page1) do
[
{ 'name' => name1, 'clone_url' => clone_url1 },
{ 'name' => name2, 'clone_url' => clone_url2 }
]
end
let(:command1) { "git clone #{clone_url1} #{username}/#{name1}" }
let(:command2) { "git clone #{clone_url2} #{username}/#{name2}" }
let(:final_command) { "#{command1} & #{command2}" }

it 'clones the repos' do
expect(Net::HTTP).to receive(:get_response).with(user_info_uri).and_return(user_info_response)
expect(Net::HTTP).to receive(:get_response).with(page1_info_uri).and_return(page1_response)
expect(Kernel).to receive(:system).with(final_command).and_return(true)

expect(clone).to eq(true)
end
end

context 'valid username with 2 pages of repos' do
let(:user_info) { { 'public_repos' => 3 } }
let(:user_info_response) { OpenStruct.new(code: '200', body: JSON.generate(user_info)) }
let(:page1_response) { OpenStruct.new(code: '200', body: JSON.generate(page1)) }
let(:page2_response) { OpenStruct.new(code: '200', body: JSON.generate(page2)) }
let(:page1) do
[
{ 'name' => name1, 'clone_url' => clone_url1 },
{ 'name' => name2, 'clone_url' => clone_url2 }
]
end
let(:page2) do
[
{ 'name' => name3, 'clone_url' => clone_url3 },
]
end
let(:command1) { "git clone #{clone_url1} #{username}/#{name1}" }
let(:command2) { "git clone #{clone_url2} #{username}/#{name2}" }
let(:command3) { "git clone #{clone_url3} #{username}/#{name3}" }
let(:final_command) { "#{command1} & #{command2} & #{command3}" }

it 'clones the repos' do
expect(Net::HTTP).to receive(:get_response).with(user_info_uri).and_return(user_info_response)
expect(Net::HTTP).to receive(:get_response).with(page1_info_uri).and_return(page1_response)
expect(Net::HTTP).to receive(:get_response).with(page2_info_uri).and_return(page2_response)
expect(Kernel).to receive(:system).with(final_command).and_return(true)

expect(clone).to eq(true)
end
end

context 'valid username with no repos' do
let(:user_info) { { 'public_repos' => 0 } }
let(:user_info_response) { OpenStruct.new(code: '200', body: JSON.generate(user_info)) }
let(:error_message) { 'No repositories found at this account' }

it 'does not clone repos' do
expect(Net::HTTP).to receive(:get_response).with(user_info_uri).and_return(user_info_response)
expect(Net::HTTP).not_to receive(:get_response).with(page1_info_uri)
expect(Kernel).not_to receive(:system)
expect(Kernel).to receive(:puts).with(error_message)

expect(clone).to eq(false)
end
end

context 'username not found' do
let(:user_info) { { 'message' => 'Not Found' } }
let(:user_info_response) { OpenStruct.new(code: '404', body: JSON.generate(user_info)) }
let(:error_message) { 'Not Found' }

it 'does not clone repos' do
expect(Net::HTTP).to receive(:get_response).with(user_info_uri).and_return(user_info_response)
expect(Net::HTTP).not_to receive(:get_response).with(page1_info_uri)
expect(Kernel).not_to receive(:system)
expect(Kernel).to receive(:puts).with(error_message)

expect(clone).to eq(false)
end
end

context 'username nil' do
let(:username) { nil }
let(:error_message) { 'No username provided' }

it 'does not clone repos' do
expect(Net::HTTP).not_to receive(:get_response)
expect(Kernel).not_to receive(:system)
expect(Kernel).to receive(:puts).with(error_message)

expect(clone).to eq(false)
end
end

context 'username empty string' do
let(:username) { '' }
let(:error_message) { 'No username provided' }

it 'does not clone repos' do
expect(Net::HTTP).not_to receive(:get_response)
expect(Kernel).not_to receive(:system)
expect(Kernel).to receive(:puts).with(error_message)

expect(clone).to eq(false)
end
end

context 'network error' do
let(:http_error) { 'Failed to open TCP connection' }
let(:error_message) { 'Network error' }

it 'prints error' do
expect(Net::HTTP).to receive(:get_response).and_raise(SocketError)
expect(Kernel).to receive(:puts).with(error_message)
expect(Kernel).not_to receive(:system)

expect(clone).to eq(false)
end
end

context 'API rate limit exceeded at user request' do
let(:api_error) { 'API rate limit exceeded for user' }
let(:rate_limit_response) { { 'message' => api_error } }
let(:error_message) { 'API rate limit exceeded for user' }
let(:user_info_response) { OpenStruct.new(code: '403', body: JSON.generate(rate_limit_response)) }

it 'prints error' do
expect(Net::HTTP).to receive(:get_response).with(user_info_uri).and_return(user_info_response)
expect(Net::HTTP).not_to receive(:get_response).with(page1_info_uri)
expect(Kernel).not_to receive(:system)
expect(Kernel).to receive(:puts).with(error_message)

expect(clone).to eq(false)
end
end

context 'API rate limit exceeded at repo request' do
let(:api_error) { 'API rate limit exceeded for user' }
let(:rate_limit_response) { { 'message' => api_error } }
let(:error_message) { 'API rate limit exceeded for user' }
let(:user_info) { { 'public_repos' => 3 } }
let(:user_info_response) { OpenStruct.new(code: '200', body: JSON.generate(user_info)) }
let(:page1_response) { OpenStruct.new(code: '403', body: JSON.generate(rate_limit_response)) }

it 'prints error' do
expect(Net::HTTP).to receive(:get_response).with(user_info_uri).and_return(user_info_response)
expect(Net::HTTP).to receive(:get_response).with(page1_info_uri).and_return(page1_response)
expect(Net::HTTP).not_to receive(:get_response).with(page2_info_uri)
expect(Kernel).not_to receive(:system)
expect(Kernel).to receive(:puts).with(error_message)

expect(clone).to eq(false)
end
end
end
end

0 comments on commit a585625

Please sign in to comment.