-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
390 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
source 'https://rubygems.org' | ||
|
||
gem 'rspec' | ||
gem 'pry' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |