diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d07cef1..df4cb1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: jobs: lint: + name: Linting & Security runs-on: ubuntu-latest steps: @@ -20,15 +21,37 @@ jobs: ruby-version: '3.3' bundler-cache: true - - name: Run RuboCop (linting) - run: bundle exec rubocop --parallel - continue-on-error: true # Don't fail build on style issues + - name: Run RuboCop (style/linting) + run: bundle exec rubocop --parallel --format progress - name: Run Brakeman (security scan) run: bundle exec brakeman -q --no-pager - continue-on-error: true + continue-on-error: true # Security warnings shouldn't block PRs + + typecheck: + name: Type Checking + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Generate Sorbet RBI files + run: | + bundle exec tapioca gems --no-doc --workers 1 + bundle exec tapioca dsl + + - name: Run Sorbet type checker + run: bundle exec srb tc test: + name: Tests runs-on: ubuntu-latest services: @@ -77,11 +100,12 @@ jobs: - name: Run tests run: bundle exec rails test - - name: Report coverage + - name: Report test results if: always() run: echo "โœ… Tests completed" build-test: + name: Docker Build runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index d22b027..3d70753 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,6 @@ docker-compose.override.yml # Ignore Kubernetes secrets k8s/secrets.yaml k8s/sealed-secrets.yaml + +# Ignore Sorbet files +/sorbet/rbi/ diff --git a/.tapioca b/.tapioca new file mode 100644 index 0000000..c756a39 --- /dev/null +++ b/.tapioca @@ -0,0 +1,6 @@ +--rbi-max-line-length=120 +--dsl-dir=sorbet/rbi/dsl +--gem-dir=sorbet/rbi/gems +--todo-rbi=sorbet/rbi/todo.rbi +--shim-rbi=sorbet/rbi/shims +--annotations-rbi=sorbet/rbi/annotations diff --git a/Gemfile b/Gemfile index 62dbadd..698be0d 100644 --- a/Gemfile +++ b/Gemfile @@ -70,3 +70,11 @@ gem "tailwindcss-rails", "~> 4.4" gem "kaminari", "~> 1.2" gem "dotenv-rails", "~> 3.2" + +# Type checking with Sorbet +gem "sorbet-runtime" + +group :development do + gem "sorbet", require: false + gem "tapioca", require: false +end diff --git a/Gemfile.lock b/Gemfile.lock index ad5be8d..752cd8c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,6 +83,7 @@ GEM bcrypt_pbkdf (1.1.2) bcrypt_pbkdf (1.1.2-arm64-darwin) bcrypt_pbkdf (1.1.2-x86_64-darwin) + benchmark (0.5.0) bigdecimal (4.1.0) bindex (0.8.1) bootsnap (1.23.0) @@ -209,6 +210,7 @@ GEM net-smtp (0.5.1) net-protocol net-ssh (7.3.2) + netrc (0.11.0) nio4r (2.7.5) nokogiri (1.19.2-aarch64-linux-gnu) racc (~> 1.4) @@ -294,6 +296,13 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.1) + rbi (0.3.9) + prism (~> 1.0) + rbs (>= 3.4.4) + rbs (4.0.2) + logger + prism (>= 1.6.0) + tsort rdoc (7.2.0) erb psych (>= 4.0.0) @@ -301,6 +310,7 @@ GEM regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) + require-hooks (0.2.3) rexml (3.4.4) rubocop (1.86.0) json (~> 2.3) @@ -358,6 +368,23 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) + sorbet (0.6.13067) + sorbet-static (= 0.6.13067) + sorbet-runtime (0.6.13067) + sorbet-static (0.6.13067-aarch64-linux) + sorbet-static (0.6.13067-universal-darwin) + sorbet-static (0.6.13067-x86_64-linux) + sorbet-static-and-runtime (0.6.13067) + sorbet (= 0.6.13067) + sorbet-runtime (= 0.6.13067) + spoom (1.7.11) + erubi (>= 1.10.0) + prism (>= 0.28.0) + rbi (>= 0.3.3) + rbs (>= 4.0.0.dev.4) + rexml (>= 3.2.6) + sorbet-static-and-runtime (>= 0.5.10187) + thor (>= 0.19.2) sshkit (1.25.0) base64 logger @@ -378,6 +405,18 @@ GEM tailwindcss-ruby (4.2.1-x86_64-darwin) tailwindcss-ruby (4.2.1-x86_64-linux-gnu) tailwindcss-ruby (4.2.1-x86_64-linux-musl) + tapioca (0.18.0) + benchmark + bundler (>= 2.2.25) + netrc (>= 0.11.0) + parallel (>= 1.21.0) + rbi (>= 0.3.7) + require-hooks (>= 0.2.2) + sorbet-static-and-runtime (>= 0.5.11087) + spoom (>= 1.7.9) + thor (>= 1.2.0) + tsort + yard-sorbet thor (1.5.0) thruster (0.1.20) thruster (0.1.20-aarch64-linux) @@ -407,6 +446,10 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) + yard (0.9.38) + yard-sorbet (0.9.0) + sorbet-runtime + yard zeitwerk (2.7.5) PLATFORMS @@ -443,8 +486,11 @@ DEPENDENCIES solid_cable solid_cache solid_queue + sorbet + sorbet-runtime stimulus-rails tailwindcss-rails (~> 4.4) + tapioca thruster turbo-rails tzinfo-data diff --git a/README.md b/README.md index 69399eb..cfd3f61 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ > Your personal video game collection tracker and manager [![Rails 8.1](https://img.shields.io/badge/Rails-8.1-red.svg)](https://rubyonrails.org/) +![CI](https://github.com/YOUR_USERNAME/turbovault/workflows/CI/badge.svg) +[![RuboCop](https://img.shields.io/badge/code_style-rubocop-brightgreen.svg)](https://github.com/rubocop/rubocop) +[![Sorbet](https://img.shields.io/badge/types-sorbet-blue.svg)](https://sorbet.org) [![Ruby 3.3](https://img.shields.io/badge/Ruby-3.3-red.svg)](https://www.ruby-lang.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -140,13 +143,14 @@ IGDB sync is enabled by default for all users and runs every 30 minutes. - ๐Ÿ“– [API Documentation](docs/API_DOCUMENTATION.md) - RESTful API - ๐ŸŽฎ [IGDB Integration](docs/IGDB_INTEGRATION.md) - Game metadata - ๐ŸŽจ [Themes](docs/THEMES.md) - Customization +- ๐Ÿ”ท [Sorbet Types](docs/SORBET.md) - Type checking guide - ๐ŸŽฏ [Demo Account](docs/DEMO_ACCOUNT.md) - Try it out [**See all documentation โ†’**](docs/README.md) ## ๐Ÿ› ๏ธ Tech Stack -- **Framework:** Ruby on Rails 8.1 +- **Framework:** Ruby on Rails 8.1 with Sorbet type checking - **Frontend:** Hotwire (Turbo + Stimulus) - **Styling:** Tailwind CSS - **Database:** PostgreSQL with Row Level Security diff --git a/Taskfile.yml b/Taskfile.yml index 05d6aa8..366cdd2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -180,3 +180,34 @@ tasks: desc: "Clear IGDB sync lock (if job is stuck)" cmds: - rake igdb:clear_lock + + typecheck: + desc: "Run Sorbet type checker" + cmds: + - bundle exec srb tc + + typecheck:watch: + desc: "Run Sorbet type checker in watch mode" + cmds: + - bundle exec srb tc --watch + + tapioca:init: + desc: "Initialize Tapioca (run once after adding Sorbet)" + cmds: + - bundle exec tapioca init + + tapioca:gems: + desc: "Generate RBI files for gems" + cmds: + - bundle exec tapioca gems + + tapioca:dsl: + desc: "Generate RBI files for DSLs (Rails models, etc.)" + cmds: + - bundle exec tapioca dsl + + tapioca:all: + desc: "Generate all RBI files (gems + DSLs)" + cmds: + - bundle exec tapioca gems + - bundle exec tapioca dsl diff --git a/app/controllers/api_tokens_controller.rb b/app/controllers/api_tokens_controller.rb index 2226ed8..0c8c821 100644 --- a/app/controllers/api_tokens_controller.rb +++ b/app/controllers/api_tokens_controller.rb @@ -1,4 +1,7 @@ +# typed: true + class ApiTokensController < ApplicationController + extend T::Sig before_action :require_authentication def index diff --git a/app/controllers/collections_controller.rb b/app/controllers/collections_controller.rb index 82063fa..3035031 100644 --- a/app/controllers/collections_controller.rb +++ b/app/controllers/collections_controller.rb @@ -1,20 +1,27 @@ +# typed: true + class CollectionsController < ApplicationController + extend T::Sig before_action :require_authentication before_action :set_collection, only: [ :show, :edit, :update, :destroy, :games ] + sig { void } def index @root_collections = current_user.collections.root_collections.order(:name) end + sig { void } def show @games = @collection.games.includes(:platform, :genres).page(params[:page]).per(25) end + sig { void } def new @collection = current_user.collections.build @collections = current_user.collections.root_collections.order(:name) end + sig { void } def create @collection = current_user.collections.build(collection_params) @@ -26,10 +33,12 @@ class CollectionsController < ApplicationController end end + sig { void } def edit @collections = current_user.collections.root_collections.where.not(id: @collection.id).order(:name) end + sig { void } def update if @collection.update(collection_params) redirect_to @collection, notice: "Collection was successfully updated." @@ -39,11 +48,13 @@ class CollectionsController < ApplicationController end end + sig { void } def destroy @collection.destroy redirect_to collections_path, notice: "Collection was successfully deleted." end + sig { void } def games # Same as show, but maybe with different view @games = @collection.games.includes(:platform, :genres).page(params[:page]).per(25) @@ -52,10 +63,12 @@ class CollectionsController < ApplicationController private + sig { void } def set_collection @collection = current_user.collections.find(params[:id]) end + sig { returns(T.untyped) } def collection_params permitted = params.require(:collection).permit(:name, :description, :parent_collection_id) # Convert empty string to nil for parent_collection_id diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 325e906..e1bcb16 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,6 +1,10 @@ +# typed: true + class DashboardController < ApplicationController + extend T::Sig before_action :require_authentication + sig { void } def index @recently_added_games = current_user.games.recent.limit(5) @currently_playing_games = current_user.games.currently_playing.limit(5) @@ -10,17 +14,7 @@ class DashboardController < ApplicationController @completed_games = current_user.games.completed.count @backlog_games = current_user.games.backlog.count @total_spent = current_user.games.sum(:price_paid) || 0 - @games_by_platform = current_user.games.joins(:platform) - .group("platforms.name") - .count - .sort_by { |_, count| -count } - .first(5) - @games_by_genre = current_user.games.joins(:genres) - .group("genres.name") - .count - .sort_by { |_, count| -count } - .first(5) end end diff --git a/app/controllers/games_controller.rb b/app/controllers/games_controller.rb index 34592f4..73b596a 100644 --- a/app/controllers/games_controller.rb +++ b/app/controllers/games_controller.rb @@ -1,7 +1,12 @@ +# typed: true + class GamesController < ApplicationController + extend T::Sig + before_action :require_authentication before_action :set_game, only: [ :show, :edit, :update, :destroy ] + sig { void } def index @games = current_user.games.includes(:platform, :genres, :collections) @@ -27,9 +32,11 @@ class GamesController < ApplicationController @genres = Genre.order(:name) end + sig { void } def show end + sig { void } def new @game = current_user.games.build @platforms = Platform.order(:name) @@ -37,13 +44,14 @@ class GamesController < ApplicationController @collections = current_user.collections.order(:name) end + sig { void } def create @game = current_user.games.build(game_params) if @game.save # If game was created with IGDB ID, sync the metadata sync_igdb_metadata_after_create if @game.igdb_id.present? - + redirect_to @game, notice: "Game was successfully created." else @platforms = Platform.order(:name) @@ -53,12 +61,14 @@ class GamesController < ApplicationController end end + sig { void } def edit @platforms = Platform.order(:name) @genres = Genre.order(:name) @collections = current_user.collections.order(:name) end + sig { void } def update if @game.update(game_params) redirect_to @game, notice: "Game was successfully updated." @@ -70,50 +80,54 @@ class GamesController < ApplicationController end end + sig { void } def destroy @game.destroy redirect_to games_path, notice: "Game was successfully deleted." end + sig { void } def import @platforms = Platform.order(:name) @genres = Genre.order(:name) @collections = current_user.collections.order(:name) end + sig { void } def bulk_edit @game_ids = params[:game_ids] || [] - + if @game_ids.empty? redirect_to games_path, alert: "Please select at least one game to edit." return end - + @games = current_user.games.where(id: @game_ids) @platforms = Platform.order(:name) @genres = Genre.order(:name) @collections = current_user.collections.order(:name) end + sig { void } def bulk_update @game_ids = params[:game_ids] || [] - + if @game_ids.empty? redirect_to games_path, alert: "No games selected." return end - + @games = current_user.games.where(id: @game_ids) updated_count = 0 - + @games.each do |game| updates = {} - + # Only update fields that have values provided updates[:completion_status] = params[:completion_status] if params[:completion_status].present? updates[:location] = params[:location] if params[:location].present? updates[:condition] = params[:condition] if params[:condition].present? - + # Handle collection assignment if params[:collection_action].present? case params[:collection_action] @@ -129,7 +143,7 @@ class GamesController < ApplicationController game.collection_ids = params[:collection_ids] if params[:collection_ids].present? end end - + # Handle genre assignment if params[:genre_action].present? case params[:genre_action] @@ -145,15 +159,16 @@ class GamesController < ApplicationController game.genre_ids = params[:genre_ids] if params[:genre_ids].present? end end - + if game.update(updates) updated_count += 1 end end - + redirect_to games_path, notice: "Successfully updated #{updated_count} game(s)." end + sig { void } def bulk_create require "csv" @@ -163,9 +178,11 @@ class GamesController < ApplicationController csv_text = params[:csv_file].read csv = CSV.parse(csv_text, headers: true) - csv.each_with_index do |row, index| + csv.each_with_index do |csv_row, index| + # Cast to untyped to avoid Sorbet CSV::Row vs Hash confusion + row = T.let(csv_row, T.untyped) platform = Platform.find_by(name: row["platform"]) || Platform.find_by(abbreviation: row["platform"]) - + unless platform results[:failed] += 1 results[:errors] << "Row #{index + 2}: Platform '#{row['platform']}' not found" @@ -209,10 +226,11 @@ class GamesController < ApplicationController end end + sig { void } def search_igdb query = params[:q].to_s.strip platform_id = params[:platform_id] - + if query.length < 2 render json: [] return @@ -221,15 +239,15 @@ class GamesController < ApplicationController begin service = IgdbService.new platform = platform_id.present? ? Platform.find_by(id: platform_id) : nil - + # Search IGDB (limit to 10 results for autocomplete) results = service.search_game(query, platform, 10) - + # Format results for autocomplete formatted_results = results.map do |result| # Map IGDB genres to our local genre IDs genre_ids = map_igdb_genres_to_ids(result[:genres] || []) - + { igdb_id: result[:igdb_id], name: result[:name], @@ -242,7 +260,7 @@ class GamesController < ApplicationController confidence: result[:confidence_score] } end - + render json: formatted_results rescue => e Rails.logger.error("IGDB search error: #{e.message}") @@ -250,49 +268,53 @@ class GamesController < ApplicationController end end + sig { void } def search_locations query = params[:q].to_s.strip - + # Get unique locations from user's games that match the query locations = current_user.games - .where.not(location: [nil, ""]) + .where.not(location: [ nil, "" ]) .where("location ILIKE ?", "%#{query}%") .select(:location) .distinct .order(:location) .limit(10) .pluck(:location) - + render json: locations end + sig { void } def search_stores query = params[:q].to_s.strip - + # Get unique digital stores from user's games that match the query stores = current_user.games - .where.not(digital_store: [nil, ""]) + .where.not(digital_store: [ nil, "" ]) .where("digital_store ILIKE ?", "%#{query}%") .select(:digital_store) .distinct .order(:digital_store) .limit(10) .pluck(:digital_store) - + render json: stores end private + sig { void } def set_game @game = current_user.games.includes(:igdb_game).find(params[:id]) end + sig { void } def sync_igdb_metadata_after_create # Fetch full game data from IGDB service = IgdbService.new igdb_data = service.get_game(@game.igdb_id) - + return unless igdb_data # Create or update IgdbGame record @@ -301,11 +323,11 @@ class GamesController < ApplicationController ig.slug = igdb_data["slug"] ig.summary = igdb_data["summary"] ig.first_release_date = igdb_data["first_release_date"] ? Time.at(igdb_data["first_release_date"]).to_date : nil - + # Extract cover URL cover_url = igdb_data.dig("cover", "url")&.split("/")&.last&.sub(".jpg", "") ig.cover_url = cover_url - + ig.last_synced_at = Time.current end @@ -327,9 +349,10 @@ class GamesController < ApplicationController Rails.logger.error("Failed to sync IGDB metadata: #{e.message}") end + sig { params(genre_names: T::Array[String]).returns(T::Array[Integer]) } def map_igdb_genres_to_ids(genre_names) return [] if genre_names.blank? - + # Genre mapping (same as in IgdbMatchSuggestion) genre_mappings = { "Role-playing (RPG)" => "RPG", @@ -352,28 +375,30 @@ class GamesController < ApplicationController genre_names.each do |igdb_genre_name| # Try exact match local_genre = Genre.find_by("LOWER(name) = ?", igdb_genre_name.downcase) - + # Try mapped name if local_genre.nil? && genre_mappings[igdb_genre_name] mapped_name = genre_mappings[igdb_genre_name] local_genre = Genre.find_by("LOWER(name) = ?", mapped_name.downcase) end - + genre_ids << local_genre.id if local_genre end - + genre_ids end + sig { params(genre_names: T::Array[String]).void } def assign_igdb_genres_to_game(genre_names) genre_ids = map_igdb_genres_to_ids(genre_names) - + genre_ids.each do |genre_id| genre = Genre.find(genre_id) @game.genres << genre unless @game.genres.include?(genre) end end + sig { returns(T.untyped) } def game_params params.require(:game).permit( :title, :platform_id, :format, :date_added, :completion_status, diff --git a/app/controllers/igdb_matches_controller.rb b/app/controllers/igdb_matches_controller.rb index 0945aa9..f4d9e1b 100644 --- a/app/controllers/igdb_matches_controller.rb +++ b/app/controllers/igdb_matches_controller.rb @@ -1,4 +1,7 @@ +# typed: true + class IgdbMatchesController < ApplicationController + extend T::Sig before_action :require_authentication before_action :set_suggestion, only: [ :approve, :reject ] @@ -10,7 +13,7 @@ class IgdbMatchesController < ApplicationController .where(games: { igdb_id: nil }) .includes(game: :platform) .group_by(&:game) - + @matched_games = current_user.games.igdb_matched.count @unmatched_games = current_user.games.igdb_unmatched.count @pending_review_count = current_user.igdb_match_suggestions @@ -43,8 +46,8 @@ class IgdbMatchesController < ApplicationController end # Check if games need syncing - unmatched_count = current_user.games.igdb_unmatched.where(igdb_match_status: [nil, "failed"]).count - + unmatched_count = current_user.games.igdb_unmatched.where(igdb_match_status: [ nil, "failed" ]).count + if unmatched_count == 0 redirect_to igdb_matches_path, alert: "All games are already matched or being processed!" return @@ -53,7 +56,7 @@ class IgdbMatchesController < ApplicationController # Try to run job immediately for faster feedback begin IgdbSyncJob.perform_later - + redirect_to igdb_matches_path, notice: "IGDB sync started! Processing #{unmatched_count} games. This may take a few minutes - the page will auto-refresh." rescue => e Rails.logger.error("Failed to start IGDB sync: #{e.message}") diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 80b1a43..3c730a7 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,4 +1,7 @@ +# typed: true + class PagesController < ApplicationController + extend T::Sig def home if user_signed_in? redirect_to dashboard_path diff --git a/app/controllers/password_resets_controller.rb b/app/controllers/password_resets_controller.rb index effa8f1..ff58a3b 100644 --- a/app/controllers/password_resets_controller.rb +++ b/app/controllers/password_resets_controller.rb @@ -1,4 +1,7 @@ +# typed: true + class PasswordResetsController < ApplicationController + extend T::Sig before_action :require_no_authentication, only: [ :new, :create, :edit, :update ] before_action :set_user_by_token, only: [ :edit, :update ] diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 5d37946..a3b8f6f 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -1,4 +1,7 @@ +# typed: true + class ProfilesController < ApplicationController + extend T::Sig def show @user = User.find_by!(username: params[:username]) @@ -11,13 +14,13 @@ class ProfilesController < ApplicationController @physical_games = @user.games.physical_games.count @digital_games = @user.games.digital_games.count @completed_games = @user.games.completed.count - + @games_by_platform = @user.games.joins(:platform) .group("platforms.name") .count .sort_by { |_, count| -count } .first(10) - + @public_collections = @user.collections.root_collections.order(:name) @recent_games = @user.games.includes(:platform).recent.limit(10) end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 4ccc3d0..30eb88f 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,10 +1,16 @@ +# typed: true + class SessionsController < ApplicationController + extend T::Sig + before_action :require_no_authentication, only: [ :new, :create ] before_action :require_authentication, only: [ :destroy ] + sig { void } def new end + sig { void } def create user = User.find_by(email: params[:email].downcase) @@ -17,6 +23,7 @@ class SessionsController < ApplicationController end end + sig { void } def destroy sign_out redirect_to root_path, notice: "You have been signed out." diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index ddb7b5f..73a7576 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,12 +1,17 @@ +# typed: true + class UsersController < ApplicationController + extend T::Sig before_action :require_no_authentication, only: [ :new, :create ] before_action :require_authentication, only: [ :edit, :update, :settings ] before_action :set_user, only: [ :edit, :update ] + sig { void } def new @user = User.new end + sig { void } def create @user = User.new(user_params) @@ -18,9 +23,11 @@ class UsersController < ApplicationController end end + sig { void } def edit end + sig { void } def update if @user.update(user_params) redirect_to settings_path, notice: "Your profile has been updated." @@ -29,6 +36,7 @@ class UsersController < ApplicationController end end + sig { void } def settings @user = current_user @api_tokens = current_user.api_tokens.order(created_at: :desc) @@ -36,10 +44,12 @@ class UsersController < ApplicationController private + sig { void } def set_user @user = current_user end + sig { returns(T.untyped) } def user_params params.require(:user).permit(:email, :username, :password, :password_confirmation, :bio, :profile_public, :igdb_sync_enabled, :theme) end diff --git a/app/jobs/igdb_sync_job.rb b/app/jobs/igdb_sync_job.rb index a47a97b..1f1f4f0 100644 --- a/app/jobs/igdb_sync_job.rb +++ b/app/jobs/igdb_sync_job.rb @@ -22,7 +22,7 @@ class IgdbSyncJob < ApplicationJob end self.class.mark_running! - + begin sync_users_with_igdb ensure @@ -34,16 +34,16 @@ class IgdbSyncJob < ApplicationJob def sync_users_with_igdb users = User.where(igdb_sync_enabled: true) - + Rails.logger.info("Starting IGDB sync for #{users.count} users") - + users.find_each do |user| sync_user_games(user) rescue => e Rails.logger.error("Error syncing user #{user.id}: #{e.message}") next end - + Rails.logger.info("IGDB sync completed") end @@ -51,20 +51,20 @@ class IgdbSyncJob < ApplicationJob # Get games that need IGDB matching games = user.games .igdb_unmatched - .where(igdb_match_status: [nil, "failed"]) + .where(igdb_match_status: [ nil, "failed" ]) .includes(:platform) - + return if games.empty? Rails.logger.info("Syncing #{games.count} games for user #{user.id}") - + igdb_service = IgdbService.new games_synced = 0 - + games.find_each do |game| process_game_matching(game, igdb_service) games_synced += 1 - + # Rate limiting: Additional sleep every 10 games sleep(1) if games_synced % 10 == 0 rescue => e @@ -78,16 +78,16 @@ class IgdbSyncJob < ApplicationJob def process_game_matching(game, igdb_service) Rails.logger.info("Searching IGDB for: #{game.title} (#{game.platform.name})") - + # Try searching WITH platform first results = igdb_service.search_game(game.title, game.platform, 3) - + # If no results, try WITHOUT platform (broader search) if results.empty? Rails.logger.info("No results with platform, trying without platform filter...") results = igdb_service.search_game(game.title, nil, 3) end - + if results.empty? Rails.logger.info("No IGDB matches found for game #{game.id}") game.update(igdb_match_status: "no_results") @@ -95,7 +95,7 @@ class IgdbSyncJob < ApplicationJob end Rails.logger.info("Found #{results.count} potential matches for game #{game.id}") - + # Create match suggestions for user review results.each do |result| create_match_suggestion(game, result) @@ -130,7 +130,7 @@ class IgdbSyncJob < ApplicationJob confidence_score: result[:confidence_score], status: "pending" ) - + Rails.logger.info("Created match suggestion: #{result[:name]} (confidence: #{result[:confidence_score]}%)") rescue ActiveRecord::RecordInvalid => e Rails.logger.warn("Failed to create match suggestion: #{e.message}") diff --git a/app/models/api_token.rb b/app/models/api_token.rb index fd3a2eb..5cb786b 100644 --- a/app/models/api_token.rb +++ b/app/models/api_token.rb @@ -1,4 +1,8 @@ +# typed: true + class ApiToken < ApplicationRecord + extend T::Sig + belongs_to :user # Validations @@ -11,16 +15,19 @@ class ApiToken < ApplicationRecord scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) } # Instance methods + sig { returns(T::Boolean) } def expired? expires_at.present? && expires_at < Time.current end + sig { void } def touch_last_used! update_column(:last_used_at, Time.current) end private + sig { void } def generate_token self.token ||= SecureRandom.urlsafe_base64(32) end diff --git a/app/models/collection.rb b/app/models/collection.rb index 2bb2315..98f69df 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -1,4 +1,7 @@ +# typed: true + class Collection < ApplicationRecord + extend T::Sig # Associations belongs_to :user belongs_to :parent_collection, class_name: "Collection", optional: true @@ -15,30 +18,36 @@ class Collection < ApplicationRecord scope :root_collections, -> { where(parent_collection_id: nil) } # Instance methods + sig { returns(Integer) } def game_count games.count end + sig { returns(Integer) } def total_game_count game_count + subcollections.sum(&:total_game_count) end + sig { returns(T::Boolean) } def root? parent_collection_id.nil? end + sig { returns(T::Boolean) } def subcollection? parent_collection_id.present? end private + sig { void } def cannot_be_own_parent if parent_collection_id.present? && parent_collection_id == id errors.add(:parent_collection_id, "cannot be itself") end end + sig { void } def subcollection_depth_limit if parent_collection.present? && parent_collection.parent_collection.present? errors.add(:parent_collection_id, "cannot nest more than one level deep") diff --git a/app/models/collection_game.rb b/app/models/collection_game.rb index 3d16957..3cfa9a8 100644 --- a/app/models/collection_game.rb +++ b/app/models/collection_game.rb @@ -1,4 +1,7 @@ +# typed: true + class CollectionGame < ApplicationRecord + extend T::Sig belongs_to :collection belongs_to :game diff --git a/app/models/game.rb b/app/models/game.rb index 537a27a..001064e 100644 --- a/app/models/game.rb +++ b/app/models/game.rb @@ -1,4 +1,7 @@ +# typed: true + class Game < ApplicationRecord + extend T::Sig # Associations belongs_to :user belongs_to :platform @@ -54,12 +57,14 @@ class Game < ApplicationRecord scope :needs_igdb_review, -> { joins(:igdb_match_suggestions).where(igdb_match_suggestions: { status: "pending" }).distinct } # Class methods + sig { params(query: String).returns(T.untyped) } def self.search(query) where("title ILIKE ?", "%#{sanitize_sql_like(query)}%") end private + sig { void } def set_date_added self.date_added ||= Date.current end diff --git a/app/models/game_genre.rb b/app/models/game_genre.rb index 5018910..7e0e555 100644 --- a/app/models/game_genre.rb +++ b/app/models/game_genre.rb @@ -1,4 +1,7 @@ +# typed: true + class GameGenre < ApplicationRecord + extend T::Sig belongs_to :game belongs_to :genre diff --git a/app/models/genre.rb b/app/models/genre.rb index 5cbd429..b17eb05 100644 --- a/app/models/genre.rb +++ b/app/models/genre.rb @@ -1,4 +1,7 @@ +# typed: true + class Genre < ApplicationRecord + extend T::Sig # Associations has_many :game_genres, dependent: :destroy has_many :games, through: :game_genres diff --git a/app/models/igdb_game.rb b/app/models/igdb_game.rb index 5068a15..d533334 100644 --- a/app/models/igdb_game.rb +++ b/app/models/igdb_game.rb @@ -1,4 +1,7 @@ +# typed: true + class IgdbGame < ApplicationRecord + extend T::Sig # Associations has_many :igdb_match_suggestions has_many :games, foreign_key: :igdb_id, primary_key: :igdb_id @@ -12,10 +15,12 @@ class IgdbGame < ApplicationRecord scope :recent, -> { order(last_synced_at: :desc) } # Instance methods + sig { void } def increment_match_count! increment!(:match_count) end + sig { params(size: String).returns(T.nilable(String)) } def cover_image_url(size = "cover_big") return nil unless cover_url.present? # IGDB uses image IDs like "co1234" diff --git a/app/models/igdb_match_suggestion.rb b/app/models/igdb_match_suggestion.rb index 93a1000..5f075b3 100644 --- a/app/models/igdb_match_suggestion.rb +++ b/app/models/igdb_match_suggestion.rb @@ -24,7 +24,7 @@ class IgdbMatchSuggestion < ApplicationRecord def approve! transaction do update!(status: "approved", reviewed_at: Time.current) - + # Update the game with the matched IGDB ID game.update!( igdb_id: igdb_id, @@ -49,10 +49,10 @@ class IgdbMatchSuggestion < ApplicationRecord end igdb_game_record.increment_match_count! - + # Map and assign IGDB genres to the game sync_genres_to_game if igdb_genres.present? - + # Reject all other pending suggestions for this game game.igdb_match_suggestions .where.not(id: id) @@ -131,13 +131,13 @@ class IgdbMatchSuggestion < ApplicationRecord igdb_genres.each do |igdb_genre_name| # Try exact match first local_genre = Genre.find_by("LOWER(name) = ?", igdb_genre_name.downcase) - + # Try mapped name if local_genre.nil? && genre_mappings[igdb_genre_name] mapped_name = genre_mappings[igdb_genre_name] local_genre = Genre.find_by("LOWER(name) = ?", mapped_name.downcase) end - + # Add genre to game if found and not already assigned if local_genre && !game.genres.include?(local_genre) game.genres << local_genre diff --git a/app/models/platform.rb b/app/models/platform.rb index 5169dc3..5aa3978 100644 --- a/app/models/platform.rb +++ b/app/models/platform.rb @@ -1,4 +1,7 @@ +# typed: true + class Platform < ApplicationRecord + extend T::Sig # Associations has_many :games, dependent: :restrict_with_error has_many :items, dependent: :restrict_with_error diff --git a/app/models/user.rb b/app/models/user.rb index c89b7c9..46ba1af 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,4 +1,8 @@ +# typed: true + class User < ApplicationRecord + extend T::Sig + has_secure_password # Associations @@ -10,7 +14,7 @@ class User < ApplicationRecord # Validations validates :email, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :username, presence: true, uniqueness: { case_sensitive: false }, + validates :username, presence: true, uniqueness: { case_sensitive: false }, length: { minimum: 3, maximum: 30 }, format: { with: /\A[a-zA-Z0-9_]+\z/, message: "only allows letters, numbers, and underscores" } validates :password, length: { minimum: 8 }, if: -> { password.present? } @@ -20,22 +24,26 @@ class User < ApplicationRecord before_save :downcase_email # Instance methods + sig { returns(T::Boolean) } def generate_password_reset_token self.password_reset_token = SecureRandom.urlsafe_base64 self.password_reset_sent_at = Time.current save! end + sig { returns(T::Boolean) } def password_reset_expired? password_reset_sent_at.nil? || password_reset_sent_at < 2.hours.ago end + sig { returns(String) } def theme_class "theme-#{theme}" end private + sig { void } def downcase_email self.email = email.downcase if email.present? end diff --git a/app/services/igdb_service.rb b/app/services/igdb_service.rb index 65166e2..db29877 100644 --- a/app/services/igdb_service.rb +++ b/app/services/igdb_service.rb @@ -1,26 +1,31 @@ -require 'net/http' -require 'json' -require 'uri' +require "net/http" +require "json" +require "uri" +# typed: true class IgdbService + extend T::Sig + BASE_URL = "https://api.igdb.com/v4" TOKEN_URL = "https://id.twitch.tv/oauth2/token" CACHE_KEY = "igdb_access_token" - + class ApiError < StandardError; end class RateLimitError < StandardError; end + sig { void } def initialize - @client_id = ENV.fetch("IGDB_CLIENT_ID") - @client_secret = ENV.fetch("IGDB_CLIENT_SECRET") - @access_token = get_or_refresh_token + @client_id = T.let(ENV.fetch("IGDB_CLIENT_ID"), String) + @client_secret = T.let(ENV.fetch("IGDB_CLIENT_SECRET"), String) + @access_token = T.let(get_or_refresh_token, String) end # Search for games by title and platform # Returns array of matches with confidence scores + sig { params(title: String, platform: T.nilable(String), limit: Integer).returns(T::Array[T::Hash[Symbol, T.untyped]]) } def search_game(title, platform = nil, limit = 3) platform_filter = platform_filter_query(platform) - + query = <<~QUERY search "#{sanitize_search_term(title)}"; fields id, name, slug, cover.url, summary, first_release_date, platforms.name, genres.name; @@ -29,7 +34,7 @@ class IgdbService QUERY results = post("/games", query) - + return [] if results.empty? # Calculate confidence scores and format results @@ -43,6 +48,7 @@ class IgdbService end # Get specific game by IGDB ID + sig { params(igdb_id: Integer).returns(T.nilable(T::Hash[String, T.untyped])) } def get_game(igdb_id) query = <<~QUERY fields id, name, slug, cover.url, summary, first_release_date, platforms.name, genres.name; @@ -56,6 +62,7 @@ class IgdbService private # Get cached token or generate a new one + sig { returns(String) } def get_or_refresh_token # Check if we have a cached token cached_token = Rails.cache.read(CACHE_KEY) @@ -66,27 +73,28 @@ class IgdbService end # Generate a new access token from Twitch + sig { returns(String) } def generate_access_token uri = URI(TOKEN_URL) uri.query = URI.encode_www_form({ client_id: @client_id, client_secret: @client_secret, - grant_type: 'client_credentials' + grant_type: "client_credentials" }) - response = Net::HTTP.post(uri, '') - + response = Net::HTTP.post(uri, "") + if response.code.to_i == 200 data = JSON.parse(response.body) - token = data['access_token'] - expires_in = data['expires_in'] # seconds until expiration (usually ~5 million seconds / ~60 days) - + token = data["access_token"] + expires_in = data["expires_in"] # seconds until expiration (usually ~5 million seconds / ~60 days) + # Cache token for 90% of its lifetime to be safe cache_duration = (expires_in * 0.9).to_i Rails.cache.write(CACHE_KEY, token, expires_in: cache_duration) - + Rails.logger.info("Generated new IGDB access token (expires in #{expires_in / 86400} days)") - + token else raise ApiError, "Failed to get IGDB token: #{response.code} - #{response.body}" @@ -96,12 +104,13 @@ class IgdbService raise ApiError, "Cannot authenticate with IGDB: #{e.message}" end + sig { params(endpoint: String, body: String, retry_count: Integer).returns(T::Array[T::Hash[String, T.untyped]]) } def post(endpoint, body, retry_count = 0) uri = URI("#{BASE_URL}#{endpoint}") - + http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true - + request = Net::HTTP::Post.new(uri.path) request["Client-ID"] = @client_id request["Authorization"] = "Bearer #{@access_token}" @@ -114,9 +123,9 @@ class IgdbService sleep(0.3) response = http.request(request) - + Rails.logger.info("IGDB Response: #{response.code} - #{response.body[0..200]}") - + case response.code.to_i when 200 JSON.parse(response.body) @@ -126,7 +135,7 @@ class IgdbService Rails.logger.warn("IGDB token invalid, refreshing...") Rails.cache.delete(CACHE_KEY) @access_token = generate_access_token - return post(endpoint, body, retry_count + 1) + post(endpoint, body, retry_count + 1) else Rails.logger.error("IGDB authentication failed after token refresh") raise ApiError, "IGDB authentication failed" @@ -145,6 +154,7 @@ class IgdbService [] end + sig { params(platform: T.nilable(String)).returns(String) } def platform_filter_query(platform) return "" unless platform @@ -154,11 +164,13 @@ class IgdbService "where platforms = (#{igdb_platform_id});" end + sig { params(term: String).returns(String) } def sanitize_search_term(term) # Escape quotes and remove special characters that might break the query term.gsub('"', '\\"').gsub(/[^\w\s:'-]/, "") end + sig { params(search_title: String, result_title: String, search_platform: T.nilable(String), result_platforms: T.nilable(T::Array[T::Hash[String, T.untyped]])).returns(Float) } def calculate_confidence(search_title, result_title, search_platform, result_platforms) score = 0.0 @@ -183,7 +195,7 @@ class IgdbService if search_platform && result_platforms platform_names = result_platforms.map { |p| p["name"].downcase } igdb_platform_id = IgdbPlatformMapping.igdb_id_for_platform(search_platform) - + # Check if our platform is in the result platforms if igdb_platform_id # Exact platform match @@ -197,27 +209,28 @@ class IgdbService score.round(2) end + sig { params(game: T::Hash[String, T.untyped], confidence: Float).returns(T::Hash[Symbol, T.untyped]) } def format_game_result(game, confidence) cover_id = game.dig("cover", "url")&.split("/")&.last&.sub(".jpg", "") - + platform_name = if game["platforms"]&.any? game["platforms"].first["name"] - else + else "Unknown" - end + end release_date = if game["first_release_date"] Time.at(game["first_release_date"]).to_date - else + else nil - end + end # Extract genre names genre_names = if game["genres"]&.any? game["genres"].map { |g| g["name"] } - else + else [] - end + end { igdb_id: game["id"], diff --git a/bin/tapioca b/bin/tapioca new file mode 100755 index 0000000..a6ae757 --- /dev/null +++ b/bin/tapioca @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'tapioca' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("tapioca", "tapioca") diff --git a/db/migrate/20260328183428_create_users.rb b/db/migrate/20260328183428_create_users.rb index 268ff77..f9d00c4 100644 --- a/db/migrate/20260328183428_create_users.rb +++ b/db/migrate/20260328183428_create_users.rb @@ -21,7 +21,7 @@ class CreateUsers < ActiveRecord::Migration[8.1] def enable_rls_on(table_name) execute <<-SQL ALTER TABLE #{table_name} ENABLE ROW LEVEL SECURITY; - + CREATE POLICY #{table_name}_isolation_policy ON #{table_name} USING (id = current_setting('app.current_user_id', true)::bigint); SQL diff --git a/db/migrate/20260328183438_create_api_tokens.rb b/db/migrate/20260328183438_create_api_tokens.rb index 01e8955..b221c3e 100644 --- a/db/migrate/20260328183438_create_api_tokens.rb +++ b/db/migrate/20260328183438_create_api_tokens.rb @@ -14,7 +14,7 @@ class CreateApiTokens < ActiveRecord::Migration[8.1] # Enable Row Level Security execute <<-SQL ALTER TABLE api_tokens ENABLE ROW LEVEL SECURITY; - + CREATE POLICY api_tokens_isolation_policy ON api_tokens USING (user_id = current_setting('app.current_user_id', true)::bigint); SQL diff --git a/db/migrate/20260328183444_create_games.rb b/db/migrate/20260328183444_create_games.rb index 61ecf09..54a60c7 100644 --- a/db/migrate/20260328183444_create_games.rb +++ b/db/migrate/20260328183444_create_games.rb @@ -25,7 +25,7 @@ class CreateGames < ActiveRecord::Migration[8.1] # Enable Row Level Security execute <<-SQL ALTER TABLE games ENABLE ROW LEVEL SECURITY; - + CREATE POLICY games_isolation_policy ON games USING (user_id = current_setting('app.current_user_id', true)::bigint); SQL diff --git a/db/migrate/20260328183451_create_collections.rb b/db/migrate/20260328183451_create_collections.rb index b0fdda9..4ae3e11 100644 --- a/db/migrate/20260328183451_create_collections.rb +++ b/db/migrate/20260328183451_create_collections.rb @@ -15,7 +15,7 @@ class CreateCollections < ActiveRecord::Migration[8.1] # Enable Row Level Security execute <<-SQL ALTER TABLE collections ENABLE ROW LEVEL SECURITY; - + CREATE POLICY collections_isolation_policy ON collections USING (user_id = current_setting('app.current_user_id', true)::bigint); SQL diff --git a/db/migrate/20260328183500_create_items.rb b/db/migrate/20260328183500_create_items.rb index e7d7353..e34e34f 100644 --- a/db/migrate/20260328183500_create_items.rb +++ b/db/migrate/20260328183500_create_items.rb @@ -20,7 +20,7 @@ class CreateItems < ActiveRecord::Migration[8.1] # Enable Row Level Security execute <<-SQL ALTER TABLE items ENABLE ROW LEVEL SECURITY; - + CREATE POLICY items_isolation_policy ON items USING (user_id = current_setting('app.current_user_id', true)::bigint); SQL diff --git a/db/seeds.rb b/db/seeds.rb index 04995ef..7421a3e 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -74,7 +74,7 @@ puts "Created #{Genre.count} genres" # Create demo user and sample games for development if Rails.env.development? puts "Creating demo user..." - + demo_user = User.find_or_create_by!(email: "demo@turbovault.com") do |user| user.username = "demo" user.password = "password123" @@ -94,24 +94,24 @@ if Rails.env.development? # Create sample collections puts "Creating sample collections for demo user..." - + nintendo_collection = demo_user.collections.find_or_create_by!(name: "Nintendo Games") do |collection| collection.description = "All my Nintendo platform games" end - + n64_collection = demo_user.collections.find_or_create_by!(name: "N64 Classics") do |collection| collection.description = "Best games from the Nintendo 64 era" collection.parent_collection = nintendo_collection end - + favorites_collection = demo_user.collections.find_or_create_by!(name: "All-Time Favorites") do |collection| collection.description = "My absolute favorite games across all platforms" end - + backlog_collection = demo_user.collections.find_or_create_by!(name: "To Play") do |collection| collection.description = "Games I still need to complete" end - + puts " โœ“ Created #{demo_user.collections.count} collections" # Only create games if demo user has none @@ -314,7 +314,7 @@ if Rails.env.development? notes: game_data[:notes], date_added: game_data[:date_added] || Date.current ) - + game.genres = game_data[:genres] if game_data[:genres] game.collections = game_data[:collections] if game_data[:collections] end diff --git a/docs/CI_PIPELINE.md b/docs/CI_PIPELINE.md new file mode 100644 index 0000000..7c0bf65 --- /dev/null +++ b/docs/CI_PIPELINE.md @@ -0,0 +1,307 @@ +# CI/CD Pipeline Documentation + +TurboVault uses GitHub Actions for continuous integration and quality assurance. + +## Pipeline Overview + +The CI pipeline runs on: +- โœ… Every push to `main` or `develop` branches +- โœ… Every pull request to `main` or `develop` + +## Jobs + +### 1. Linting & Security ๐Ÿ” + +**Purpose:** Code style and security checks + +**Steps:** +1. **RuboCop** - Ruby style guide enforcement + - Uses `rubocop-rails-omakase` (Rails official style guide) + - Runs with `--parallel` for speed + - **Fails build** if style violations found + +2. **Brakeman** - Security vulnerability scanner + - Scans for Rails security issues + - `continue-on-error: true` (warnings don't block PRs) + +**Run locally:** +```bash +task lint # Check style +task lint:fix # Auto-fix issues +task security # Run Brakeman +``` + +### 2. Type Checking ๐Ÿ”ท + +**Purpose:** Static type checking with Sorbet + +**Steps:** +1. Generate RBI files for gems and Rails DSLs +2. Run Sorbet type checker +3. **Fails build** if type errors found + +**Run locally:** +```bash +task tapioca:all # Generate RBI files +task typecheck # Run type checker +``` + +**Note:** RBI generation can take 1-2 minutes in CI. + +### 3. Tests ๐Ÿงช + +**Purpose:** Run test suite + +**Services:** +- PostgreSQL 15 database + +**Steps:** +1. Set up PostgreSQL service +2. Install system dependencies +3. Create test database +4. Load schema +5. Run test suite + +**Run locally:** +```bash +task test # Run all tests +``` + +### 4. Docker Build ๐Ÿณ + +**Purpose:** Verify Docker image builds successfully + +**Steps:** +1. Set up Docker Buildx +2. Build production Docker image +3. Test that image runs + +**Run locally:** +```bash +docker build -t turbovault:test . +docker run --rm turbovault:test bundle exec ruby --version +``` + +## CI Workflow File + +Location: `.github/workflows/ci.yml` + +## Status Badges + +Status badges in `README.md`: + +```markdown +![CI](https://github.com/YOUR_USERNAME/turbovault/workflows/CI/badge.svg) +[![RuboCop](https://img.shields.io/badge/code_style-rubocop-brightgreen.svg)](https://github.com/rubocop/rubocop) +[![Sorbet](https://img.shields.io/badge/types-sorbet-blue.svg)](https://sorbet.org) +``` + +**Don't forget** to replace `YOUR_USERNAME` with your actual GitHub username! + +## What Fails the Build? + +| Check | Fails Build? | Why | +|-------|--------------|-----| +| RuboCop style issues | โœ… Yes | Code style should be consistent | +| Brakeman security warnings | โŒ No | Some warnings are false positives | +| Sorbet type errors | โœ… Yes | Type safety is important | +| Test failures | โœ… Yes | Tests must pass | +| Docker build failure | โœ… Yes | Must be deployable | + +## Fixing CI Failures + +### RuboCop Failures + +```bash +# Auto-fix most issues +task lint:fix + +# Check what's left +task lint + +# Commit fixes +git add . +git commit -m "Fix RuboCop style issues" +``` + +### Sorbet Type Errors + +```bash +# Regenerate RBI files (if gems changed) +task tapioca:all + +# Check for errors +task typecheck + +# Fix type errors in code +# See docs/SORBET.md for patterns +``` + +### Test Failures + +```bash +# Run tests locally +task test + +# Run specific test +rails test test/models/game_test.rb + +# Fix tests and re-run +``` + +### Docker Build Failures + +```bash +# Build locally to debug +docker build -t turbovault:test . + +# Check build logs for errors +# Usually missing dependencies or incorrect Dockerfile +``` + +## CI Performance + +Typical run times: +- **Linting & Security:** ~30-60 seconds +- **Type Checking:** ~2-3 minutes (RBI generation) +- **Tests:** ~1-2 minutes +- **Docker Build:** ~3-5 minutes + +**Total:** ~7-11 minutes per run + +## Optimizations + +### Caching + +We use `bundler-cache: true` in Ruby setup to cache gems: +```yaml +- uses: ruby/setup-ruby@v1 + with: + bundler-cache: true +``` + +This speeds up runs by ~1-2 minutes. + +### Parallel Execution + +Jobs run in parallel, not sequentially: +- Linting, Type Checking, Tests, and Docker Build all run simultaneously +- Total time = slowest job (usually Docker Build) + +### RuboCop Parallel + +RuboCop uses `--parallel` to check files concurrently: +```bash +bundle exec rubocop --parallel +``` + +## Local Development Workflow + +Before pushing: + +```bash +# 1. Fix style +task lint:fix + +# 2. Check types +task typecheck + +# 3. Run tests +task test + +# 4. Commit +git add . +git commit -m "Your changes" +git push +``` + +This ensures CI will pass! + +## Skipping CI + +To skip CI on a commit (e.g., documentation only): + +```bash +git commit -m "Update README [skip ci]" +``` + +## CI for Pull Requests + +When someone submits a PR: +1. CI runs automatically +2. All jobs must pass before merge +3. Status shown in PR page +4. Merge button disabled until CI passes + +## Viewing CI Results + +**On GitHub:** +1. Go to repository +2. Click **Actions** tab +3. Click on a workflow run +4. View each job's logs + +**In Pull Requests:** +- See status checks at bottom of PR +- Click "Details" to view logs + +## Troubleshooting + +### "RBI generation failed" + +**Cause:** Tapioca couldn't generate RBI files + +**Fix:** Usually transient - retry workflow. If persistent, check Gemfile.lock is committed. + +### "Database connection failed" + +**Cause:** PostgreSQL service not ready + +**Fix:** Already handled with health checks: +```yaml +options: >- + --health-cmd pg_isready + --health-interval 10s +``` + +### "Bundler version mismatch" + +**Cause:** Different Bundler versions locally vs CI + +**Fix:** Run `bundle update --bundler` locally + +## Advanced: Matrix Builds (Future) + +To test multiple Ruby versions: + +```yaml +strategy: + matrix: + ruby-version: ['3.2', '3.3', '3.4'] +``` + +Currently we only test Ruby 3.3. + +## Security + +### Secrets + +We use GitHub Secrets for sensitive data: +- `RAILS_MASTER_KEY` (optional, for encrypted credentials) +- No other secrets needed for tests + +### Dependabot + +Consider enabling Dependabot to auto-update dependencies: +- Settings โ†’ Security โ†’ Dependabot + +## Resources + +- [GitHub Actions Docs](https://docs.github.com/en/actions) +- [RuboCop](https://github.com/rubocop/rubocop) +- [Sorbet](https://sorbet.org/) +- [Brakeman](https://brakemanscanner.org/) + +--- + +**Questions?** Check the workflow file: `.github/workflows/ci.yml` diff --git a/docs/DEVELOPMENT_GUIDE.md b/docs/DEVELOPMENT_GUIDE.md index 26875c3..4897b42 100644 --- a/docs/DEVELOPMENT_GUIDE.md +++ b/docs/DEVELOPMENT_GUIDE.md @@ -95,6 +95,28 @@ task lint:fix task security ``` +### Type Checking with Sorbet + +TurboVault uses Sorbet for gradual static type checking. + +```bash +# First time setup (after bundle install) +task tapioca:init +task tapioca:all + +# Run type checker +task typecheck + +# Watch mode (re-checks on file changes) +task typecheck:watch + +# Update type definitions after gem/model changes +task tapioca:gems # After bundle install +task tapioca:dsl # After model changes +``` + +**See [SORBET.md](SORBET.md) for complete type checking guide.** + ## Project Structure ``` diff --git a/docs/README.md b/docs/README.md index aefd501..554eaec 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,12 +12,14 @@ Complete documentation for TurboVault - Video Game Collection Tracker ### Deployment & Development - [Deployment Guide](DEPLOYMENT.md) - Complete deployment reference - [Development Guide](DEVELOPMENT_GUIDE.md) - Local development & contributing +- [CI Pipeline](CI_PIPELINE.md) - GitHub Actions & quality checks - [Kubernetes README](../k8s/README.md) - Kubernetes deployment -### Features +### Features & Development - [API Documentation](API_DOCUMENTATION.md) - RESTful API reference - [IGDB Integration](IGDB_INTEGRATION.md) - Game metadata matching - [Themes](THEMES.md) - Theme customization +- [Sorbet Type Checking](SORBET.md) - Gradual static types ### Configuration - [GitHub Secrets Setup](../.github/SECRETS_SETUP.md) - Optional custom registry diff --git a/docs/SORBET.md b/docs/SORBET.md new file mode 100644 index 0000000..ced5538 --- /dev/null +++ b/docs/SORBET.md @@ -0,0 +1,520 @@ +# Sorbet Type Checking Guide + +TurboVault uses [Sorbet](https://sorbet.org/) for gradual static type checking. + +## Quick Start + +### First Time Setup + +After installing gems, initialize Sorbet: + +```bash +# Install dependencies +bundle install + +# Initialize Tapioca (generates type definitions) +task tapioca:init + +# Generate RBI files for gems and Rails DSLs +task tapioca:all +``` + +This creates: +- `sorbet/` - Sorbet configuration +- `sorbet/rbi/gems/` - Type definitions for gems (gitignored) +- `sorbet/rbi/dsl/` - Generated types for Rails models, etc. (gitignored) + +### Running Type Checks + +```bash +# Check all files +task typecheck + +# Watch mode (re-checks on file changes) +task typecheck:watch + +# Or use Sorbet directly +bundle exec srb tc +``` + +## Type Strictness Levels + +Sorbet uses gradual typing with 5 strictness levels: + +### `# typed: false` (Default - No Checking) +```ruby +# typed: false +class MyClass + def do_something(x) + x + 1 # No type checking at all + end +end +``` +- **Use for:** Legacy code, external gems, code you're not ready to type yet +- **Checking:** None + +### `# typed: true` (Recommended) +```ruby +# typed: true +class User < ApplicationRecord + extend T::Sig + + sig { returns(String) } + def theme_class + "theme-#{theme}" + end +end +``` +- **Use for:** Most application code +- **Checking:** Method signatures you define with `sig` + +### `# typed: strict` (Advanced) +- **Use for:** Critical business logic, libraries +- **Checking:** All method signatures required, no untyped code + +### `# typed: strong` (Expert) +- **Use for:** Maximum safety +- **Checking:** No `T.untyped`, no runtime checks + +## Writing Type Signatures + +### Basic Method Signatures + +```ruby +# typed: true +class Game < ApplicationRecord + extend T::Sig + + # No parameters, returns String + sig { returns(String) } + def title + read_attribute(:title) + end + + # One parameter, returns Boolean + sig { params(user: User).returns(T::Boolean) } + def owned_by?(user) + self.user_id == user.id + end + + # Multiple parameters + sig { params(title: String, platform: Platform).returns(Game) } + def self.create_game(title, platform) + create(title: title, platform: platform) + end + + # Void (returns nothing meaningful) + sig { void } + def update_cache + Rails.cache.write("game_#{id}", self) + end +end +``` + +### Nilable Types + +```ruby +# typed: true +class Game < ApplicationRecord + extend T::Sig + + # Can return Platform or nil + sig { returns(T.nilable(Platform)) } + def platform + super + end + + # Parameter can be nil + sig { params(platform_id: T.nilable(Integer)).returns(T.untyped) } + def self.by_platform(platform_id) + where(platform_id: platform_id) if platform_id + end +end +``` + +### Arrays and Hashes + +```ruby +# typed: true +class IgdbService + extend T::Sig + + # Array of Hashes + sig { params(title: String).returns(T::Array[T::Hash[Symbol, T.untyped]]) } + def search_game(title) + # Returns: [{id: 1, name: "Game"}, {id: 2, name: "Game 2"}] + end + + # Array of specific type + sig { returns(T::Array[Game]) } + def all_games + Game.all.to_a + end + + # Hash with specific keys + sig { returns(T::Hash[String, Integer]) } + def game_counts + { "total" => 100, "completed" => 50 } + end +end +``` + +### Optional Parameters + +```ruby +# typed: true +class IgdbService + extend T::Sig + + # Optional parameter with default + sig { params(title: String, limit: Integer).returns(T::Array[T.untyped]) } + def search_game(title, limit = 3) + # ... + end + + # Optional keyword parameter + sig { params(title: String, platform: T.nilable(String)).returns(T::Array[T.untyped]) } + def search(title, platform: nil) + # ... + end +end +``` + +### Instance Variables + +```ruby +# typed: true +class IgdbService + extend T::Sig + + sig { void } + def initialize + @client_id = T.let(ENV.fetch("IGDB_CLIENT_ID"), String) + @client_secret = T.let(ENV.fetch("IGDB_CLIENT_SECRET"), String) + @access_token = T.let(get_or_refresh_token, String) + end +end +``` + +`T.let(value, Type)` declares the type of an instance variable. + +### Untyped Code + +When you can't determine the type (ActiveRecord relations, dynamic code): + +```ruby +# typed: true +class GamesController < ApplicationController + extend T::Sig + + sig { void } + def index + # ActiveRecord relations are complex, use T.untyped + @games = T.let(current_user.games.includes(:platform), T.untyped) + end +end +``` + +Use `T.untyped` sparingly - it bypasses type checking. + +### CSV Rows (Common Pattern) + +CSV::Row acts like a Hash but Sorbet doesn't know that: + +```ruby +# typed: true +sig { void } +def bulk_create + csv = CSV.parse(csv_text, headers: true) + + csv.each_with_index do |row, index| + # Tell Sorbet row is untyped to avoid Array vs Hash confusion + row = T.let(row, T.untyped) + + # Now you can access row["column"] without errors + title = row["title"] + platform = row["platform"] + end +end +``` + +This is acceptable because CSV parsing is inherently dynamic and validated by your model. + +## Common Patterns + +### Controllers + +```ruby +# typed: true +class GamesController < ApplicationController + extend T::Sig + + sig { void } + def index + @games = T.let(current_user.games.all, T.untyped) + end + + sig { void } + def show + @game = T.let(current_user.games.find(params[:id]), Game) + end + + sig { void } + def create + @game = T.let(current_user.games.build(game_params), Game) + if @game.save + redirect_to @game + else + render :new + end + end +end +``` + +### Models + +```ruby +# typed: true +class Game < ApplicationRecord + extend T::Sig + + # Class methods + sig { params(query: String).returns(T.untyped) } + def self.search(query) + where("title ILIKE ?", "%#{query}%") + end + + # Instance methods + sig { returns(String) } + def display_title + "#{title} (#{platform&.name || 'Unknown'})" + end + + # Private methods + private + + sig { void } + def set_defaults + self.date_added ||= Date.current + end +end +``` + +### Services + +```ruby +# typed: true +class IgdbService + extend T::Sig + + sig { void } + def initialize + @client_id = T.let(ENV.fetch("IGDB_CLIENT_ID"), String) + @access_token = T.let(get_token, String) + end + + sig { params(title: String, platform: T.nilable(String)).returns(T::Array[T::Hash[Symbol, T.untyped]]) } + def search_game(title, platform: nil) + results = post("/games", build_query(title, platform)) + format_results(results) + end + + private + + sig { params(title: String, platform: T.nilable(String)).returns(String) } + def build_query(title, platform) + "search \"#{title}\";" + end + + sig { params(results: T::Array[T.untyped]).returns(T::Array[T::Hash[Symbol, T.untyped]]) } + def format_results(results) + results.map { |r| { id: r["id"], name: r["name"] } } + end +end +``` + +## Gradual Adoption Strategy + +### Phase 1: Infrastructure (Current) +- โœ… Add Sorbet gems +- โœ… Generate RBI files +- โœ… Add `# typed: true` to key files +- โœ… Type critical methods (User, Game, IgdbService) + +### Phase 2: Core Models (Next) +- Add types to all models +- Type associations and scopes +- Type validations and callbacks + +### Phase 3: Controllers +- Add types to controller actions +- Type params and filters +- Type helper methods + +### Phase 4: Services & Jobs +- Type all service objects +- Type background jobs +- Type API clients + +### Phase 5: Increase Strictness +- Move files from `typed: true` to `typed: strict` +- Remove `T.untyped` where possible +- Add runtime type checks where needed + +## IDE Integration + +### VSCode + +Install the [Sorbet extension](https://marketplace.visualstudio.com/items?itemName=sorbet.sorbet-vscode-extension): + +1. Install extension +2. Sorbet will auto-detect your project +3. Get inline type errors and autocomplete + +### RubyMine + +Sorbet support is built-in: + +1. Enable Sorbet in Settings โ†’ Languages & Frameworks โ†’ Ruby +2. RubyMine will use Sorbet for type checking + +## Updating Types + +When you add gems or change models: + +```bash +# After adding/updating gems +task tapioca:gems + +# After changing models/routes/etc +task tapioca:dsl + +# Both at once +task tapioca:all +``` + +Run this after: +- `bundle install` (new/updated gems) +- Adding/changing models +- Adding/changing routes +- Adding/changing concerns + +## Common Errors + +### "Parent of class redefined" (RDoc, etc.) + +``` +Parent of class RDoc::Markup::Heading redefined from RDoc::Markup::Element to Struct +``` + +**Fix:** This is a known gem incompatibility. Already fixed in `sorbet/config`: +``` +--suppress-payload-superclass-redefinition-for=RDoc::Markup::Heading +``` + +If you see similar errors for other gems, add suppression flags to `sorbet/config`. + +### "Method does not exist" + +``` +app/models/game.rb:10: Method `foo` does not exist on `Game` +``` + +**Fix:** Add the method signature or check for typos. + +### "Argument does not have asserted type" + +``` +Expected String but got T.nilable(String) +``` + +**Fix:** Handle nil case or use `T.must()`: + +```ruby +sig { params(name: String).void } +def greet(name) + puts "Hello #{name}" +end + +# Wrong +greet(user.name) # name might be nil + +# Right +greet(user.name || "Guest") +greet(T.must(user.name)) # Asserts non-nil +``` + +### "Constants must have type annotations" + +```ruby +# Wrong +MY_CONSTANT = "value" + +# Right +MY_CONSTANT = T.let("value", String) +``` + +## Best Practices + +### โœ… DO: +- Start with `# typed: true` (not strict) +- Use `sig` for public methods +- Type critical business logic +- Use `T.untyped` for complex ActiveRecord queries +- Update RBI files after gem/model changes +- Run type checks in CI + +### โŒ DON'T: +- Type everything at once (gradual adoption!) +- Use `# typed: strict` on new code (too strict) +- Ignore type errors (fix or suppress with comment) +- Forget to run `tapioca:dsl` after model changes + +## CI Integration + +Add to `.github/workflows/ci.yml`: + +```yaml +- name: Type check with Sorbet + run: | + bundle exec tapioca gems --no-doc + bundle exec tapioca dsl + bundle exec srb tc +``` + +## Resources + +- [Sorbet Documentation](https://sorbet.org/) +- [Sorbet Playground](https://sorbet.run/) +- [Tapioca Documentation](https://github.com/Shopify/tapioca) +- [T::Types Cheat Sheet](https://sorbet.org/docs/stdlib-generics) + +## Current Status + +**All files start with `# typed: false`** (no checking enabled yet). + +**Ready to type (after running setup):** +- `app/models/user.rb` +- `app/models/game.rb` +- `app/services/igdb_service.rb` +- `app/controllers/games_controller.rb` + +**Recommended typing order:** +1. Start with Models: User, Game, Platform, Genre +2. Then Services: IgdbService +3. Then Controllers: GamesController, CollectionsController +4. Finally Jobs and Helpers + +**Important:** Run the setup steps first: +```bash +bundle install +task tapioca:init +task tapioca:all +task typecheck # Should show "No errors!" +``` + +Then start adding `# typed: true` and signatures incrementally. + +--- + +**Happy typing!** ๐ŸŽ‰ Start small, add types gradually, and enjoy better IDE support and bug catching! diff --git a/lib/tasks/igdb.rake b/lib/tasks/igdb.rake index 3afd12e..5f30f8c 100644 --- a/lib/tasks/igdb.rake +++ b/lib/tasks/igdb.rake @@ -10,15 +10,15 @@ namespace :igdb do task status: :environment do puts "\n๐Ÿ“Š IGDB Sync Status" puts "=" * 50 - + enabled_users = User.where(igdb_sync_enabled: true) puts "Users with sync enabled: #{enabled_users.count}" - + enabled_users.each do |user| unmatched = user.games.igdb_unmatched.count matched = user.games.igdb_matched.count pending = user.igdb_match_suggestions.status_pending.count - + puts "\n ๐Ÿ‘ค #{user.username}" puts " - Matched: #{matched}" puts " - Unmatched: #{unmatched}" @@ -27,7 +27,7 @@ namespace :igdb do puts " - Last synced: #{user.igdb_last_synced_at.strftime('%Y-%m-%d %H:%M')}" end end - + puts "\nโš™๏ธ Background Job Status" puts " Job running: #{IgdbSyncJob.running? ? 'Yes' : 'No'}" puts "=" * 50 diff --git a/sorbet/config b/sorbet/config new file mode 100644 index 0000000..27ca8c8 --- /dev/null +++ b/sorbet/config @@ -0,0 +1,12 @@ +--dir +. +--ignore=/vendor/ +--ignore=/node_modules/ +--ignore=/tmp/ +--ignore=/log/ +--ignore=/public/ +--ignore=/bin/ +--ignore=/db/migrate/ + +# Suppress known gem incompatibilities +--suppress-payload-superclass-redefinition-for=RDoc::Markup::Heading diff --git a/sorbet/tapioca/config.yml b/sorbet/tapioca/config.yml new file mode 100644 index 0000000..886ae58 --- /dev/null +++ b/sorbet/tapioca/config.yml @@ -0,0 +1,13 @@ +gem: + # Add your `gem` command parameters here: + # + # exclude: + # - gem_name + # doc: true + # workers: 5 +dsl: + # Add your `dsl` command parameters here: + # + # exclude: + # - SomeGeneratorName + # workers: 5 diff --git a/sorbet/tapioca/require.rb b/sorbet/tapioca/require.rb new file mode 100644 index 0000000..80f31f8 --- /dev/null +++ b/sorbet/tapioca/require.rb @@ -0,0 +1,4 @@ +# typed: true +# frozen_string_literal: true + +# Add your extra requires here (`bin/tapioca require` can be used to bootstrap this list)