diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 154e760..eaccb53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,17 +2,20 @@ name: CI on: [push] jobs: - lint: + test: runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4'] steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: - ruby-version: 3.4.1 + ruby-version: ${{ matrix.ruby-version }} - name: Install dependencies run: bundle install - - name: Run linter - run: bundle exec rubocop + - name: Run linter and tests + run: bundle exec rake release-please: runs-on: ubuntu-latest @@ -26,7 +29,7 @@ jobs: release: runs-on: ubuntu-latest - needs: [lint, release-please] + needs: [test, release-please] if: ${{ needs.release-please.outputs.release_created }} steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index fa3b077..64318ca 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ examples .idea Deployfile Gemfile.lock +spec/examples.txt diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..bb69742 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--format documentation +--color \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index debf34e..0156e1f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,14 +1,26 @@ AllCops: TargetRubyVersion: 2.7 NewCops: enable + SuggestExtensions: false Metrics/BlockLength: Exclude: - spec/**/* + - test/**/* Metrics/ModuleLength: Exclude: - spec/**/* + - test/**/* + +Metrics/MethodLength: + Max: 14 + Exclude: + - test/**/* + +Metrics/AbcSize: + Exclude: + - test/**/* Style/StringConcatenation: Enabled: false @@ -20,8 +32,9 @@ Naming/FileName: Exclude: - lib/klogger-logger.rb -Metrics/MethodLength: - Max: 14 +Naming/PredicateMethod: + Exclude: + - lib/deploy/resource.rb Metrics/ClassLength: Max: 120 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1a4b648 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +DeployHQ Ruby API library and CLI client. Provides programmatic access to the DeployHQ deployment platform and a command-line tool for triggering deployments. + +## Development Commands + +### Setup +```bash +bundle install +``` + +### Linting and testing +```bash +# Run all checks (linting + tests) +bundle exec rake + +# Run only linting +bundle exec rubocop + +# Run only tests +bundle exec rspec + +# Run a specific test file +bundle exec rspec spec/configuration_spec.rb + +# Run tests with verbose output +bundle exec rspec --format documentation +``` + +### Building the gem +```bash +gem build deployhq.gemspec +``` + +### Testing CLI locally +```bash +ruby -Ilib bin/deployhq +``` + +## Architecture + +### Core Components + +**Deploy Module** (`lib/deploy.rb`): Main entry point that provides configuration management via `Deploy.configure` and `Deploy.configuration`. Configuration can be loaded from files using `Deploy.configuration_file=`. + +**Resource System**: Base class pattern where `Deploy::Resource` provides ActiveRecord-like interface for API objects: +- `find(:all)` and `find(id)` for retrieval +- `save`, `create`, `update` for persistence +- `destroy` for deletion +- Child resources (Project, Deployment, Server, ServerGroup, DeploymentStep, DeploymentStepLog) inherit this behavior + +**Request Layer** (`lib/deploy/request.rb`): HTTP client using Net::HTTP with basic auth. Handles JSON serialization/deserialization and translates HTTP status codes to appropriate exceptions or boolean success states. + +**CLI** (`lib/deploy/cli.rb`): OptionParser-based command-line interface with three main commands: +- `configure`: Interactive setup wizard for creating Deployfile +- `servers`: Lists servers and server groups +- `deploy`: Interactive deployment workflow with real-time progress via WebSocket + +**Configuration** (`lib/deploy/configuration.rb`): Loads from JSON Deployfile containing: +- `account`: DeployHQ account URL (e.g., https://account.deployhq.com) +- `username`: User email or username +- `api_key`: API key from user profile +- `project`: Default project permalink +- `websocket_hostname`: Optional WebSocket endpoint (defaults to wss://websocket.deployhq.com) + +### Resource Relationships + +Projects contain Servers and ServerGroups. Deployments belong to Projects and have DeploymentSteps. DeploymentSteps have DeploymentStepLogs. All child resources use the `:project` param to construct proper API paths like `projects/:permalink/deployments/:id`. + +### WebSocket Integration + +`Deploy::CLI::WebSocketClient` connects to deployment progress streams. `Deploy::CLI::DeploymentProgressOutput` consumes WebSocket messages and renders deployment progress to terminal in real-time. + +## Release Process + +This project uses [Conventional Commits](https://www.conventionalcommits.org/) for automated releases via [release-please](https://github.com/googleapis/release-please). + +**Commit Message Format**: +- `feat:` or `feature:` - New features +- `fix:` - Bug fixes +- `docs:` - Documentation changes +- `refactor:` - Code refactoring +- `perf:` - Performance improvements +- `chore:` - Maintenance tasks + +On merge to master, release-please analyzes commits and creates a release PR. When merged, it: +1. Updates CHANGELOG.md +2. Bumps version in lib/deploy/version.rb +3. Creates GitHub release +4. Publishes gem to RubyGems + +## Configuration File + +Each developer needs a `Deployfile` in their working directory (not committed). Use `deployhq configure` to generate interactively, or create manually following `Deployfile.example`. \ No newline at end of file diff --git a/Gemfile b/Gemfile index 32b58ed..18fbad0 100644 --- a/Gemfile +++ b/Gemfile @@ -5,4 +5,10 @@ source 'http://rubygems.org' gemspec +gem 'rake', '~> 13.0' gem 'rubocop' + +group :test do + gem 'rspec', '~> 3.13' + gem 'webmock', '~> 3.23' +end diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..5b9f9c6 --- /dev/null +++ b/Rakefile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'rspec/core/rake_task' +require 'rubocop/rake_task' + +RSpec::Core::RakeTask.new(:spec) + +RuboCop::RakeTask.new + +task default: [:rubocop, :spec] diff --git a/deployhq.gemspec b/deployhq.gemspec index ba689e0..5e54a9c 100644 --- a/deployhq.gemspec +++ b/deployhq.gemspec @@ -19,7 +19,7 @@ Gem::Specification.new do |s| s.add_dependency('json', '~> 2.6') s.add_dependency('websocket-eventmachine-client', '~> 1.2') - s.authors = ['Adam Cooke'] - s.email = ['adam@k.io'] - s.homepage = 'https://github.com/krystal/deployhq-lib' + s.authors = ['DeployHQ Team'] + s.email = ['support@deployhq.com'] + s.homepage = 'https://github.com/deployhq/deployhq-lib' end diff --git a/lib/deploy/cli.rb b/lib/deploy/cli.rb index ad5103b..eead0b5 100644 --- a/lib/deploy/cli.rb +++ b/lib/deploy/cli.rb @@ -100,7 +100,7 @@ def invoke(args) def server_list @server_groups ||= @project.server_groups - if @server_groups.count.positive? + if @server_groups.any? @server_groups.each do |group| puts "Group: #{group.name}" puts group.servers.map { |server| format_server(server) }.join("\n\n") @@ -108,9 +108,9 @@ def server_list end @ungrouped_servers ||= @project.servers - return unless @ungrouped_servers.count.positive? + return unless @ungrouped_servers.any? - puts "\n" if @server_groups.count.positive? + puts "\n" if @server_groups.any? puts 'Ungrouped Servers' puts @ungrouped_servers.map { |server| format_server(server) }.join("\n\n") end @@ -197,7 +197,6 @@ def format_server(server) end.join("\n") end - # rubocop:disable Lint/FormatParameterMismatch def format_kv_pair(hash) longest_key = hash.keys.map(&:length).max + 2 hash.each_with_index.map do |(k, v), _i| @@ -205,7 +204,6 @@ def format_kv_pair(hash) str end.join("\n") end - # rubocop:enable Lint/FormatParameterMismatch end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb new file mode 100644 index 0000000..0ad90c7 --- /dev/null +++ b/spec/configuration_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tempfile' + +RSpec.describe Deploy::Configuration do + describe '#websocket_hostname' do + it 'has a default value' do + config = described_class.new + expect(config.websocket_hostname).to eq('wss://websocket.deployhq.com') + end + + it 'allows setting a custom value' do + config = described_class.new + config.websocket_hostname = 'wss://custom.example.com' + expect(config.websocket_hostname).to eq('wss://custom.example.com') + end + end + + describe 'attribute setters and getters' do + it 'sets and retrieves all configuration attributes' do + config = described_class.new + config.account = 'https://test.deployhq.com' + config.username = 'testuser' + config.api_key = 'test-api-key' + config.project = 'test-project' + + expect(config.account).to eq('https://test.deployhq.com') + expect(config.username).to eq('testuser') + expect(config.api_key).to eq('test-api-key') + expect(config.project).to eq('test-project') + end + end + + describe '.from_file' do + context 'with all fields present' do + it 'loads configuration from JSON file' do + config_data = { + 'account' => 'https://test.deployhq.com', + 'username' => 'testuser', + 'api_key' => 'test-key', + 'project' => 'test-project', + 'websocket_hostname' => 'wss://test.example.com' + } + + Tempfile.create(['config', '.json']) do |f| + f.write(JSON.generate(config_data)) + f.flush + f.rewind + + config = described_class.from_file(f.path) + + expect(config.account).to eq('https://test.deployhq.com') + expect(config.username).to eq('testuser') + expect(config.api_key).to eq('test-key') + expect(config.project).to eq('test-project') + expect(config.websocket_hostname).to eq('wss://test.example.com') + end + end + end + + context 'without websocket_hostname' do + it 'uses the default websocket hostname' do + config_data = { + 'account' => 'https://test.deployhq.com', + 'username' => 'testuser', + 'api_key' => 'test-key', + 'project' => 'test-project' + } + + Tempfile.create(['config', '.json']) do |f| + f.write(JSON.generate(config_data)) + f.flush + f.rewind + + config = described_class.from_file(f.path) + + expect(config.account).to eq('https://test.deployhq.com') + expect(config.websocket_hostname).to eq('wss://websocket.deployhq.com') + end + end + end + + context 'with missing file' do + it 'raises Errno::ENOENT' do + expect do + described_class.from_file('/nonexistent/file.json') + end.to raise_error(Errno::ENOENT) + end + end + + context 'with invalid JSON' do + it 'raises JSON::ParserError' do + Tempfile.create(['config', '.json']) do |f| + f.write('invalid json {') + f.flush + f.rewind + + expect do + described_class.from_file(f.path) + end.to raise_error(JSON::ParserError) + end + end + end + end +end diff --git a/spec/request_spec.rb b/spec/request_spec.rb new file mode 100644 index 0000000..1efe24d --- /dev/null +++ b/spec/request_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Deploy::Request do + before do + Deploy.configure do |config| + config.account = 'https://test.deployhq.com' + config.username = 'testuser' + config.api_key = 'test-key' + end + end + + describe 'successful GET request' do + it 'makes a GET request with proper authentication' do + stub_request(:get, 'https://test.deployhq.com/test/path') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return(status: 200, body: '{"status":"ok"}', headers: {}) + + request = described_class.new('test/path', :get) + request.make + + expect(request.success?).to be true + expect(request.output).to eq('{"status":"ok"}') + end + end + + describe 'successful POST request' do + it 'makes a POST request with data' do + stub_request(:post, 'https://test.deployhq.com/test/path') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }, + body: '{"key":"value"}' + ) + .to_return(status: 200, body: '{"created":true}', headers: {}) + + request = described_class.new('test/path', :post) + request.data = { 'key' => 'value' } + request.make + + expect(request.success?).to be true + expect(request.output).to eq('{"created":true}') + end + end + + describe 'error handling' do + context 'when resource is not found' do + it 'raises CommunicationError' do + stub_request(:get, 'https://test.deployhq.com/missing') + .to_return(status: 404, body: 'Not Found', headers: {}) + + request = described_class.new('missing', :get) + + expect do + request.make + end.to raise_error(Deploy::Errors::CommunicationError, /Not Found/) + end + end + + context 'when access is forbidden' do + it 'raises AccessDenied' do + stub_request(:get, 'https://test.deployhq.com/forbidden') + .to_return(status: 403, body: 'Forbidden', headers: {}) + + request = described_class.new('forbidden', :get) + + expect do + request.make + end.to raise_error(Deploy::Errors::AccessDenied, /Access Denied/) + end + end + + context 'when unauthorized' do + it 'raises AccessDenied' do + stub_request(:get, 'https://test.deployhq.com/unauthorized') + .to_return(status: 401, body: 'Unauthorized', headers: {}) + + request = described_class.new('unauthorized', :get) + + expect { request.make }.to raise_error(Deploy::Errors::AccessDenied) + end + end + + context 'when service is unavailable' do + it 'raises ServiceUnavailable' do + stub_request(:get, 'https://test.deployhq.com/unavailable') + .to_return(status: 503, body: 'Service Unavailable', headers: {}) + + request = described_class.new('unavailable', :get) + + expect { request.make }.to raise_error(Deploy::Errors::ServiceUnavailable) + end + end + + context 'with client error' do + it 'returns false and sets output' do + stub_request(:get, 'https://test.deployhq.com/bad-request') + .to_return(status: 400, body: 'Bad Request', headers: {}) + + request = described_class.new('bad-request', :get) + request.make + + expect(request.success?).to be false + expect(request.output).to eq('Bad Request') + end + end + end + + describe 'PUT request' do + it 'makes a PUT request with data' do + stub_request(:put, 'https://test.deployhq.com/test/123') + .with( + basic_auth: %w[testuser test-key], + body: '{"status":"updated"}' + ) + .to_return(status: 200, body: '{"updated":true}', headers: {}) + + request = described_class.new('test/123', :put) + request.data = { 'status' => 'updated' } + request.make + + expect(request.success?).to be true + end + end + + describe 'DELETE request' do + it 'makes a DELETE request' do + stub_request(:delete, 'https://test.deployhq.com/test/123') + .with(basic_auth: %w[testuser test-key]) + .to_return(status: 200, body: '', headers: {}) + + request = described_class.new('test/123', :delete) + request.make + + expect(request.success?).to be true + end + end +end diff --git a/spec/resource_spec.rb b/spec/resource_spec.rb new file mode 100644 index 0000000..e02d0b4 --- /dev/null +++ b/spec/resource_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Deploy::Resource do + before do + Deploy.configure do |config| + config.account = 'https://test.deployhq.com' + config.username = 'testuser' + config.api_key = 'test-key' + end + end + + describe '#method_missing' do + context 'for attribute access' do + it 'allows reading attributes via method calls' do + resource = described_class.new + resource.attributes = { 'name' => 'Test Resource', 'status' => 'active' } + + expect(resource.name).to eq('Test Resource') + expect(resource.status).to eq('active') + end + end + + context 'for attribute setting' do + it 'allows setting attributes via method calls' do + resource = described_class.new + resource.name = 'New Name' + resource.status = 'inactive' + + expect(resource.attributes['name']).to eq('New Name') + expect(resource.attributes['status']).to eq('inactive') + end + end + end + + describe '#new_record?' do + context 'with no id' do + it 'returns true' do + resource = described_class.new + expect(resource.new_record?).to be true + end + end + + context 'with an id' do + it 'returns false' do + resource = described_class.new + resource.id = 123 + expect(resource.new_record?).to be false + end + end + end + + describe '.class_name' do + it 'returns the lowercase class name' do + expect(described_class.class_name).to eq('resource') + end + end + + describe '.collection_path' do + it 'returns the pluralized class name' do + expect(described_class.collection_path).to eq('resources') + end + end + + describe '.member_path' do + it 'returns the path for a specific resource' do + expect(described_class.member_path(123)).to eq('resources/123') + end + end + + describe '.find' do + context 'finding a single resource' do + it 'returns a resource instance with attributes' do + stub_request(:get, 'https://test.deployhq.com/resources/123') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: JSON.generate({ 'id' => 123, 'name' => 'Test Resource' }), + headers: { 'Content-Type' => 'application/json' } + ) + + resource = described_class.find(123) + + expect(resource.id).to eq(123) + expect(resource.name).to eq('Test Resource') + end + end + + context 'finding all resources' do + it 'returns an array of resource instances' do + stub_request(:get, 'https://test.deployhq.com/resources') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: JSON.generate([ + { 'id' => 1, 'name' => 'Resource 1' }, + { 'id' => 2, 'name' => 'Resource 2' } + ]), + headers: { 'Content-Type' => 'application/json' } + ) + + resources = described_class.find(:all) + + expect(resources.length).to eq(2) + expect(resources[0].id).to eq(1) + expect(resources[0].name).to eq('Resource 1') + expect(resources[1].id).to eq(2) + expect(resources[1].name).to eq('Resource 2') + end + end + + context 'finding all resources with pagination' do + it 'extracts records from paginated response' do + stub_request(:get, 'https://test.deployhq.com/resources') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: JSON.generate({ + 'records' => [ + { 'id' => 1, 'name' => 'Resource 1' }, + { 'id' => 2, 'name' => 'Resource 2' } + ], + 'pagination' => { 'page' => 1, 'per_page' => 10 } + }), + headers: { 'Content-Type' => 'application/json' } + ) + + resources = described_class.find(:all) + + expect(resources.length).to eq(2) + expect(resources[0].id).to eq(1) + end + end + end + + describe '#destroy' do + it 'makes a DELETE request and returns true on success' do + resource = described_class.new + resource.id = 123 + + stub_request(:delete, 'https://test.deployhq.com/resources/123') + .with( + basic_auth: %w[testuser test-key], + headers: { 'Accept' => 'application/json', 'Content-Type' => 'application/json' } + ) + .to_return(status: 200, body: '', headers: {}) + + expect(resource.destroy).to be true + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..6c1c5ba --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) + +require 'webmock/rspec' +require 'deploy' + +# Disable external HTTP requests during tests +WebMock.disable_net_connect! + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = 'spec/examples.txt' + config.disable_monkey_patching! + config.warnings = false + + config.default_formatter = 'doc' if config.files_to_run.one? + + config.order = :random + Kernel.srand config.seed +end