├── .env.test ├── .foreman ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── tests.yml ├── .gitignore ├── .rspec ├── .ruby-version ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── Procfile ├── Procfile.dev ├── README.md ├── Rakefile ├── app.json ├── bin └── hanami ├── config.ru ├── config ├── app.rb ├── assets.js ├── providers │ ├── persistence.rb │ └── sentry.rb ├── puma.rb ├── routes.rb ├── schedule.yml ├── settings.rb ├── sidekiq.rb ├── sidekiq.yml └── slices │ ├── processing.rb │ └── web.rb ├── db └── migrate │ ├── 20221209194551_create_users.rb │ ├── 20221212121815_create_gems.rb │ ├── 20221213145821_add_admin.rb │ ├── 20221215121048_create_issues.rb │ ├── 20230107215950_remove_users.rb │ ├── 20230107220211_add_created_at_to_issues.rb │ ├── 20230111180103_add_pushed_to_gems.rb │ ├── 20230111185237_remove_uniq_constraint_on_issues.rb │ ├── 20230115105235_add_repos.rb │ ├── 20230305222521_add_additional_gem_info.rb │ ├── 20230319213848_restore_users.rb │ ├── 20230321204915_create_favorites.rb │ ├── 20230324013057_add_unique_constrain_on_favorites.rb │ ├── 20231001162627_add_delete_cascade_to_issues.rb │ ├── 20241009122616_switch_download_count_to_bigint.rb │ └── 20241009182306_switch_github_id_to_bigint.rb ├── lib ├── shiny_gems │ ├── persistance │ │ └── relations │ │ │ ├── favorites.rb │ │ │ ├── gems.rb │ │ │ ├── issues.rb │ │ │ ├── repos.rb │ │ │ └── users.rb │ ├── service.rb │ └── types.rb └── tasks │ ├── .keep │ └── db │ └── setup.rake ├── package.json ├── public └── .keep ├── slices ├── processing │ ├── config │ │ └── providers │ │ │ ├── gems_api.rb │ │ │ └── octokit.rb │ ├── repositories │ │ ├── gems_repository.rb │ │ ├── issues_repository.rb │ │ └── repos_repository.rb │ ├── services │ │ ├── discover.rb │ │ ├── github │ │ │ ├── issues_list_fetcher.rb │ │ │ └── repo_fetcher.rb │ │ ├── issues_syncer.rb │ │ ├── repo_syncer.rb │ │ ├── rubygems │ │ │ ├── gem_fetcher.rb │ │ │ └── list_fetcher.rb │ │ └── syncer.rb │ ├── worker.rb │ └── workers │ │ ├── discover_worker.rb │ │ ├── sync_all_worker.rb │ │ ├── sync_issues_worker.rb │ │ ├── sync_repo_worker.rb │ │ └── sync_worker.rb └── web │ ├── action.rb │ ├── actions │ ├── favorites │ │ ├── create.rb │ │ ├── delete.rb │ │ └── index.rb │ ├── gemfile │ │ └── create.rb │ ├── gems │ │ ├── index.rb │ │ ├── show.rb │ │ └── show_old.rb │ ├── pages │ │ ├── index.rb │ │ ├── not_found.rb │ │ └── privacy.rb │ └── session │ │ ├── create.rb │ │ ├── destroy.rb │ │ └── failure.rb │ ├── assets │ ├── css │ │ └── app.scss │ ├── images │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── favicon.ico │ │ ├── logo.svg │ │ └── og.png │ └── js │ │ └── app.js │ ├── lib │ ├── errors_mapper.rb │ └── formatter.rb │ ├── repositories │ ├── favorites_repository.rb │ ├── gems_repository.rb │ └── users_repository.rb │ ├── services │ └── gemfile_parser.rb │ ├── templates │ ├── favorites │ │ └── index.html.erb │ ├── gemfile │ │ └── create.html.erb │ ├── gems │ │ ├── _gems_list.html.erb │ │ ├── index.html.erb │ │ └── show.html.erb │ ├── layouts │ │ ├── _flash.html.erb │ │ ├── _footer.html.erb │ │ ├── _navbar.html.erb │ │ └── application.html.erb │ └── pages │ │ ├── error.html.erb │ │ ├── index.html.erb │ │ ├── not_authorized.html.erb │ │ └── privacy.html.erb │ ├── view.rb │ └── views │ ├── favorites │ └── index.rb │ ├── gemfile │ └── create.rb │ ├── gems │ ├── index.rb │ └── show.rb │ ├── pages │ ├── error.rb │ ├── index.rb │ ├── not_authorized.rb │ └── privacy.rb │ └── parts │ ├── gem.rb │ ├── issue.rb │ ├── label.rb │ └── repo.rb ├── spec ├── features │ ├── auth_spec.rb │ ├── browse_spec.rb │ ├── favorites_spec.rb │ ├── gemfile_upload_spec.rb │ └── processing_spec.rb ├── slices │ ├── processing │ │ ├── repositories │ │ │ ├── gems_repository_spec.rb │ │ │ └── repos_repository_spec.rb │ │ ├── services │ │ │ ├── discover_spec.rb │ │ │ ├── github │ │ │ │ ├── issues_list_fetcher_spec.rb │ │ │ │ └── repo_fetcher_spec.rb │ │ │ ├── issues_syncer_spec.rb │ │ │ ├── repo_syncer_spec.rb │ │ │ ├── rubygems │ │ │ │ ├── gem_fetcher_spec.rb │ │ │ │ └── list_fetcher_spec.rb │ │ │ └── syncer_spec.rb │ │ └── workers │ │ │ ├── discover_worker_spec.rb │ │ │ ├── sync_all_worker_spec.rb │ │ │ ├── sync_issues_worker_spec.rb │ │ │ ├── sync_repo_worker_spec.rb │ │ │ └── sync_worker_spec.rb │ └── web │ │ ├── actions │ │ ├── favorites │ │ │ ├── create_spec.rb │ │ │ ├── delete_spec.rb │ │ │ └── index_spec.rb │ │ ├── gemfile │ │ │ └── create_spec.rb │ │ ├── gems │ │ │ ├── index_spec.rb │ │ │ ├── show_old_spec.rb │ │ │ └── show_spec.rb │ │ ├── pages │ │ │ └── index_spec.rb │ │ └── session │ │ │ ├── create_spec.rb │ │ │ ├── destroy_spec.rb │ │ │ └── failure_spec.rb │ │ ├── lib │ │ ├── errors_mapper_spec.rb │ │ └── formatter_spec.rb │ │ ├── repositories │ │ ├── favorites_repository_spec.rb │ │ ├── gems_repository_spec.rb │ │ └── users_repository_spec.rb │ │ ├── services │ │ └── gemfile_parser_spec.rb │ │ └── views │ │ ├── gems │ │ └── index_spec.rb │ │ ├── pages │ │ └── error_spec.rb │ │ └── parts │ │ ├── gem_spec.rb │ │ ├── label_spec.rb │ │ └── repo_spec.rb ├── spec_helper.rb └── support │ ├── auth.rb │ ├── capybara.rb │ ├── cov.rb │ ├── csrf_fix.rb │ ├── database_cleaner.rb │ ├── factories │ ├── favorite.rb │ ├── gem.rb │ ├── issue.rb │ ├── repo.rb │ └── user.rb │ ├── factory.rb │ ├── fake_repositories.rb │ ├── files │ └── Gemfile.test │ ├── matchers.rb │ ├── omniauth_mock.rb │ ├── rspec.rb │ ├── sidekiq.rb │ └── webmock.rb ├── web.sh ├── worker.sh └── yarn.lock /.env.test: -------------------------------------------------------------------------------- 1 | GITHUB_KEY=xxx 2 | GITHUB_SECRET=xxx 3 | SESSION_SECRET=3a26cab87e8f3bb75b193d0d9b241a42f655f042a773648a8db0f2b9a5f91fe27fc3d7e3ce0e23c22f19ba5b4c7fb70d23a3cd66fc8411e785ae14ce9d88b87e 4 | DATABASE_URL=postgres://localhost:5432/shiny_gems_test 5 | REDIS_URL=redis://localhost:6379/12 6 | HOST=xx 7 | SENTRY_URL=xx 8 | SIDEKIQ_WEB_USER=xx 9 | SIDEKIQ_WEB_PASS=xx 10 | -------------------------------------------------------------------------------- /.foreman: -------------------------------------------------------------------------------- 1 | procfile: Procfile.dev 2 | env: .env 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [kukicola] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "bundler" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | concurrency: 7 | group: ci-${{ github.ref }} 8 | cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} 9 | jobs: 10 | tests: 11 | name: 'Run specs' 12 | runs-on: ubuntu-latest 13 | services: 14 | postgres: 15 | image: postgres:13-alpine 16 | env: 17 | POSTGRES_USER: postgres 18 | POSTGRES_PASSWORD: postgres 19 | POSTGRES_DB: postgres 20 | ports: 21 | - 5432:5432 22 | options: >- 23 | --mount type=tmpfs,destination=/var/lib/postgresql/data 24 | --health-cmd pg_isready 25 | --health-interval 1s 26 | --health-timeout 1s 27 | --health-retries 10 28 | env: 29 | HANAMI_ENV: test 30 | DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres 31 | steps: 32 | - uses: actions/checkout@v3 33 | - uses: ruby/setup-ruby@v1 34 | with: 35 | ruby-version: '3.3.6' 36 | bundler-cache: true 37 | - uses: actions/setup-node@v3 38 | with: 39 | node-version: '16.18.0' 40 | - run: npm install yarn -g 41 | - name: Yarn cache 42 | uses: actions/cache@v3 43 | id: yarn-cache 44 | with: 45 | path: node_modules 46 | key: yarn-${{ hashFiles('./yarn.lock') }} 47 | restore-keys: | 48 | yarn- 49 | - run: yarn install --frozen-lockfile 50 | - run: bundle exec rake db:migrate 51 | - run: bundle exec hanami assets compile 52 | - run: bundle exec rspec 53 | - run: bundle exec standardrb 54 | 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .sass-cache 2 | .bundle 3 | /log 4 | /node_modules 5 | .env 6 | coverage 7 | yarn-error.log 8 | public/assets 9 | public/assets.json 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.3.6 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.3.6 2 | ENV LANG=en_US.UTF-8 3 | 4 | RUN apt-get update -qq && \ 5 | apt-get install -y nodejs npm postgresql-client libjemalloc2 && \ 6 | apt-get clean && \ 7 | rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man && \ 8 | npm install -g yarn 9 | 10 | WORKDIR /app 11 | COPY Gemfile Gemfile.lock package.json yarn.lock ./ 12 | RUN bundle install 13 | RUN yarn install 14 | 15 | COPY . ./ 16 | RUN bundle exec hanami assets compile 17 | 18 | ENV LD_PRELOAD="libjemalloc.so.2" 19 | 20 | EXPOSE 2300 21 | CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"] 22 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | ruby "3.3.6" 6 | 7 | gem "hanami", "~> 2.2" 8 | gem "hanami-router", "~> 2.2" 9 | gem "hanami-controller", "~> 2.2" 10 | gem "hanami-validations", "~> 2.2" 11 | gem "hanami-view", "~> 2.2" 12 | gem "hanami-assets", "~> 2.2" 13 | gem "rom", "~> 5.3" 14 | gem "rom-sql", "~> 3.6" 15 | gem "pg" 16 | gem "octokit" 17 | gem "dry-monads" 18 | gem "gems" 19 | gem "dry-types", "~> 1.7" 20 | gem "puma" 21 | gem "rake" 22 | gem "sidekiq" 23 | gem "faraday-retry" 24 | gem "sentry-ruby" 25 | gem "sentry-sidekiq" 26 | gem "sidekiq-cron" 27 | gem "sequel_pg" 28 | gem "omniauth" 29 | gem "omniauth-github" 30 | 31 | group :development, :test do 32 | gem "dotenv" 33 | gem "standard" 34 | end 35 | 36 | group :cli, :development do 37 | gem "hanami-reloader" 38 | end 39 | 40 | group :cli, :development, :test do 41 | gem "hanami-rspec" 42 | end 43 | 44 | group :test do 45 | gem "rack-test" 46 | gem "database_cleaner-sequel" 47 | gem "rom-factory" 48 | gem "capybara" 49 | gem "rspec-sidekiq" 50 | gem "simplecov" 51 | gem "webmock" 52 | gem "cuprite" 53 | end 54 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (8.0.0) 5 | base64 6 | benchmark (>= 0.3) 7 | bigdecimal 8 | concurrent-ruby (~> 1.0, >= 1.3.1) 9 | connection_pool (>= 2.2.5) 10 | drb 11 | i18n (>= 1.6, < 2) 12 | logger (>= 1.4.2) 13 | minitest (>= 5.1) 14 | securerandom (>= 0.3) 15 | tzinfo (~> 2.0, >= 2.0.5) 16 | uri (>= 0.13.1) 17 | addressable (2.8.7) 18 | public_suffix (>= 2.0.2, < 7.0) 19 | ast (2.4.2) 20 | base64 (0.2.0) 21 | benchmark (0.4.0) 22 | bigdecimal (3.1.8) 23 | capybara (3.40.0) 24 | addressable 25 | matrix 26 | mini_mime (>= 0.1.3) 27 | nokogiri (~> 1.11) 28 | rack (>= 1.6.0) 29 | rack-test (>= 0.6.3) 30 | regexp_parser (>= 1.5, < 3.0) 31 | xpath (~> 3.2) 32 | coderay (1.1.3) 33 | concurrent-ruby (1.3.4) 34 | connection_pool (2.4.1) 35 | crack (1.0.0) 36 | bigdecimal 37 | rexml 38 | cronex (0.15.0) 39 | tzinfo 40 | unicode (>= 0.4.4.5) 41 | cuprite (0.15.1) 42 | capybara (~> 3.0) 43 | ferrum (~> 0.15.0) 44 | database_cleaner-core (2.0.1) 45 | database_cleaner-sequel (2.0.2) 46 | database_cleaner-core (~> 2.0.0) 47 | sequel 48 | diff-lcs (1.5.1) 49 | docile (1.4.1) 50 | dotenv (3.1.4) 51 | drb (2.2.1) 52 | dry-auto_inject (1.0.1) 53 | dry-core (~> 1.0) 54 | zeitwerk (~> 2.6) 55 | dry-cli (1.2.0) 56 | dry-configurable (1.2.0) 57 | dry-core (~> 1.0, < 2) 58 | zeitwerk (~> 2.6) 59 | dry-core (1.0.2) 60 | concurrent-ruby (~> 1.0) 61 | logger 62 | zeitwerk (~> 2.6) 63 | dry-events (1.0.1) 64 | concurrent-ruby (~> 1.0) 65 | dry-core (~> 1.0, < 2) 66 | dry-files (1.1.0) 67 | dry-inflector (1.1.0) 68 | dry-initializer (3.1.1) 69 | dry-logger (1.0.4) 70 | dry-logic (1.5.0) 71 | concurrent-ruby (~> 1.0) 72 | dry-core (~> 1.0, < 2) 73 | zeitwerk (~> 2.6) 74 | dry-monads (1.6.0) 75 | concurrent-ruby (~> 1.0) 76 | dry-core (~> 1.0, < 2) 77 | zeitwerk (~> 2.6) 78 | dry-monitor (1.0.1) 79 | dry-configurable (~> 1.0, < 2) 80 | dry-core (~> 1.0, < 2) 81 | dry-events (~> 1.0, < 2) 82 | dry-schema (1.13.4) 83 | concurrent-ruby (~> 1.0) 84 | dry-configurable (~> 1.0, >= 1.0.1) 85 | dry-core (~> 1.0, < 2) 86 | dry-initializer (~> 3.0) 87 | dry-logic (>= 1.4, < 2) 88 | dry-types (>= 1.7, < 2) 89 | zeitwerk (~> 2.6) 90 | dry-struct (1.6.0) 91 | dry-core (~> 1.0, < 2) 92 | dry-types (>= 1.7, < 2) 93 | ice_nine (~> 0.11) 94 | zeitwerk (~> 2.6) 95 | dry-system (1.1.1) 96 | dry-auto_inject (~> 1.0) 97 | dry-configurable (~> 1.0) 98 | dry-core (~> 1.0) 99 | dry-inflector (~> 1.0) 100 | dry-transformer (1.0.1) 101 | zeitwerk (~> 2.6) 102 | dry-types (1.7.2) 103 | bigdecimal (~> 3.0) 104 | concurrent-ruby (~> 1.0) 105 | dry-core (~> 1.0) 106 | dry-inflector (~> 1.0) 107 | dry-logic (~> 1.4) 108 | zeitwerk (~> 2.6) 109 | dry-validation (1.10.0) 110 | concurrent-ruby (~> 1.0) 111 | dry-core (~> 1.0, < 2) 112 | dry-initializer (~> 3.0) 113 | dry-schema (>= 1.12, < 2) 114 | zeitwerk (~> 2.6) 115 | et-orbi (1.2.11) 116 | tzinfo 117 | faker (3.5.1) 118 | i18n (>= 1.8.11, < 2) 119 | faraday (2.12.1) 120 | faraday-net_http (>= 2.0, < 3.5) 121 | json 122 | logger 123 | faraday-net_http (3.4.0) 124 | net-http (>= 0.5.0) 125 | faraday-retry (2.2.1) 126 | faraday (~> 2.0) 127 | ferrum (0.15) 128 | addressable (~> 2.5) 129 | concurrent-ruby (~> 1.1) 130 | webrick (~> 1.7) 131 | websocket-driver (~> 0.7) 132 | ffi (1.17.0-arm64-darwin) 133 | ffi (1.17.0-x86_64-linux-gnu) 134 | formatador (1.1.0) 135 | fugit (1.11.1) 136 | et-orbi (~> 1, >= 1.2.11) 137 | raabro (~> 1.4) 138 | gems (1.3.0) 139 | globalid (1.2.1) 140 | activesupport (>= 6.1) 141 | guard (2.19.0) 142 | formatador (>= 0.2.4) 143 | listen (>= 2.7, < 4.0) 144 | lumberjack (>= 1.0.12, < 2.0) 145 | nenv (~> 0.1) 146 | notiffany (~> 0.0) 147 | pry (>= 0.13.0) 148 | shellany (~> 0.0) 149 | thor (>= 0.18.1) 150 | guard-compat (1.2.1) 151 | guard-puma (0.8.1) 152 | guard (~> 2.14) 153 | guard-compat (~> 1.2) 154 | puma (>= 4.0, < 7) 155 | hanami (2.2.1) 156 | bundler (>= 1.16, < 3) 157 | dry-configurable (~> 1.0, >= 1.2.0, < 2) 158 | dry-core (~> 1.0, < 2) 159 | dry-inflector (~> 1.0, >= 1.1.0, < 2) 160 | dry-logger (~> 1.0, < 2) 161 | dry-monitor (~> 1.0, >= 1.0.1, < 2) 162 | dry-system (~> 1.1) 163 | hanami-cli (~> 2.2.1) 164 | hanami-utils (~> 2.2) 165 | json (>= 2.7.2) 166 | zeitwerk (~> 2.6) 167 | hanami-assets (2.2.0) 168 | zeitwerk (~> 2.6) 169 | hanami-cli (2.2.1) 170 | bundler (~> 2.1) 171 | dry-cli (~> 1.0, >= 1.1.0) 172 | dry-files (~> 1.0, >= 1.0.2, < 2) 173 | dry-inflector (~> 1.0, < 2) 174 | rake (~> 13.0) 175 | zeitwerk (~> 2.6) 176 | hanami-controller (2.2.0) 177 | dry-configurable (~> 1.0, < 2) 178 | dry-core (~> 1.0) 179 | hanami-utils (~> 2.2) 180 | rack (~> 2.0) 181 | zeitwerk (~> 2.6) 182 | hanami-reloader (2.2.0) 183 | guard (~> 2.19) 184 | guard-puma (~> 0.8) 185 | hanami-cli (~> 2.2) 186 | zeitwerk (~> 2.6) 187 | hanami-router (2.2.0) 188 | mustermann (~> 3.0) 189 | mustermann-contrib (~> 3.0) 190 | rack (~> 2.0) 191 | hanami-rspec (2.2.1) 192 | hanami-cli (~> 2.2) 193 | rake (~> 13.0) 194 | rspec (~> 3.12) 195 | zeitwerk (~> 2.6) 196 | hanami-utils (2.2.0) 197 | concurrent-ruby (~> 1.0) 198 | dry-core (~> 1.0, < 2) 199 | dry-transformer (~> 1.0, < 2) 200 | hanami-validations (2.2.0) 201 | dry-validation (>= 1.10, < 2) 202 | hanami-view (2.2.0) 203 | dry-configurable (~> 1.0) 204 | dry-core (~> 1.0) 205 | dry-inflector (~> 1.0, < 2) 206 | temple (~> 0.10.0, >= 0.10.2) 207 | tilt (~> 2.3) 208 | zeitwerk (~> 2.6) 209 | hansi (0.2.1) 210 | hashdiff (1.1.2) 211 | hashie (5.0.0) 212 | i18n (1.14.6) 213 | concurrent-ruby (~> 1.0) 214 | ice_nine (0.11.2) 215 | json (2.8.2) 216 | jwt (2.9.3) 217 | base64 218 | language_server-protocol (3.17.0.3) 219 | lint_roller (1.1.0) 220 | listen (3.9.0) 221 | rb-fsevent (~> 0.10, >= 0.10.3) 222 | rb-inotify (~> 0.9, >= 0.9.10) 223 | logger (1.6.1) 224 | lumberjack (1.2.10) 225 | matrix (0.4.2) 226 | method_source (1.1.0) 227 | mini_mime (1.1.5) 228 | minitest (5.25.1) 229 | multi_xml (0.7.1) 230 | bigdecimal (~> 3.1) 231 | mustermann (3.0.3) 232 | ruby2_keywords (~> 0.0.1) 233 | mustermann-contrib (3.0.3) 234 | hansi (~> 0.2.0) 235 | mustermann (= 3.0.3) 236 | nenv (0.3.0) 237 | net-http (0.5.0) 238 | uri 239 | nio4r (2.7.4) 240 | nokogiri (1.16.7-arm64-darwin) 241 | racc (~> 1.4) 242 | nokogiri (1.16.7-x86_64-linux) 243 | racc (~> 1.4) 244 | notiffany (0.1.3) 245 | nenv (~> 0.1) 246 | shellany (~> 0.0) 247 | oauth2 (2.0.9) 248 | faraday (>= 0.17.3, < 3.0) 249 | jwt (>= 1.0, < 3.0) 250 | multi_xml (~> 0.5) 251 | rack (>= 1.2, < 4) 252 | snaky_hash (~> 2.0) 253 | version_gem (~> 1.1) 254 | octokit (9.2.0) 255 | faraday (>= 1, < 3) 256 | sawyer (~> 0.9) 257 | omniauth (2.1.2) 258 | hashie (>= 3.4.6) 259 | rack (>= 2.2.3) 260 | rack-protection 261 | omniauth-github (2.0.1) 262 | omniauth (~> 2.0) 263 | omniauth-oauth2 (~> 1.8) 264 | omniauth-oauth2 (1.8.0) 265 | oauth2 (>= 1.4, < 3) 266 | omniauth (~> 2.0) 267 | parallel (1.26.3) 268 | parser (3.3.6.0) 269 | ast (~> 2.4.1) 270 | racc 271 | pg (1.5.9) 272 | pry (0.15.0) 273 | coderay (~> 1.1) 274 | method_source (~> 1.0) 275 | public_suffix (6.0.1) 276 | puma (6.4.3) 277 | nio4r (~> 2.0) 278 | raabro (1.4.0) 279 | racc (1.8.1) 280 | rack (2.2.10) 281 | rack-protection (3.2.0) 282 | base64 (>= 0.1.0) 283 | rack (~> 2.2, >= 2.2.4) 284 | rack-test (2.1.0) 285 | rack (>= 1.3) 286 | rainbow (3.1.1) 287 | rake (13.2.1) 288 | rb-fsevent (0.11.2) 289 | rb-inotify (0.11.1) 290 | ffi (~> 1.0) 291 | redis-client (0.22.2) 292 | connection_pool 293 | regexp_parser (2.9.2) 294 | rexml (3.3.9) 295 | rom (5.3.2) 296 | rom-changeset (~> 5.3, >= 5.3.0) 297 | rom-core (~> 5.3, >= 5.3.2) 298 | rom-repository (~> 5.3, >= 5.3.0) 299 | rom-changeset (5.3.0) 300 | dry-core (~> 1.0) 301 | rom-core (~> 5.3) 302 | transproc (~> 1.0, >= 1.1.0) 303 | rom-core (5.3.2) 304 | concurrent-ruby (~> 1.1) 305 | dry-configurable (~> 1.0) 306 | dry-core (~> 1.0) 307 | dry-inflector (~> 1.0) 308 | dry-initializer (~> 3.0, >= 3.0.1) 309 | dry-struct (~> 1.0) 310 | dry-types (~> 1.6) 311 | transproc (~> 1.0, >= 1.1.0) 312 | rom-factory (0.12.0) 313 | dry-configurable (~> 1.0) 314 | dry-core (~> 1.0) 315 | dry-struct (~> 1.6) 316 | faker (>= 2.0, < 4) 317 | rom-core (~> 5.3) 318 | rom-repository (5.3.0) 319 | dry-core (~> 1.0) 320 | dry-initializer (~> 3.0, >= 3.0.1) 321 | rom-core (~> 5.3, >= 5.3.0) 322 | rom-sql (3.6.4) 323 | dry-core (~> 1.0) 324 | dry-types (~> 1.0) 325 | rom (~> 5.2, >= 5.2.1) 326 | sequel (>= 4.49) 327 | rspec (3.13.0) 328 | rspec-core (~> 3.13.0) 329 | rspec-expectations (~> 3.13.0) 330 | rspec-mocks (~> 3.13.0) 331 | rspec-core (3.13.2) 332 | rspec-support (~> 3.13.0) 333 | rspec-expectations (3.13.3) 334 | diff-lcs (>= 1.2.0, < 2.0) 335 | rspec-support (~> 3.13.0) 336 | rspec-mocks (3.13.2) 337 | diff-lcs (>= 1.2.0, < 2.0) 338 | rspec-support (~> 3.13.0) 339 | rspec-sidekiq (5.0.0) 340 | rspec-core (~> 3.0) 341 | rspec-expectations (~> 3.0) 342 | rspec-mocks (~> 3.0) 343 | sidekiq (>= 5, < 8) 344 | rspec-support (3.13.1) 345 | rubocop (1.68.0) 346 | json (~> 2.3) 347 | language_server-protocol (>= 3.17.0) 348 | parallel (~> 1.10) 349 | parser (>= 3.3.0.2) 350 | rainbow (>= 2.2.2, < 4.0) 351 | regexp_parser (>= 2.4, < 3.0) 352 | rubocop-ast (>= 1.32.2, < 2.0) 353 | ruby-progressbar (~> 1.7) 354 | unicode-display_width (>= 2.4.0, < 3.0) 355 | rubocop-ast (1.36.1) 356 | parser (>= 3.3.1.0) 357 | rubocop-performance (1.22.1) 358 | rubocop (>= 1.48.1, < 2.0) 359 | rubocop-ast (>= 1.31.1, < 2.0) 360 | ruby-progressbar (1.13.0) 361 | ruby2_keywords (0.0.5) 362 | sawyer (0.9.2) 363 | addressable (>= 2.3.5) 364 | faraday (>= 0.17.3, < 3) 365 | securerandom (0.3.2) 366 | sentry-ruby (5.21.0) 367 | bigdecimal 368 | concurrent-ruby (~> 1.0, >= 1.0.2) 369 | sentry-sidekiq (5.21.0) 370 | sentry-ruby (~> 5.21.0) 371 | sidekiq (>= 3.0) 372 | sequel (5.86.0) 373 | bigdecimal 374 | sequel_pg (1.17.1) 375 | pg (>= 0.18.0, != 1.2.0) 376 | sequel (>= 4.38.0) 377 | shellany (0.0.1) 378 | sidekiq (7.3.5) 379 | connection_pool (>= 2.3.0) 380 | logger 381 | rack (>= 2.2.4) 382 | redis-client (>= 0.22.2) 383 | sidekiq-cron (2.0.1) 384 | cronex (>= 0.13.0) 385 | fugit (~> 1.8, >= 1.11.1) 386 | globalid (>= 1.0.1) 387 | sidekiq (>= 6.5.0) 388 | simplecov (0.22.0) 389 | docile (~> 1.1) 390 | simplecov-html (~> 0.11) 391 | simplecov_json_formatter (~> 0.1) 392 | simplecov-html (0.13.1) 393 | simplecov_json_formatter (0.1.4) 394 | snaky_hash (2.0.1) 395 | hashie 396 | version_gem (~> 1.1, >= 1.1.1) 397 | standard (1.42.0) 398 | language_server-protocol (~> 3.17.0.2) 399 | lint_roller (~> 1.0) 400 | rubocop (~> 1.68.0) 401 | standard-custom (~> 1.0.0) 402 | standard-performance (~> 1.5) 403 | standard-custom (1.0.2) 404 | lint_roller (~> 1.0) 405 | rubocop (~> 1.50) 406 | standard-performance (1.5.0) 407 | lint_roller (~> 1.1) 408 | rubocop-performance (~> 1.22.0) 409 | temple (0.10.3) 410 | thor (1.3.2) 411 | tilt (2.4.0) 412 | transproc (1.1.1) 413 | tzinfo (2.0.6) 414 | concurrent-ruby (~> 1.0) 415 | unicode (0.4.4.5) 416 | unicode-display_width (2.6.0) 417 | uri (1.0.2) 418 | version_gem (1.1.4) 419 | webmock (3.24.0) 420 | addressable (>= 2.8.0) 421 | crack (>= 0.3.2) 422 | hashdiff (>= 0.4.0, < 2.0.0) 423 | webrick (1.9.0) 424 | websocket-driver (0.7.6) 425 | websocket-extensions (>= 0.1.0) 426 | websocket-extensions (0.1.5) 427 | xpath (3.2.0) 428 | nokogiri (~> 1.8) 429 | zeitwerk (2.7.1) 430 | 431 | PLATFORMS 432 | arm64-darwin-22 433 | arm64-darwin-24 434 | x86_64-linux 435 | 436 | DEPENDENCIES 437 | capybara 438 | cuprite 439 | database_cleaner-sequel 440 | dotenv 441 | dry-monads 442 | dry-types (~> 1.7) 443 | faraday-retry 444 | gems 445 | hanami (~> 2.2) 446 | hanami-assets (~> 2.2) 447 | hanami-controller (~> 2.2) 448 | hanami-reloader 449 | hanami-router (~> 2.2) 450 | hanami-rspec 451 | hanami-validations (~> 2.2) 452 | hanami-view (~> 2.2) 453 | octokit 454 | omniauth 455 | omniauth-github 456 | pg 457 | puma 458 | rack-test 459 | rake 460 | rom (~> 5.3) 461 | rom-factory 462 | rom-sql (~> 3.6) 463 | rspec-sidekiq 464 | sentry-ruby 465 | sentry-sidekiq 466 | sequel_pg 467 | sidekiq 468 | sidekiq-cron 469 | simplecov 470 | standard 471 | webmock 472 | 473 | RUBY VERSION 474 | ruby 3.3.6p108 475 | 476 | BUNDLED WITH 477 | 2.5.4 478 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | group :server do 4 | guard "puma", port: ENV.fetch("HANAMI_PORT", 2300) do 5 | watch(%r{config/*}) 6 | watch(%r{lib/*}) 7 | watch(%r{app/*}) 8 | watch(%r{slices/*}) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: ./web.sh 2 | release: bundle exec rake db:migrate 3 | worker: ./worker.sh 4 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | server: bundle exec hanami server 2 | assets: bundle exec hanami assets watch 3 | sidekiq: bundle exec sidekiq -r ./config/sidekiq.rb 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ShinyGems 2 | [shinygems.dev](https://shinygems.dev) source code. 3 | 4 | ## Roadmap 5 | - add search and fill it automatically when searching with Gemfile 6 | - add more information about gems and repositories 7 | - restore github auth, allow to subscribe to updates for selected gems 8 | - UI improvements 9 | - better support for monorepos 10 | - documentation 11 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "hanami/rake_tasks" 4 | require "rom/sql/rake_task" 5 | 6 | Dir.glob("lib/tasks/**/*.rake").each { |r| load r } 7 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shinygems", 3 | "healthchecks": { 4 | "web": [ 5 | { 6 | "name": "web check", 7 | "path": "/", 8 | "port": 2300, 9 | "type": "startup" 10 | } 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /bin/hanami: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'hanami' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("hanami-cli", "hanami") 28 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "hanami/boot" 4 | 5 | run Hanami.app 6 | -------------------------------------------------------------------------------- /config/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "hanami" 4 | 5 | module ShinyGems 6 | class App < Hanami::App 7 | config.shared_app_component_keys += ["sentry", "persistence.rom"] 8 | 9 | config.actions.sessions = :cookie, { 10 | key: "session", 11 | secret: settings.session_secret, 12 | expire_after: 60 * 60 * 24 * 365 13 | } 14 | 15 | config.actions.content_security_policy[:form_action] += " https://github.com" 16 | config.actions.content_security_policy[:script_src] += " https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js https://static.cloudflareinsights.com/beacon.min.js/" 17 | config.actions.content_security_policy[:font_src] += " https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.2/font/fonts/" 18 | 19 | config.middleware.use Rack::Static, {urls: ["/assets", "/favicon.ico"], root: "public"} 20 | config.middleware.use :body_parser, :form 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /config/assets.js: -------------------------------------------------------------------------------- 1 | import * as assets from "hanami-assets"; 2 | import {sassPlugin} from 'esbuild-sass-plugin' 3 | 4 | await assets.run({ 5 | esbuildOptionsFn: (args, esbuildOptions) => { 6 | const plugins = [...esbuildOptions.plugins, sassPlugin()]; 7 | 8 | return { 9 | ...esbuildOptions, 10 | plugins, 11 | }; 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /config/providers/persistence.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Hanami.app.register_provider :persistence, namespace: true do 4 | prepare do 5 | require "rom" 6 | 7 | config = ROM::Configuration.new(:sql, target["settings"].database_url, max_connections: 10, sslmode: "disable") 8 | 9 | register "config", config 10 | register "db", config.gateways[:default].connection 11 | end 12 | 13 | start do 14 | config = target["persistence.config"] 15 | 16 | config.auto_registration( 17 | target.root.join("lib/shiny_gems/persistance"), 18 | namespace: "ShinyGems::Persistence" 19 | ) 20 | 21 | container = ROM.container(config) 22 | container.gateways[:default].use_logger(target["logger"]) if Hanami.env?(:development) 23 | register "rom", container 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /config/providers/sentry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Hanami.app.register_provider :sentry do 4 | prepare do 5 | require "sentry-ruby" 6 | require "sentry-sidekiq" 7 | 8 | Sentry.init do |config| 9 | config.dsn = target["settings"].sentry_url 10 | end 11 | end 12 | 13 | start do 14 | register "sentry", Sentry 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | max_threads_count = ENV.fetch("HANAMI_MAX_THREADS", 5) 4 | min_threads_count = ENV.fetch("HANAMI_MIN_THREADS") { max_threads_count } 5 | threads min_threads_count, max_threads_count 6 | 7 | port ENV.fetch("HANAMI_PORT", 2300) 8 | environment ENV.fetch("HANAMI_ENV", "development") 9 | workers ENV.fetch("HANAMI_WEB_CONCURRENCY", 2) 10 | 11 | preload_app! 12 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ShinyGems 4 | class Routes < Hanami::Routes 5 | slice :web, at: "/" do 6 | require "sidekiq/web" 7 | require "sidekiq/cron/web" 8 | require "omniauth" 9 | require "omniauth-github" 10 | 11 | Sidekiq::Web.use Rack::Auth::Basic do |username, password| 12 | Rack::Utils.secure_compare(::Digest::SHA256.hexdigest(username), ::Digest::SHA256.hexdigest(Hanami.app["settings"].sidekiq_web_user)) & 13 | Rack::Utils.secure_compare(::Digest::SHA256.hexdigest(password), ::Digest::SHA256.hexdigest(Hanami.app["settings"].sidekiq_web_pass)) 14 | end 15 | mount Sidekiq::Web, at: "/sidekiq" 16 | 17 | use OmniAuth::Builder do 18 | configure do |config| 19 | config.request_validation_phase = Web::Action.new 20 | end 21 | provider :github, Hanami.app["settings"].github_key, Hanami.app["settings"].github_secret, scope: "user:email" 22 | end 23 | 24 | root to: "pages.index" 25 | get "privacy", to: "pages.privacy" 26 | 27 | scope "auth" do 28 | get ":provider/callback", to: "session.create" 29 | get "destroy", to: "session.destroy" 30 | get "failure", to: "session.failure" 31 | end 32 | 33 | scope "gems" do 34 | get "/", to: "gems.index" 35 | get "/:id", id: /\d+/, to: "gems.show_old" 36 | get "/:name", to: "gems.show" 37 | post "/:name/favorite", to: "favorites.create" 38 | post "/:name/unfavorite", to: "favorites.delete" 39 | end 40 | post "/gemfile", to: "gemfile.create" 41 | scope "favorites" do 42 | get "/", to: "favorites.index" 43 | end 44 | 45 | get "/*any", to: "pages.not_found" 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /config/schedule.yml: -------------------------------------------------------------------------------- 1 | daily_sync: 2 | cron: "0 * * * *" 3 | class: "Processing::Workers::SyncAllWorker" 4 | queue: default 5 | date_as_argument: true 6 | weekly_discover: 7 | cron: "0 0 * * 1" 8 | class: "Processing::Workers::DiscoverWorker" 9 | queue: default 10 | -------------------------------------------------------------------------------- /config/settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ShinyGems 4 | class Settings < Hanami::Settings 5 | setting :github_key, constructor: Types::String.optional 6 | setting :github_secret, constructor: Types::String.optional 7 | setting :database_url, constructor: Types::String.optional 8 | setting :redis_url, constructor: Types::String.optional 9 | setting :sentry_url, constructor: Types::String.optional 10 | setting :host, constructor: Types::String.optional 11 | setting :session_secret, constructor: Types::String.optional 12 | setting :sidekiq_web_user, constructor: Types::String.optional 13 | setting :sidekiq_web_pass, constructor: Types::String.optional 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /config/sidekiq.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "sentry-sidekiq" 4 | require "sidekiq-cron" 5 | require "hanami/boot" 6 | 7 | Sidekiq.configure_server do |c| 8 | c.redis = {url: Hanami.app["settings"].redis_url} 9 | end 10 | -------------------------------------------------------------------------------- /config/sidekiq.yml: -------------------------------------------------------------------------------- 1 | --- 2 | :concurrency: 10 3 | -------------------------------------------------------------------------------- /config/slices/processing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "sidekiq" 4 | 5 | module Processing 6 | class Slice < Hanami::Slice 7 | Sidekiq.configure_client do |c| 8 | c.redis = {url: Hanami.app["settings"].redis_url} 9 | end 10 | 11 | prepare_container do |container| 12 | container.config.component_dirs.dir("") do |dir| 13 | dir.instance = proc do |component| 14 | if component.key.match?(/workers\./) 15 | component.loader.constant(component) 16 | else 17 | component.loader.call(component) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /config/slices/web.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | class Slice < Hanami::Slice 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20221209194551_create_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | create_table :users do 6 | primary_key :id 7 | column :username, String, null: false 8 | column :github_id, Integer, null: false, index: {unique: true} 9 | column :github_token_encrypted, String, null: false 10 | column :avatar, String, null: false 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20221212121815_create_gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | create_table :gems do 6 | primary_key :id 7 | column :name, String, null: false, index: {unique: true} 8 | column :repo, String, null: false 9 | column :description, String, text: true 10 | column :stars, Integer, null: false 11 | column :downloads, Integer, null: false 12 | foreign_key :user_id, :users 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20221213145821_add_admin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | alter_table(:users) do 6 | add_column :admin, TrueClass, default: false, null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20221215121048_create_issues.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | create_table :issues do 6 | primary_key :id 7 | column :title, String, null: false 8 | column :url, String, null: false 9 | column :github_id, Integer, null: false, index: {unique: true} 10 | column :comments, Integer, null: false 11 | column :labels, :jsonb, null: false 12 | foreign_key :gem_id, :gems, on_delete: :cascade 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20230107215950_remove_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | alter_table :gems do 6 | drop_column :user_id 7 | end 8 | drop_table :users 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20230107220211_add_created_at_to_issues.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | alter_table :issues do 6 | add_column :created_at, DateTime 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20230111180103_add_pushed_to_gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | alter_table :gems do 6 | add_column :pushed_at, DateTime 7 | set_column_allow_null :stars 8 | set_column_allow_null :downloads 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20230111185237_remove_uniq_constraint_on_issues.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | alter_table :issues do 6 | drop_index(:github_id) 7 | add_unique_constraint([:gem_id, :github_id]) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20230115105235_add_repos.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | create_table :repos do 6 | primary_key :id 7 | column :name, String, null: false, index: {unique: true} 8 | column :stars, Integer 9 | column :pushed_at, DateTime 10 | end 11 | 12 | alter_table :gems do 13 | drop_column :repo 14 | drop_column :stars 15 | drop_column :pushed_at 16 | add_foreign_key :repo_id, :repos, index: true 17 | end 18 | 19 | alter_table :issues do 20 | drop_foreign_key :gem_id 21 | add_foreign_key :repo_id, :repos, index: true 22 | add_index :github_id, unique: true 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /db/migrate/20230305222521_add_additional_gem_info.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | alter_table :gems do 6 | add_column :version, String 7 | add_column :licenses, "text[]" 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20230319213848_restore_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | create_table :users do 6 | primary_key :id 7 | column :username, String, null: false 8 | column :email, String, null: false 9 | column :github_id, Integer, null: false, index: {unique: true} 10 | column :avatar, String, null: false 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20230321204915_create_favorites.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | create_table :favorites do 6 | primary_key :id 7 | foreign_key :gem_id, :gems, on_delete: :cascade 8 | foreign_key :user_id, :users, on_delete: :cascade 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20230324013057_add_unique_constrain_on_favorites.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | alter_table :favorites do 6 | add_index([:gem_id, :user_id], unique: true) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20231001162627_add_delete_cascade_to_issues.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | alter_table :issues do 6 | drop_foreign_key :repo_id 7 | add_foreign_key :repo_id, :repos, index: true, on_delete: :cascade 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20241009122616_switch_download_count_to_bigint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | alter_table :gems do 6 | set_column_type :downloads, "bigint" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20241009182306_switch_github_id_to_bigint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ROM::SQL.migration do 4 | change do 5 | alter_table :issues do 6 | set_column_type :github_id, "bigint" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/shiny_gems/persistance/relations/favorites.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ShinyGems 4 | module Persistence 5 | module Relations 6 | class Favorites < ROM::Relation[:sql] 7 | schema(:favorites, infer: true) do 8 | associations do 9 | belongs_to :user 10 | belongs_to :gem 11 | end 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/shiny_gems/persistance/relations/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ShinyGems 4 | module Persistence 5 | module Relations 6 | class Gems < ROM::Relation[:sql] 7 | use :pagination 8 | 9 | schema(:gems, infer: true) do 10 | associations do 11 | belongs_to :repo 12 | has_many :favorites 13 | end 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/shiny_gems/persistance/relations/issues.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ShinyGems 4 | module Persistence 5 | module Relations 6 | class Issues < ROM::Relation[:sql] 7 | schema(:issues, infer: true) do 8 | associations do 9 | belongs_to :repo 10 | end 11 | end 12 | 13 | def ordered 14 | order { created_at.desc } 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/shiny_gems/persistance/relations/repos.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ShinyGems 4 | module Persistence 5 | module Relations 6 | class Repos < ROM::Relation[:sql] 7 | schema(:repos, infer: true) do 8 | associations do 9 | has_many :gems 10 | has_many :issues, view: :ordered 11 | end 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/shiny_gems/persistance/relations/users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ShinyGems 4 | module Persistence 5 | module Relations 6 | class Users < ROM::Relation[:sql] 7 | schema(:users, infer: true) do 8 | associations do 9 | has_many :favorites 10 | end 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/shiny_gems/service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/monads" 4 | 5 | module ShinyGems 6 | class Service 7 | include Dry::Monads[:result, :maybe, :do] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/shiny_gems/types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/types" 4 | 5 | module ShinyGems 6 | Types = Dry.Types 7 | 8 | module Types 9 | # Define your custom types here 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kukicola/shiny_gems/54255d05e44dabad0d861f80e45d047b5ba57314/lib/tasks/.keep -------------------------------------------------------------------------------- /lib/tasks/db/setup.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | desc "Setups DB" 4 | namespace :db do 5 | task setup: :environment do 6 | Hanami.app.prepare(:persistence) 7 | ROM::SQL::RakeSupport.env = Hanami.app["persistence.config"] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shiny_gems", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "assets": "node config/assets.js" 7 | }, 8 | "dependencies": { 9 | "@popperjs/core": "^2.11.8", 10 | "bootstrap": "^5.3.2", 11 | "bootstrap-icons": "^1.11.1", 12 | "esbuild-sass-plugin": "^2.16.0", 13 | "hanami-assets": "^2.2.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /public/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kukicola/shiny_gems/54255d05e44dabad0d861f80e45d047b5ba57314/public/.keep -------------------------------------------------------------------------------- /slices/processing/config/providers/gems_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Processing::Slice.register_provider :gems_api do 4 | prepare do 5 | require "gems" 6 | end 7 | 8 | start do 9 | register "gems_api", ::Gems 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /slices/processing/config/providers/octokit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Processing::Slice.register_provider :octokit do 4 | prepare do 5 | require "octokit" 6 | 7 | Octokit.configure do |c| 8 | c.auto_paginate = true 9 | end 10 | end 11 | 12 | start do 13 | register "octokit", Octokit::Client.new(client_id: target["settings"].github_key, client_secret: target["settings"].github_secret) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /slices/processing/repositories/gems_repository.rb: -------------------------------------------------------------------------------- 1 | # memoize: true 2 | # frozen_string_literal: true 3 | 4 | module Processing 5 | module Repositories 6 | class GemsRepository < ROM::Repository[:gems] 7 | include Deps[container: "persistence.rom"] 8 | 9 | commands :create, update: :by_pk, delete: :by_pk 10 | auto_struct true 11 | 12 | def by_id(id, with: nil) 13 | query = gems.by_pk(id) 14 | query = query.combine(with) if with 15 | query.one 16 | end 17 | 18 | def pluck_ids_for_hour(hour) 19 | gems.where(Sequel.lit("id % 24 = ?", hour)).pluck(:id) 20 | end 21 | 22 | def pluck_name_by_list(items) 23 | gems.where(name: items).pluck(:name) 24 | end 25 | 26 | def replace_repo(old_id, new_id) 27 | gems.where(repo_id: old_id).update(repo_id: new_id) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /slices/processing/repositories/issues_repository.rb: -------------------------------------------------------------------------------- 1 | # memoize: true 2 | # frozen_string_literal: true 3 | 4 | module Processing 5 | module Repositories 6 | class IssuesRepository < ROM::Repository[:issues] 7 | include Deps[container: "persistence.rom"] 8 | 9 | commands :create, update: :by_pk, delete: :by_pk 10 | auto_struct true 11 | 12 | def replace_repo(old_id, new_id) 13 | issues.where(repo_id: old_id).update(repo_id: new_id) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /slices/processing/repositories/repos_repository.rb: -------------------------------------------------------------------------------- 1 | # memoize: true 2 | # frozen_string_literal: true 3 | 4 | module Processing 5 | module Repositories 6 | class ReposRepository < ROM::Repository[:repos] 7 | include Deps[container: "persistence.rom"] 8 | 9 | commands :create, update: :by_pk, delete: :by_pk 10 | auto_struct true 11 | 12 | def by_id(id, with: nil) 13 | query = repos.by_pk(id) 14 | query = query.combine(with) if with 15 | query.one 16 | end 17 | 18 | def pluck_ids_for_hour(hour) 19 | repos.where(Sequel.lit("id % 24 = ?", hour)).pluck(:id) 20 | end 21 | 22 | def find_or_create(attributes) 23 | existing = repos.where(attributes).one 24 | return existing if existing 25 | 26 | create(attributes) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /slices/processing/services/discover.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Processing 4 | module Services 5 | class Discover < ShinyGems::Service 6 | include Deps["services.rubygems.list_fetcher", "repositories.gems_repository", "repositories.repos_repository"] 7 | 8 | GITHUB_REPO_REGEX = %r{https?://github.com/([^/#]+/[^/#]+)} 9 | 10 | def call(page: 1) 11 | gems = yield list_fetcher.call(page: page) 12 | 13 | return Failure(:no_results) if gems.empty? 14 | 15 | existing_gem_names = gems_repository.pluck_name_by_list(gems.map { |gem| gem["name"] }) 16 | missing_gems = gems.reject { |gem| existing_gem_names.include?(gem["name"]) } 17 | created_gems = gems_repository.transaction do 18 | missing_gems.map(&method(:attempt_create)).compact 19 | end 20 | Success(created_gems) 21 | end 22 | 23 | private 24 | 25 | def attempt_create(gem) 26 | github_repo = extract_github_repo(gem) 27 | return unless github_repo 28 | 29 | repo = repos_repository.find_or_create({name: github_repo}) 30 | 31 | gems_repository.create({ 32 | name: gem["name"], 33 | repo_id: repo.id 34 | }) 35 | end 36 | 37 | def extract_github_repo(gem) 38 | gem["homepage_uri"]&.match(GITHUB_REPO_REGEX)&.[](1) || gem["source_code_uri"]&.match(GITHUB_REPO_REGEX)&.[](1) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /slices/processing/services/github/issues_list_fetcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Processing 4 | module Services 5 | module Github 6 | class IssuesListFetcher < ShinyGems::Service 7 | include Deps[:octokit] 8 | 9 | def call(repo) 10 | Success(octokit.list_issues(repo, state: "open", labels: "help wanted").reject { |issue| issue[:pull_request] }) 11 | rescue Octokit::Error => e 12 | Failure(e) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /slices/processing/services/github/repo_fetcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Processing 4 | module Services 5 | module Github 6 | class RepoFetcher < ShinyGems::Service 7 | include Deps["octokit"] 8 | 9 | def call(name) 10 | Success(octokit.repo(name)) 11 | rescue Octokit::Error => e 12 | Failure(e) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /slices/processing/services/issues_syncer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Processing 4 | module Services 5 | class IssuesSyncer < ShinyGems::Service 6 | include Deps["services.github.issues_list_fetcher", "repositories.issues_repository"] 7 | 8 | def call(repo) 9 | list = yield issues_list_fetcher.call(repo.name) 10 | github_ids = list.map { |gh_issue| gh_issue[:id] } 11 | issues_to_remove = repo.issues.reject { |issue| github_ids.include?(issue.github_id) } 12 | 13 | issues_repository.transaction do 14 | list.each do |gh_issue| 15 | existing_issue = repo.issues.find { |issue| issue.github_id == gh_issue[:id] } 16 | 17 | if existing_issue 18 | issues_repository.update(existing_issue.id, issue_attributes(gh_issue)) 19 | else 20 | issues_repository.create(issue_attributes(gh_issue).merge({repo_id: repo.id})) 21 | end 22 | end 23 | 24 | issues_to_remove.each do |issue| 25 | issues_repository.delete(issue.id) 26 | end 27 | end 28 | 29 | Success() 30 | end 31 | 32 | private 33 | 34 | def issue_attributes(gh_issue) 35 | { 36 | github_id: gh_issue[:id], 37 | title: gh_issue[:title], 38 | url: gh_issue[:html_url], 39 | comments: gh_issue[:comments], 40 | created_at: gh_issue[:created_at], 41 | labels: gh_issue[:labels].map { |label| label.to_h.slice(:name, :color) } 42 | } 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /slices/processing/services/repo_syncer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Processing 4 | module Services 5 | class RepoSyncer < ShinyGems::Service 6 | include Deps["services.github.repo_fetcher", "repositories.repos_repository", 7 | "repositories.gems_repository", "workers.sync_issues_worker"] 8 | 9 | def call(repo) 10 | info = yield repo_fetcher.call(repo.name) 11 | 12 | attributes = { 13 | stars: info[:stargazers_count], 14 | pushed_at: info[:pushed_at] 15 | } 16 | 17 | if info[:full_name] == repo.name 18 | Success(repos_repository.update(repo.id, attributes)) 19 | else 20 | repos_repository.transaction do 21 | new_repo = repos_repository.find_or_create({name: info[:full_name]}) 22 | gems_repository.replace_repo(repo.id, new_repo.id) 23 | repos_repository.delete(repo.id) 24 | Success(repos_repository.update(new_repo.id, attributes)) 25 | end 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /slices/processing/services/rubygems/gem_fetcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Processing 4 | module Services 5 | module Rubygems 6 | class GemFetcher < ShinyGems::Service 7 | include Deps["gems_api"] 8 | 9 | def call(name) 10 | Success(gems_api.info(name)) 11 | rescue ::Gems::GemError => e 12 | Failure(e) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /slices/processing/services/rubygems/list_fetcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Processing 4 | module Services 5 | module Rubygems 6 | class ListFetcher < ShinyGems::Service 7 | include Deps["gems_api"] 8 | 9 | MIN_DOWNLOADS = 1_000_000 10 | 11 | def call(page: 1) 12 | Success(gems_api.search("downloads:>#{MIN_DOWNLOADS}", {page: page})) 13 | rescue ::Gems::GemError => e 14 | Failure(e) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /slices/processing/services/syncer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Processing 4 | module Services 5 | class Syncer < ShinyGems::Service 6 | include Deps["services.rubygems.gem_fetcher", "repositories.gems_repository"] 7 | 8 | def call(gem) 9 | info = yield gem_fetcher.call(gem.name) 10 | 11 | attributes = { 12 | description: info["info"], 13 | downloads: info["downloads"], 14 | version: info["version"], 15 | licenses: info["licenses"] 16 | } 17 | 18 | Success(gems_repository.update(gem.id, attributes)) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /slices/processing/worker.rb: -------------------------------------------------------------------------------- 1 | # auto_register: false 2 | # frozen_string_literal: true 3 | 4 | module Processing 5 | class Worker 6 | include Sidekiq::Job 7 | 8 | sidekiq_options retry: 10 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /slices/processing/workers/discover_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Processing 4 | module Workers 5 | class DiscoverWorker < Processing::Worker 6 | include Deps["workers.sync_worker", "workers.sync_issues_worker", "workers.sync_repo_worker", "services.discover"] 7 | 8 | def perform(page = 1) 9 | result = discover.call(page: page) 10 | 11 | case result 12 | in Dry::Monads::Success[*gems] 13 | gems.each do |gem| 14 | sync_worker.perform_async(gem.id) 15 | sync_repo_worker.perform_async(gem.repo_id) 16 | sync_issues_worker.perform_async(gem.repo_id) 17 | end 18 | self.class.perform_async(page + 1) 19 | in Dry::Monads::Failure(StandardError => exception) 20 | raise exception 21 | in Dry::Monads::Failure(:no_results) 22 | nil 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /slices/processing/workers/sync_all_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Processing 4 | module Workers 5 | class SyncAllWorker < Processing::Worker 6 | include Deps["workers.sync_worker", "workers.sync_issues_worker", "workers.sync_repo_worker", 7 | "repositories.gems_repository", "repositories.repos_repository"] 8 | 9 | def perform(time) 10 | hour = Time.at(time).utc.hour 11 | 12 | gems_repository.pluck_ids_for_hour(hour).each do |id| 13 | sync_worker.perform_async(id) 14 | end 15 | 16 | repos_repository.pluck_ids_for_hour(hour).each do |id| 17 | sync_repo_worker.perform_async(id) 18 | sync_issues_worker.perform_async(id) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /slices/processing/workers/sync_issues_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Processing 4 | module Workers 5 | class SyncIssuesWorker < Processing::Worker 6 | include Deps["repositories.repos_repository", "services.issues_syncer"] 7 | 8 | def perform(repo_id) 9 | repo = repos_repository.by_id(repo_id, with: [:issues]) 10 | result = issues_syncer.call(repo) 11 | 12 | raise result.failure if !result.success? && ![Octokit::NotFound, Octokit::Forbidden].include?(result.failure.class) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /slices/processing/workers/sync_repo_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Processing 4 | module Workers 5 | class SyncRepoWorker < Processing::Worker 6 | include Deps["services.repo_syncer", "repositories.repos_repository"] 7 | 8 | def perform(repo_id) 9 | repo = repos_repository.by_id(repo_id) 10 | result = repo_syncer.call(repo) 11 | 12 | raise result.failure if !result.success? && !result.failure.is_a?(Octokit::NotFound) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /slices/processing/workers/sync_worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Processing 4 | module Workers 5 | class SyncWorker < Processing::Worker 6 | include Deps["services.syncer", "repositories.gems_repository"] 7 | 8 | def perform(gem_id) 9 | gem = gems_repository.by_id(gem_id) 10 | result = syncer.call(gem) 11 | 12 | case result 13 | in Dry::Monads::Success 14 | nil 15 | in Dry::Monads::Failure[Gems::NotFound] 16 | gems_repository.delete(gem_id) 17 | else 18 | raise result.failure 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /slices/web/action.rb: -------------------------------------------------------------------------------- 1 | # auto_register: false 2 | # frozen_string_literal: true 3 | 4 | require "hanami/action" 5 | 6 | module Web 7 | class Action < Hanami::Action 8 | include Deps["sentry", "settings", "views.pages.error", "views.pages.not_authorized", "repositories.users_repository"] 9 | 10 | BadRequestError = Class.new(StandardError) 11 | NotFoundError = Class.new(StandardError) 12 | 13 | handle_exception NotFoundError => :handle_not_found 14 | handle_exception BadRequestError => :handle_bad_request 15 | handle_exception StandardError => :handle_standard_error 16 | 17 | before :check_host! 18 | before :set_current_user 19 | 20 | private 21 | 22 | def set_current_user(request, response) 23 | user_id = request.session[:user_id] 24 | response[:current_user] = user_id && users_repository.by_id(user_id) 25 | end 26 | 27 | def require_user!(_, response) 28 | return if response[:current_user] 29 | 30 | halt 200, response.render(not_authorized) 31 | end 32 | 33 | def validate_params!(request, _response) 34 | raise BadRequestError unless request.params.valid? 35 | end 36 | 37 | def handle_bad_request(_request, response, _exception) 38 | response.render(error, code: 400) 39 | response.status = 400 40 | end 41 | 42 | def handle_not_found(_request, response, _exception) 43 | response.render(error, code: 404) 44 | response.status = 404 45 | end 46 | 47 | def handle_standard_error(request, response, exception) 48 | if Hanami.env?(:development) || Hanami.env?(:test) 49 | raise exception 50 | else 51 | sentry.capture_exception(exception) do |scope| 52 | scope.set_rack_env(request.env) 53 | end 54 | response.render(error, code: 500) 55 | response.status = 500 56 | end 57 | end 58 | 59 | def check_host!(request, response) 60 | return if Hanami.env?(:test) 61 | 62 | response.redirect_to("//#{settings.host}") unless request.host == settings.host 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /slices/web/actions/favorites/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Actions 5 | module Favorites 6 | class Create < Web::Action 7 | include Deps["repositories.gems_repository", "repositories.favorites_repository"] 8 | 9 | before :require_user! 10 | before :validate_params! 11 | 12 | params do 13 | required(:name).filled(:string) 14 | end 15 | 16 | def handle(request, response) 17 | gem = gems_repository.by_name(request.params[:name]) 18 | raise NotFoundError unless gem 19 | 20 | favorites_repository.create(gem_id: gem.id, user_id: response[:current_user].id) 21 | 22 | response.flash[:success] = "Gem added to favorites" 23 | response.redirect_to("/gems/#{gem.name}") 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /slices/web/actions/favorites/delete.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Actions 5 | module Favorites 6 | class Delete < Web::Action 7 | include Deps["repositories.gems_repository", "repositories.favorites_repository"] 8 | 9 | before :require_user! 10 | before :validate_params! 11 | 12 | params do 13 | required(:name).filled(:string) 14 | end 15 | 16 | def handle(request, response) 17 | gem = gems_repository.by_name(request.params[:name]) 18 | raise NotFoundError unless gem 19 | 20 | favorites_repository.unlink(gem_id: gem.id, user_id: response[:current_user].id) 21 | 22 | response.flash[:success] = "Gem removed from favorites" 23 | response.redirect_to("/gems/#{gem.name}") 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /slices/web/actions/favorites/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Actions 5 | module Favorites 6 | class Index < Web::Action 7 | include Deps["repositories.gems_repository"] 8 | 9 | before :require_user! 10 | 11 | def handle(request, response) 12 | response[:gems] = gems_repository.user_favorites(response[:current_user].id) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /slices/web/actions/gemfile/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Actions 5 | module Gemfile 6 | class Create < Web::Action 7 | include Deps["services.gemfile_parser", "errors_mapper", "repositories.gems_repository"] 8 | 9 | def handle(request, response) 10 | result = gemfile_parser.call(request.params[:gemfile][:tempfile].read) 11 | 12 | if result.success? 13 | response[:gems] = gems_repository.by_list(result.value!) 14 | else 15 | response.flash[:warning] = errors_mapper.call(result.failure) 16 | response.redirect_to("/gems") 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /slices/web/actions/gems/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Actions 5 | module Gems 6 | class Index < Web::Action 7 | include Deps["repositories.gems_repository"] 8 | 9 | before :validate_params! 10 | 11 | DEFAULT_PARAMS = { 12 | page: 1, 13 | sort_by: "downloads" 14 | }.freeze 15 | 16 | SORTING_DIRECTIONS = ["name", "stars", "downloads", "issues_count", "recent_issues", "favorites"].freeze 17 | 18 | params do 19 | optional(:page).filled(:integer) 20 | optional(:sort_by).filled(:string, included_in?: SORTING_DIRECTIONS) 21 | end 22 | 23 | def handle(request, response) 24 | params = DEFAULT_PARAMS.merge(request.params.to_h) 25 | 26 | result = gems_repository.index(page: params[:page], order: params[:sort_by]) 27 | response[:gems] = result.to_a 28 | response[:pager] = result.pager 29 | response[:sort_by] = params[:sort_by] 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /slices/web/actions/gems/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Actions 5 | module Gems 6 | class Show < Web::Action 7 | include Deps["repositories.gems_repository", "repositories.favorites_repository"] 8 | 9 | before :validate_params! 10 | 11 | params do 12 | required(:name).filled(:string) 13 | end 14 | 15 | def handle(request, response) 16 | gem = gems_repository.by_name(request.params[:name], with: {repo: [:issues, :gems]}) 17 | raise NotFoundError unless gem 18 | 19 | response[:current_gem] = gem 20 | response[:favorite] = if response[:current_user] 21 | favorites_repository.favorite?(user_id: response[:current_user].id, gem_id: gem.id) 22 | else 23 | false 24 | end 25 | response[:total_favorites] = favorites_repository.total_favorites(gem.id) 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /slices/web/actions/gems/show_old.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Actions 5 | module Gems 6 | class ShowOld < Web::Action 7 | include Deps["repositories.gems_repository"] 8 | 9 | before :validate_params! 10 | 11 | params do 12 | required(:id).filled(:integer) 13 | end 14 | 15 | def handle(request, response) 16 | gem = gems_repository.by_id(request.params[:id]) 17 | response.redirect_to("/gems/#{gem.name}", status: 301) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /slices/web/actions/pages/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Actions 5 | module Pages 6 | class Index < Web::Action 7 | include Deps["repositories.gems_repository"] 8 | 9 | def handle(request, response) 10 | response[:random_gems] = gems_repository.random(3) 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /slices/web/actions/pages/not_found.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Actions 5 | module Pages 6 | class NotFound < Web::Action 7 | def handle(_request, _response) 8 | raise NotFoundError 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /slices/web/actions/pages/privacy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Actions 5 | module Pages 6 | class Privacy < Web::Action 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /slices/web/actions/session/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Actions 5 | module Session 6 | class Create < Web::Action 7 | include Deps["repositories.users_repository"] 8 | 9 | def handle(request, response) 10 | user = users_repository.auth(request.env["omniauth.auth"]) 11 | response.session[:user_id] = user.id 12 | response.flash[:success] = "Successfully signed in" 13 | response.redirect_to("/") 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /slices/web/actions/session/destroy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Actions 5 | module Session 6 | class Destroy < Web::Action 7 | def handle(_request, response) 8 | response.session.delete(:user_id) 9 | response.flash[:success] = "Successfully signed out" 10 | response.redirect_to("/") 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /slices/web/actions/session/failure.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Actions 5 | module Session 6 | class Failure < Web::Action 7 | def handle(_request, response) 8 | response.flash[:warning] = "Error: couldn't sign in" 9 | response.redirect_to("/") 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /slices/web/assets/css/app.scss: -------------------------------------------------------------------------------- 1 | $red: #CC342D; 2 | 3 | .page { 4 | min-height: 100vh; 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | body { 10 | background: #f5f7fb; 11 | } 12 | 13 | .navbar-logo { 14 | width: 40px; 15 | height: 40px; 16 | } 17 | 18 | .navbar-nav .nav-link { 19 | padding-left: 1rem !important; 20 | padding-right: 1rem !important; 21 | } 22 | 23 | .text-red { 24 | color: $red; 25 | } 26 | 27 | .text-gray { 28 | color: #adb5bd; 29 | 30 | svg path { 31 | fill: #adb5bd; 32 | } 33 | } 34 | 35 | a.text-gray:hover { 36 | color: #ced4da; 37 | 38 | svg path { 39 | fill: #ced4da; 40 | } 41 | } 42 | 43 | .intro { 44 | background-color: rgba(204, 52, 45, 1); 45 | background-image: linear-gradient(0deg, rgba(204, 52, 45, 1) 0%, rgba(231, 101, 98, 1) 100%); 46 | padding: 7rem 0; 47 | } 48 | 49 | .home-title { 50 | line-height: 1; 51 | } 52 | 53 | .btn-intro-main { 54 | background: #ffffff; 55 | font-weight: 500; 56 | 57 | &:hover { 58 | background: #ffffff; 59 | } 60 | } 61 | 62 | .text-big { 63 | font-size: 1.2rem; 64 | } 65 | 66 | .text-huge { 67 | font-size: 1.5rem; 68 | } 69 | 70 | .avatar { 71 | width: 40px; 72 | height: 40px; 73 | border-radius: 20px; 74 | } 75 | 76 | footer { 77 | margin-top: auto; 78 | } 79 | 80 | .home-image { 81 | border: 1px solid #dee2e6; 82 | overflow: hidden; 83 | border-radius: 10px; 84 | } 85 | 86 | .bg-red { 87 | background: $red; 88 | } 89 | 90 | .icon-huge { 91 | font-size: 7rem; 92 | } 93 | 94 | .svg-icon { 95 | height: 22px; 96 | display: inline-block; 97 | vertical-align: text-top; 98 | } 99 | -------------------------------------------------------------------------------- /slices/web/assets/images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kukicola/shiny_gems/54255d05e44dabad0d861f80e45d047b5ba57314/slices/web/assets/images/1.png -------------------------------------------------------------------------------- /slices/web/assets/images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kukicola/shiny_gems/54255d05e44dabad0d861f80e45d047b5ba57314/slices/web/assets/images/2.png -------------------------------------------------------------------------------- /slices/web/assets/images/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kukicola/shiny_gems/54255d05e44dabad0d861f80e45d047b5ba57314/slices/web/assets/images/3.png -------------------------------------------------------------------------------- /slices/web/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kukicola/shiny_gems/54255d05e44dabad0d861f80e45d047b5ba57314/slices/web/assets/images/favicon.ico -------------------------------------------------------------------------------- /slices/web/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /slices/web/assets/images/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kukicola/shiny_gems/54255d05e44dabad0d861f80e45d047b5ba57314/slices/web/assets/images/og.png -------------------------------------------------------------------------------- /slices/web/assets/js/app.js: -------------------------------------------------------------------------------- 1 | import { Tooltip, Toast } from 'bootstrap' 2 | import 'bootstrap/dist/css/bootstrap.min.css' 3 | import 'bootstrap-icons/font/bootstrap-icons.scss' 4 | import '../css/app.scss' 5 | 6 | (function () { 7 | const toastElList = document.querySelectorAll('.toast') 8 | toastElList.forEach(function (toastEl) { 9 | new Toast(toastEl, {}).show() 10 | }) 11 | 12 | const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]') 13 | tooltipTriggerList.forEach(function (tooltip) { 14 | new Tooltip(tooltip) 15 | }) 16 | 17 | const autoSaveForms = document.querySelectorAll('[data-autosave]') 18 | autoSaveForms.forEach(function (form) { 19 | form.querySelectorAll("select, input").forEach(function (select) { 20 | select.addEventListener('change', function() { 21 | select.closest('form').submit(); 22 | }) 23 | }) 24 | }) 25 | 26 | const uploadButtons = document.querySelectorAll('button[data-upload]') 27 | uploadButtons.forEach(function (button) { 28 | button.addEventListener('click', function(event) { 29 | event.preventDefault(); 30 | button.closest("form").querySelector("input[type=file]").click() 31 | }) 32 | }) 33 | })() 34 | -------------------------------------------------------------------------------- /slices/web/lib/errors_mapper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | class ErrorsMapper 5 | MAP = { 6 | no_gems_in_gemfile: "No gems found in file, are you sure it's a correct Gemfile?", 7 | gemfile_parse_failed: "Couldn't parse gemfile" 8 | }.freeze 9 | 10 | def call(error) 11 | MAP[error] 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /slices/web/lib/formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | class Formatter 5 | def separator(number) 6 | num_groups = number.to_s.chars.to_a.reverse.each_slice(3) 7 | num_groups.map(&:join).join(",").reverse 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /slices/web/repositories/favorites_repository.rb: -------------------------------------------------------------------------------- 1 | # memoize: true 2 | # frozen_string_literal: true 3 | 4 | module Web 5 | module Repositories 6 | class FavoritesRepository < ROM::Repository[:favorites] 7 | include Deps[container: "persistence.rom"] 8 | 9 | commands :create, delete: :by_pk 10 | auto_struct true 11 | 12 | def favorite?(user_id:, gem_id:) 13 | favorites.exist?(user_id: user_id, gem_id: gem_id) 14 | end 15 | 16 | def total_favorites(gem_id) 17 | favorites.where(gem_id: gem_id).count 18 | end 19 | 20 | def unlink(user_id:, gem_id:) 21 | favorites.where(user_id: user_id, gem_id: gem_id).delete 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /slices/web/repositories/gems_repository.rb: -------------------------------------------------------------------------------- 1 | # memoize: true 2 | # frozen_string_literal: true 3 | 4 | module Web 5 | module Repositories 6 | class GemsRepository < ROM::Repository[:gems] 7 | include Deps[container: "persistence.rom"] 8 | 9 | SORT = { 10 | "downloads" => proc { `downloads DESC` }, 11 | "stars" => proc { `stars DESC` }, 12 | "name" => proc { `name ASC` }, 13 | "issues_count" => proc { `issues_count DESC` }, 14 | "recent_issues" => proc { `max_issue_created_at DESC` }, 15 | "favorites" => proc { `favorites_count DESC NULLS LAST` } 16 | } 17 | 18 | auto_struct true 19 | 20 | def by_id(id, with: nil) 21 | query = gems.by_pk(id) 22 | query = query.combine(with) if with 23 | query.one 24 | end 25 | 26 | def by_name(name, with: nil) 27 | query = gems.where(name: name) 28 | query = query.combine(with) if with 29 | query.one 30 | end 31 | 32 | def index(per_page: 20, page: 1, order: "downloads") 33 | base_query 34 | .per_page(per_page) 35 | .page(page) 36 | .order(&SORT[order]) 37 | end 38 | 39 | def by_list(items) 40 | base_query.where(gems[:name] => items) 41 | end 42 | 43 | def random(limit) 44 | base_query.order { `RANDOM()` }.limit(limit) 45 | end 46 | 47 | def user_favorites(user_id) 48 | base_query 49 | .join(favorites) 50 | .where(favorites[:user_id] => user_id) 51 | .order(&SORT["name"]) 52 | end 53 | 54 | private 55 | 56 | def base_query 57 | # plain Sequel datasets for CTE 58 | new_ds = gems.dataset.with(:ic, issues_count).with(:fc, favorites_count) 59 | gems.class.new(new_ds).with(auto_struct: true) 60 | .select_append { `ic.count`.as(:issues_count) } 61 | .select_append { `fc.count`.as(:favorites_count) } 62 | .select_append { `ic.max_created_at`.as(:max_issue_created_at) } 63 | .join(repos) 64 | .join(:ic, {repo_id: repos[:id]}) 65 | .left_join(:fc, {gem_id: gems[:id]}) 66 | .where { pushed_at > DateTime.now - 365 } 67 | .combine(:repo) 68 | end 69 | 70 | def issues_count 71 | container.relations[:issues].dataset.unordered 72 | .select( 73 | :repo_id, 74 | Sequel.function(:count, "*").as(:count), 75 | Sequel.function(:max, :created_at).as(:max_created_at) 76 | ) 77 | .group(:repo_id) 78 | end 79 | 80 | def favorites_count 81 | container.relations[:favorites].dataset.unordered.group_and_count(:gem_id) 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /slices/web/repositories/users_repository.rb: -------------------------------------------------------------------------------- 1 | # memoize: true 2 | # frozen_string_literal: true 3 | 4 | module Web 5 | module Repositories 6 | class UsersRepository < ROM::Repository[:users] 7 | include Deps[container: "persistence.rom"] 8 | 9 | commands :create, update: :by_pk 10 | auto_struct true 11 | 12 | def by_id(id) 13 | users.by_pk(id).one 14 | end 15 | 16 | def auth(auth_hash) 17 | info = auth_hash.info 18 | attrs = { 19 | github_id: auth_hash.uid, 20 | username: info["nickname"], 21 | avatar: info["image"], 22 | email: info["email"] 23 | } 24 | 25 | if (user = users.where(github_id: attrs[:github_id]).one) 26 | update(user.id, attrs) 27 | else 28 | create(attrs) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /slices/web/services/gemfile_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Services 5 | class GemfileParser < ShinyGems::Service 6 | def call(content) 7 | ast = RubyVM::AbstractSyntaxTree.parse(content) 8 | result = seek_gems(ast) 9 | 10 | return Failure(:no_gems_in_gemfile) if result.empty? 11 | 12 | Success(result) 13 | rescue SyntaxError 14 | Failure(:gemfile_parse_failed) 15 | end 16 | 17 | private 18 | 19 | def seek_gems(tree) 20 | if tree.type == :FCALL && tree.children[0] == :gem 21 | return tree.children[1].children[0].children[0] 22 | end 23 | 24 | tree.children.map do |child| 25 | next unless child.is_a?(RubyVM::AbstractSyntaxTree::Node) 26 | 27 | seek_gems(child) 28 | end.compact.flatten 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /slices/web/templates/favorites/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
Favorites
5 |
6 | <% if gems.empty? %> 7 |
8 |
9 |
You don't have any favorites
10 |
Start browsing gems and pick your favorites
11 | Browse all gems 12 |
13 |
14 | <% else %> 15 | <%= render "gems/gems_list" %> 16 | <% end %> 17 |
18 |
19 | -------------------------------------------------------------------------------- /slices/web/templates/gemfile/create.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
Gems from your Gemfile
5 |
6 | <% if gems.empty? %> 7 |
8 |
9 |
Couldn't find any gems from your gemfile in our database
10 |
That's ok, you can still help with any other gem
11 | Browse all gems 12 |
13 |
14 | <% else %> 15 | <%= render "gems/gems_list" %> 16 | <% end %> 17 |
18 |
19 | -------------------------------------------------------------------------------- /slices/web/templates/gems/_gems_list.html.erb: -------------------------------------------------------------------------------- 1 | <% gems.each do |gem| %> 2 | 3 |
4 |
<%= gem.name %>
5 |

<%= gem.description %>

6 |
7 | 35 |
36 | <% end %> 37 | -------------------------------------------------------------------------------- /slices/web/templates/gems/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
Browse gems
6 |
7 | 8 | 9 | 12 |
13 |
14 |
15 |
16 | Sort by: 17 | 25 |
26 |
27 |
28 | <%= render :gems_list %> 29 |
30 | 45 |
46 |
47 |
48 | -------------------------------------------------------------------------------- /slices/web/templates/gems/show.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | <%= current_gem.name %> 6 |
7 |
8 | <%= current_gem.description %> 9 |
10 |
11 | <% if current_gem.repo.gems.size > 1 %> 12 |
13 |

Monorepo!

14 |

ShinyGems detected multiple gems in GitHub repository, which means listed issues may not correspond to current gem.

15 |
16 | <% end %> 17 |
18 |
19 |
20 | <% current_gem.repo.issues.each do |issue| %> 21 | 22 |
23 | <%= issue.title %> 24 |
25 |
26 | <% issue.labels.each do |label| %> 27 | <%= label.name %> 28 | <% end %> 29 |
30 | 31 | <%= issue.created_at.to_date %> · <%= issue.comments %> comment(s) 32 | 33 |
34 | <% end %> 35 | 36 | <% if current_gem.repo.issues.empty? %> 37 |
38 |
There are no pending issues
39 |
40 | <% end %> 41 |
42 |
43 |
44 |
45 |
46 |
47 | <%= total_favorites %> 48 |
49 | users added this gem as favorite 50 | <% if favorite %> 51 | including you 52 |
53 | 54 | 55 |
56 | <% else %> 57 |
58 | 59 | 60 |
61 | <% end %> 62 |
63 |
64 |
65 |
66 |
67 |
RubyGems
68 |
69 | <%= current_gem.name %> 70 |
71 | 72 |
GitHub Repository
73 |
74 | <%= current_gem.repo.name %> 75 |
76 | 77 |
GitHub Stars
78 |
79 | <%= current_gem.repo.stars %> 80 |
81 | 82 |
RubyGems Downloads
83 |
84 | <%= current_gem.downloads %> 85 |
86 | 87 |
Current Version
88 |
89 | <%= current_gem.version %> 90 |
91 | 92 |
License
93 |
94 | <%= current_gem.license %> 95 |
96 | 97 |
Number of issues
98 |
99 | <%= current_gem.repo.issues.size %> 100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 | -------------------------------------------------------------------------------- /slices/web/templates/layouts/_flash.html.erb: -------------------------------------------------------------------------------- 1 | <% unless flash.empty? %> 2 |
3 | <% flash.each do |type, content| %> 4 | 11 | <% end %> 12 |
13 | <% end %> 14 | -------------------------------------------------------------------------------- /slices/web/templates/layouts/_footer.html.erb: -------------------------------------------------------------------------------- 1 | 60 | -------------------------------------------------------------------------------- /slices/web/templates/layouts/_navbar.html.erb: -------------------------------------------------------------------------------- 1 | 40 | -------------------------------------------------------------------------------- /slices/web/templates/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= seo_title %> 5 | <%= stylesheet_tag "app" %> 6 | 7 | 8 | 9 | 10 | <%= favicon_tag %> 11 | 12 | 13 | <%= render :flash %> 14 |
15 | <%= render :navbar %> 16 | <%= yield %> 17 | <%= render :footer %> 18 |
19 | <%= javascript_tag "app" %> 20 | 21 | 22 | -------------------------------------------------------------------------------- /slices/web/templates/pages/error.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
<%= code %>
4 |
<%= msg %>
5 |
6 | Homepage 7 |
8 |
9 |
10 | -------------------------------------------------------------------------------- /slices/web/templates/pages/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | Help maintain your favorite gems and make them shine ✨ 6 |
7 |
8 | Make your first steps in open source.
Start browsing gems and help with their development. 9 |
10 |
11 | Browse gems 12 |
13 |
14 | or pick random one 15 |
16 |
17 |
18 |
19 | <% random_gems.each do |gem| %> 20 | 30 | <% end %> 31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
How does it work?
42 |

43 | With ShinyGems you can help ruby community develop better software. Many maintainers work on gems in their 44 | spare time and don't have enough capacity to take care of all the issues or develop new features. ShinyGems 45 | pulls data from RubyGems and GitHub to generate a list of gems you can help with. 46 |

47 |
48 |
49 |
50 |
51 | 52 |
At least 1 mln downloads
53 |
54 |
55 | 56 |
Commits in the last 365 days
57 |
58 |
59 | 60 |
Only "help wanted" issues
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
Browse gems or upload Gemfile
70 |

71 | Browse gems or upload your Gemfile to see if there are any gems you are using that require help. 72 |

73 |
74 |
75 |
76 | <%= image_tag "1.png", class: "w-100", alt: "Browse gems or upload Gemfile" %> 77 |
78 |
79 |
80 |
81 |
82 |
Pick issue and work on it
83 |

84 | ShinyGems provides list of issues you can help with. These may be bugs or new features waiting to be 85 | implemented. 86 |

87 |
88 |
89 |
90 | <%= image_tag "2.png", class: "w-100", alt: "Pick issue and work on it" %> 91 |
92 |
93 |
94 |
95 |
96 |
Make your favourite gems better!
97 |

98 | When you are done open pull request on GitHub, wait for approval and pop a beer, you deserve it! 99 |

100 |
101 |
102 |
103 | <%= image_tag "3.png", class: "w-100", alt: "Make your favourite gems better!" %> 104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 | Are you ready to start? 🚀 113 |
114 | Browse gems 115 |
116 |
117 | -------------------------------------------------------------------------------- /slices/web/templates/pages/not_authorized.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
Sign in
4 |
You need to sign in to see this page.
5 |
6 |
7 | 8 | 12 |
13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /slices/web/templates/pages/privacy.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
Privacy Policy
4 | At ShinyGems, we are committed to protecting your privacy. This Privacy Policy outlines the type of personal data we 5 | collect, how we use it, and how we protect your information, in compliance with the General Data Protection 6 | Regulation (GDPR). 7 | 8 |
Website Owner
9 | The ShinyGems website is owned and operated by Karol Bąk, ul. Pszczelna 32/31, 30-409 Cracow, Poland. 10 | 11 |
Data Collection
12 | We collect the following types of data: 13 |
    14 |
  1. 15 | Anonymous analytics data: We use cookies and other tracking technologies to collect anonymous analytics data. 16 | This data includes information about your device, browser, and how you interact with the website. We use this data 17 | to improve our website and provide a better user experience. 18 |
  2. 19 |
  3. 20 | Email addresses: We collect email addresses when you sign up. We use this 21 | information to send you notifications that you can opt-in or opt-out anytime. We 22 | won't use your email address for marketing emails unless you have provided consent. 23 |
  4. 24 |
  5. 25 | Github usernames: We collect Github usernames for authorization purposes. 26 | We do not use this information for any other purpose. 27 |
  6. 28 |
29 | 30 |
Data Usage
31 | We use the data we collect for the following purposes: 32 |
    33 |
  1. 34 | To improve our website: We use anonymous analytics data to improve our website and provide a better user 35 | experience. 36 |
  2. 37 |
  3. 38 | To communicate with our users: We use email addresses to send you notifications about our products and services. 39 |
  4. 40 |
  5. 41 | For authorization: We use Github usernames for authorization purposes. 42 |
  6. 43 |
44 | 45 |
Data Access and Deletion
46 | You have the right to access and delete your personal data that we have collected. If you would like to access or 47 | delete your data, please contact us at hello@shinygems.dev. 48 | 49 |
Data Protection
50 | We take appropriate technical and organizational measures to ensure the security of your personal data against 51 | unauthorized access, alteration, disclosure, or destruction. 52 | 53 |
Changes to this Privacy Policy
54 | We may update this Privacy Policy from time to time. Any changes will be posted on this page, and the date of the 55 | latest update will be indicated at the top of the page. 56 |
57 |
58 | -------------------------------------------------------------------------------- /slices/web/view.rb: -------------------------------------------------------------------------------- 1 | # auto_register: false 2 | # frozen_string_literal: true 3 | 4 | module Web 5 | class View < Hanami::View 6 | DEFAULT_DESCRIPTION = "Help maintain your favorite gems and make them shine ✨" 7 | 8 | setting :title 9 | setting :description 10 | 11 | config.paths = [File.join(__dir__, "templates")] 12 | config.layout = "application" 13 | config.part_namespace = Views::Parts 14 | 15 | expose :seo_title, layout: true do 16 | config.title || "ShinyGems" 17 | end 18 | 19 | expose :seo_description, layout: true do 20 | config.description || DEFAULT_DESCRIPTION 21 | end 22 | 23 | expose :current_user, layout: true 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /slices/web/views/favorites/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Views 5 | module Favorites 6 | class Index < Web::View 7 | config.template = "favorites/index" 8 | config.title = "Favorites - ShinyGems" 9 | 10 | expose :gems 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /slices/web/views/gemfile/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Views 5 | module Gemfile 6 | class Create < Web::View 7 | config.template = "gemfile/create" 8 | config.title = "Gems from your Gemfile - ShinyGems" 9 | 10 | expose :gems 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /slices/web/views/gems/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Views 5 | module Gems 6 | class Index < Web::View 7 | config.template = "gems/index" 8 | config.title = "Browse gems - ShinyGems" 9 | 10 | expose :gems, :pager, :sort_by 11 | 12 | expose :pages, decorate: false do |pager:| 13 | total = pager.total_pages 14 | total.times.map { |i| i + 1 }.map do |page| 15 | next :gap unless page == 1 || page == total || page.between?(pager.current_page - 1, pager.current_page + 1) 16 | page 17 | end.chunk(&:itself).map(&:first) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /slices/web/views/gems/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Views 5 | module Gems 6 | class Show < Web::View 7 | config.template = "gems/show" 8 | 9 | expose :current_gem, as: :gem 10 | expose :total_favorites 11 | expose :favorite 12 | 13 | expose :seo_title, layout: true do |current_gem:| 14 | "#{current_gem.name} - ShinyGems" 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /slices/web/views/pages/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Views 5 | module Pages 6 | class Error < Web::View 7 | config.template = "pages/error" 8 | config.title = "Error - ShinyGems" 9 | 10 | expose :code 11 | expose :msg do |code:| 12 | Hanami::Http::Status.message_for(code) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /slices/web/views/pages/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Views 5 | module Pages 6 | class Index < Web::View 7 | config.template = "pages/index" 8 | config.title = "ShinyGems - Help maintain your favourite gems and make them shine" 9 | 10 | expose :random_gems 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /slices/web/views/pages/not_authorized.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Views 5 | module Pages 6 | class NotAuthorized < Web::View 7 | config.template = "pages/not_authorized" 8 | config.title = "Sign in - ShinyGems" 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /slices/web/views/pages/privacy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Views 5 | module Pages 6 | class Privacy < Web::View 7 | config.template = "pages/privacy" 8 | config.title = "Privacy Policy - ShinyGems" 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /slices/web/views/parts/gem.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Views 5 | module Parts 6 | class Gem < Hanami::View::Part 7 | include Deps["formatter"] 8 | 9 | decorate :repo 10 | 11 | def downloads 12 | formatter.separator(value.downloads) 13 | end 14 | 15 | def url 16 | "https://rubygems.org/gems/#{value.name}" 17 | end 18 | 19 | def license 20 | value.licenses&.join(", ") 21 | end 22 | 23 | def favorites_count 24 | formatter.separator(value.favorites_count || 0) 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /slices/web/views/parts/issue.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Views 5 | module Parts 6 | class Issue < Hanami::View::Part 7 | decorate :labels 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /slices/web/views/parts/label.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Views 5 | module Parts 6 | class Label < Hanami::View::Part 7 | def name 8 | value["name"] 9 | end 10 | 11 | def bg_color 12 | "##{value["color"]}" 13 | end 14 | 15 | def bg_light? 16 | r, g, b = value["color"].chars.each_slice(2).map(&:join).map(&:hex) 17 | (0.2126 * r) + (0.7152 * g) + (0.0722 * b) >= 128 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /slices/web/views/parts/repo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Web 4 | module Views 5 | module Parts 6 | class Repo < Hanami::View::Part 7 | include Deps["formatter"] 8 | 9 | decorate :issues 10 | 11 | def stars 12 | formatter.separator(value.stars) 13 | end 14 | 15 | def url 16 | "https://github.com/#{value.name}" 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/features/auth_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.feature "auth", type: :feature, js: true do 4 | scenario "sign in and out" do 5 | visit "/" 6 | expect(page).to have_content("ShinyGems") 7 | click_button "Sign in with GitHub" 8 | 9 | expect(page).to have_content("Successfully signed in") 10 | expect(page).to have_content("Logged as test") 11 | expect(page).not_to have_content("Sign in with GitHub") 12 | 13 | find('a[aria-label="sign out"]').click 14 | expect(page).to have_content("Successfully signed out") 15 | expect(page).to have_content("Sign in with GitHub") 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/features/browse_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.feature "browse", type: :feature, js: true do 4 | let!(:gem) { Factory[:gem, repo: Factory[:repo, name: "my/repo"], downloads: 10, name: "a-special-gem"] } 5 | 6 | before do 7 | 30.times do 8 | Factory[:gem] 9 | end 10 | end 11 | 12 | scenario "browse gems" do 13 | visit "/gems" 14 | expect(page).to have_content("Browse gems") 15 | expect(page).to have_no_content(gem.name) 16 | 17 | click_link("2", class: "page-link") 18 | expect(page).to have_content(gem.name) 19 | 20 | click_link(gem.name) 21 | expect(page).to have_content("Some issue") 22 | 23 | expect(page).to have_content("GitHub Repository\nmy/repo") 24 | expect(page).to have_content("GitHub Stars\n1,521") 25 | expect(page).to have_content("RubyGems Downloads\n10") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/features/favorites_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.feature "favorites", type: :feature, js: true do 4 | let!(:gem) { Factory[:gem, repo: Factory[:repo, name: "my/repo"], downloads: 10, name: "a-special-gem"] } 5 | 6 | scenario "add and remove from favorites" do 7 | visit "/" 8 | 9 | within ".navbar" do 10 | click_link "Favorites" 11 | end 12 | 13 | expect(page).to have_content("You need to sign in to see this page.") 14 | click_button "Sign in with GitHub", class: "btn-primary" 15 | 16 | expect(page).to have_content("Successfully signed in") 17 | expect(page).to have_content("Logged as test") 18 | within ".navbar" do 19 | click_link "Favorites" 20 | end 21 | 22 | expect(page).to have_content("You don't have any favorites") 23 | click_link "Browse all gems" 24 | 25 | expect(page).to have_content("a-special-gem") 26 | click_link("a-special-gem") 27 | 28 | expect(page).to have_content("Some issue") 29 | click_button("Add to favorites") 30 | 31 | within ".navbar" do 32 | click_link "Favorites" 33 | end 34 | expect(page).to have_content("Favorites") 35 | expect(page).to have_content("a-special-gem") 36 | click_link("a-special-gem") 37 | 38 | expect(page).to have_content("Some issue") 39 | click_button("Remove from favorites") 40 | 41 | within ".navbar" do 42 | click_link "Favorites" 43 | end 44 | expect(page).to have_content("You don't have any favorites") 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/features/gemfile_upload_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.feature "gemfile upload", type: :feature, js: true do 4 | let!(:gem) { Factory[:gem, name: "hanami", repo: Factory[:repo, name: "my/repo"]] } 5 | let!(:other_gem) { Factory[:gem] } 6 | 7 | scenario "browse gems by uploaded gemfile" do 8 | visit "/gems" 9 | expect(page).to have_content("Browse gems") 10 | expect(page).to have_content(gem.name) 11 | expect(page).to have_content(other_gem.name) 12 | 13 | attach_file("gemfile", "#{SPEC_ROOT}/support/files/Gemfile.test", visible: false) 14 | 15 | expect(page).to have_content("Gems from your Gemfile") 16 | expect(page).to have_content(gem.name) 17 | expect(page).to have_no_content(other_gem.name) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/features/processing_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.feature "processing", type: :feature, js: true do 4 | around do |example| 5 | Sidekiq::Testing.inline! do 6 | example.call 7 | end 8 | end 9 | 10 | let(:rubygems_data) do 11 | { 12 | name: "some_gem", 13 | source_code_uri: "https://github.com/some/some_gem", 14 | info: "Just a sample gem", 15 | downloads: 1_526_321, 16 | version: "2.0.0", 17 | licenses: ["MIT"] 18 | } 19 | end 20 | 21 | let(:github_data) do 22 | { 23 | full_name: "some/some_gem", 24 | stargazers_count: 1_234, 25 | pushed_at: Time.now.to_s 26 | } 27 | end 28 | 29 | let(:issues) do 30 | [ 31 | { 32 | id: 1234, 33 | title: "Sample issue", 34 | html_url: "https://github.com/some/some_gem/issues/1", 35 | comments: 5, 36 | created_at: "2023-01-26T19:06:43Z", 37 | labels: [{name: "help wanted", color: "333333"}] 38 | }, 39 | { 40 | id: 1235, 41 | title: "Another issue", 42 | html_url: "https://github.com/some/some_gem/issues/2", 43 | comments: 10, 44 | created_at: "2023-01-26T19:06:43Z", 45 | labels: [{name: "help wanted", color: "333333"}] 46 | } 47 | ] 48 | end 49 | 50 | before do 51 | stub_request(:get, "https://rubygems.org/api/v1/search.json?page=1&query=downloads:%3E1000000") 52 | .to_return(status: 200, body: [rubygems_data].to_json) 53 | stub_request(:get, "https://rubygems.org/api/v1/search.json?page=2&query=downloads:%3E1000000") 54 | .to_return(status: 200, body: [].to_json) 55 | stub_request(:get, "https://rubygems.org/api/v1/gems/some_gem.json") 56 | .to_return(status: 200, body: rubygems_data.to_json) 57 | stub_request(:get, "https://api.github.com/repos/some/some_gem") 58 | .to_return(status: 200, body: github_data.to_json, headers: {"Content-Type" => "application/json; charset=utf-8"}) 59 | stub_request(:get, "https://api.github.com/repos/some/some_gem/issues?labels=help%20wanted&per_page=100&state=open") 60 | .to_return(status: 200, body: issues.to_json, headers: {"Content-Type" => "application/json; charset=utf-8"}) 61 | end 62 | 63 | scenario "discover and sync data" do 64 | ::Processing::Workers::DiscoverWorker.perform_async 65 | 66 | visit "/" 67 | within ".intro" do 68 | click_link "Browse gems" 69 | end 70 | 71 | expect(page).to have_content("some_gem") 72 | expect(page).to have_content("Just a sample gem") 73 | 74 | click_link "some_gem" 75 | 76 | expect(page).to have_content("some_gem") 77 | expect(page).to have_content("Just a sample gem") 78 | expect(page).to have_content("Sample issue") 79 | expect(page).to have_content("Another issue") 80 | expect(page).to have_content("RubyGems Downloads 1,526,321", normalize_ws: true) 81 | expect(page).to have_content("GitHub Stars 1,234", normalize_ws: true) 82 | expect(page).to have_content("GitHub Repository some/some_gem", normalize_ws: true) 83 | expect(page).to have_content("Current Version 2.0.0", normalize_ws: true) 84 | expect(page).to have_content("License MIT", normalize_ws: true) 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/slices/processing/repositories/gems_repository_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Processing::Repositories::GemsRepository, type: :database do 4 | subject(:repo) { described_class.new } 5 | 6 | describe "#by_id" do 7 | let!(:gem) { Factory[:gem] } 8 | 9 | context "gem exists" do 10 | it "returns gem" do 11 | expect(repo.by_id(gem.id)).to match_entity(gem) 12 | end 13 | end 14 | 15 | context "gem doesnt exist" do 16 | it "returns nil" do 17 | expect(repo.by_id(gem.id + 3)).to be_nil 18 | end 19 | end 20 | 21 | context "when 'with' present" do 22 | it "returns gem with associations" do 23 | result = repo.by_id(gem.id, with: [:repo]) 24 | expect(result.name).to eq(gem.name) 25 | expect(result.repo).to eq(gem.repo) 26 | end 27 | end 28 | end 29 | 30 | describe "#pluck_ids_for_hour" do 31 | let!(:gem1) { Factory[:gem, id: 1] } 32 | let!(:gem2) { Factory[:gem, id: 2] } 33 | let!(:gem3) { Factory[:gem, id: 25] } 34 | 35 | it "returns ids as array" do 36 | expect(subject.pluck_ids_for_hour(1)).to eq([gem1.id, gem3.id]) 37 | end 38 | end 39 | 40 | describe "#pluck_name_by_list" do 41 | let!(:gem1) { Factory[:gem] } 42 | let!(:gem2) { Factory[:gem] } 43 | 44 | it "returns names of existing gems" do 45 | expect(subject.pluck_name_by_list(["some_name", gem2.name])).to eq([gem2.name]) 46 | end 47 | end 48 | 49 | describe "#replace_repo" do 50 | let!(:gem1) { Factory[:gem] } 51 | let!(:gem2) { Factory[:gem] } 52 | let!(:new_repo) { Factory[:repo] } 53 | 54 | it "replaces repos for proper gems" do 55 | subject.replace_repo(gem1.repo_id, new_repo.id) 56 | 57 | expect(repo.by_id(gem1.id).repo_id).to eq(new_repo.id) 58 | expect(repo.by_id(gem2.id).repo_id).not_to eq(new_repo.id) 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/slices/processing/repositories/repos_repository_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Processing::Repositories::ReposRepository, type: :database do 4 | subject(:repository) { described_class.new } 5 | 6 | describe "#by_id" do 7 | let!(:repo) { Factory[:repo, issues: []] } 8 | 9 | context "repo exists" do 10 | it "returns gem" do 11 | expect(repository.by_id(repo.id)).to match_entity(repo) 12 | end 13 | end 14 | 15 | context "repo doesnt exist" do 16 | it "returns nil" do 17 | expect(repository.by_id(repo.id + 3)).to be_nil 18 | end 19 | end 20 | 21 | context "when 'with' present" do 22 | let!(:issue) { Factory[:issue, repo_id: repo.id] } 23 | 24 | it "returns repo with associations" do 25 | result = repository.by_id(repo.id, with: [:issues]) 26 | expect(result.name).to eq(repo.name) 27 | expect(result.issues).to match([match_entity(issue)]) 28 | end 29 | end 30 | end 31 | 32 | describe "#pluck_ids" do 33 | let!(:repo1) { Factory[:repo, id: 1] } 34 | let!(:repo2) { Factory[:repo, id: 2] } 35 | let!(:repo3) { Factory[:repo, id: 25] } 36 | 37 | it "returns ids as array" do 38 | expect(subject.pluck_ids_for_hour(1)).to eq([repo1.id, repo3.id]) 39 | end 40 | end 41 | 42 | describe "#find_or_create" do 43 | context "already exists" do 44 | let!(:repo) { Factory[:repo] } 45 | 46 | it "returns repo" do 47 | expect(subject.find_or_create({name: repo.name})).to match(match_entity(repo)) 48 | end 49 | 50 | it "doesn't create new repo" do 51 | expect { subject.find_or_create({name: repo.name}) }.not_to change { Hanami.app["persistence.rom"].relations[:repos].count } 52 | end 53 | end 54 | 55 | context "doesnt exist" do 56 | it "returns repo" do 57 | expect(subject.find_or_create({name: "name"})).to have_attributes(name: "name", id: kind_of(Integer)) 58 | end 59 | 60 | it "creates new repo" do 61 | expect { subject.find_or_create({name: "name"}) }.to change { Hanami.app["persistence.rom"].relations[:repos].count }.by(1) 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/slices/processing/services/discover_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Processing::Services::Discover do 4 | let(:fake_gems_repository) { fake_repository(:processing, :gems) } 5 | let(:fake_repos_repository) { fake_repository(:processing, :repos) } 6 | let(:fake_list_fetcher) { instance_double(Processing::Services::Rubygems::ListFetcher) } 7 | let(:instance) { described_class.new(list_fetcher: fake_list_fetcher, gems_repository: fake_gems_repository, repos_repository: fake_repos_repository) } 8 | 9 | subject { instance.call } 10 | 11 | context "no gems returned from API" do 12 | before { allow(fake_list_fetcher).to receive(:call).with(page: 1).and_return(Dry::Monads::Success([])) } 13 | 14 | it "returns failure" do 15 | expect(subject).to eq(Dry::Monads::Failure(:no_results)) 16 | end 17 | end 18 | 19 | context "gems returned from API" do 20 | let(:existing_gem) { Factory.structs[:gem] } 21 | let(:existing_repo) { Factory.structs[:repo, name: "some/repo"] } 22 | let(:gems_list) do 23 | [ 24 | {"name" => existing_gem.name, "homepage_uri" => "https://github.com/some/repo"}, 25 | {"name" => "gem_with_existing_repo", "homepage_uri" => "https://github.com/some/repo"}, 26 | {"name" => "new_gem_without_repo", "homepage_uri" => ""} 27 | ] 28 | end 29 | 30 | before do 31 | allow(fake_list_fetcher).to receive(:call).with(page: 1).and_return(Dry::Monads::Success(gems_list)) 32 | allow(fake_gems_repository).to receive(:pluck_name_by_list).with([existing_gem.name, "gem_with_existing_repo", "new_gem_without_repo"]) 33 | .and_return([existing_gem.name]) 34 | allow(fake_gems_repository).to receive(:transaction).and_yield 35 | allow(fake_gems_repository).to receive(:create) 36 | allow(fake_repos_repository).to receive(:find_or_create).with({name: "some/repo"}) 37 | .and_return(existing_repo) 38 | allow(fake_gems_repository).to receive(:create).with(hash_including(name: "gem_with_existing_repo", repo_id: existing_repo.id)) 39 | .and_return(Factory.structs[:gem, name: "gem_with_existing_repo"]) 40 | end 41 | 42 | it "ignores existing gem" do 43 | expect(fake_gems_repository).not_to receive(:create).with(hash_including(name: existing_gem.name)) 44 | subject 45 | end 46 | 47 | it "creates missing gem" do 48 | expect(fake_gems_repository).to receive(:create).with(hash_including(name: "gem_with_existing_repo", repo_id: existing_repo.id)) 49 | subject 50 | end 51 | 52 | it "ignores gem without repo" do 53 | expect(fake_gems_repository).not_to receive(:create).with(hash_including(name: "new_gem_without_repo")) 54 | subject 55 | end 56 | 57 | it "returns Success with list of created gems" do 58 | expect(subject.success?).to be_truthy 59 | expect(subject.value!).to match([having_attributes(name: "gem_with_existing_repo")]) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/slices/processing/services/github/issues_list_fetcher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Processing::Slice.prepare(:octokit) 4 | 5 | RSpec.describe Processing::Services::Github::IssuesListFetcher do 6 | let(:fake_octokit) { instance_double(Octokit::Client) } 7 | let(:fake_response) do 8 | [ 9 | {id: 231}, 10 | {id: 233, pull_request: {id: 123}} 11 | ] 12 | end 13 | 14 | context "everything is ok" do 15 | subject { described_class.new(octokit: fake_octokit).call("some/repo") } 16 | 17 | before do 18 | allow(fake_octokit).to receive(:list_issues).with("some/repo", state: "open", labels: "help wanted") 19 | .and_return(fake_response) 20 | end 21 | 22 | it "fetches data from octokit and returns issues without PRs" do 23 | expect(subject.success?).to be_truthy 24 | expect(subject.value!).to eq([{id: 231}]) 25 | end 26 | end 27 | 28 | context "api error" do 29 | subject { described_class.new(octokit: fake_octokit).call("some/repo") } 30 | 31 | before do 32 | allow(fake_octokit).to receive(:list_issues).with("some/repo", state: "open", labels: "help wanted") 33 | .and_raise(Octokit::NotFound) 34 | end 35 | 36 | it "returns failure" do 37 | expect(subject.success?).to be_falsey 38 | expect(subject.failure).to be_instance_of(Octokit::NotFound) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/slices/processing/services/github/repo_fetcher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Processing::Slice.prepare(:octokit) 4 | 5 | RSpec.describe Processing::Services::Github::RepoFetcher do 6 | let(:fake_octokit) { instance_double(Octokit::Client) } 7 | let(:fake_response) do 8 | {full_name: "some/repo", name: "repo"} 9 | end 10 | 11 | subject { described_class.new(octokit: fake_octokit).call("some/repo") } 12 | 13 | context "everything is ok" do 14 | before do 15 | allow(fake_octokit).to receive(:repo).with("some/repo").and_return(fake_response) 16 | end 17 | 18 | it "fetches data from octokit and returns repo data" do 19 | expect(subject.success?).to be_truthy 20 | expect(subject.value!).to eq(fake_response) 21 | end 22 | end 23 | 24 | context "api error" do 25 | before do 26 | allow(fake_octokit).to receive(:repo).with("some/repo").and_raise(Octokit::NotFound) 27 | end 28 | 29 | it "returns failure" do 30 | expect(subject.success?).to be_falsey 31 | expect(subject.failure).to be_instance_of(Octokit::NotFound) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/slices/processing/services/issues_syncer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Processing::Services::IssuesSyncer do 4 | let(:fake_issues_repository) do 5 | fake_repository(:processing, :issues) do |repository| 6 | allow(repository).to receive(:transaction).and_yield 7 | allow(repository).to receive(:update).with(any_args) 8 | allow(repository).to receive(:delete).with(any_args) 9 | allow(repository).to receive(:create).with(any_args) 10 | end 11 | end 12 | 13 | let!(:existing_issue1) { Factory.structs[:issue, github_id: 100] } 14 | let!(:existing_issue2) { Factory.structs[:issue, github_id: 101] } 15 | let(:fake_issues_list_fetcher) { instance_double(Processing::Services::Github::IssuesListFetcher) } 16 | let(:repo) { Factory.structs[:repo, issues: [existing_issue1, existing_issue2]] } 17 | let(:fake_gh_list) do 18 | [ 19 | { 20 | id: 100, 21 | title: "Issue1", 22 | comments: 5, 23 | html_url: "repo/issues/1", 24 | created_at: DateTime.new(2011, 4, 22, 13, 33, 48, 0), 25 | labels: [{name: "test", color: "324532"}] 26 | }, 27 | { 28 | id: 103, 29 | title: "Issue4", 30 | comments: 65, 31 | html_url: "repo/issues/4", 32 | created_at: DateTime.new(2011, 4, 22, 13, 33, 48, 0), 33 | labels: [] 34 | } 35 | ] 36 | end 37 | 38 | before do 39 | allow(fake_issues_list_fetcher).to receive(:call).with(repo.name).and_return(Dry::Monads::Success(fake_gh_list)) 40 | end 41 | 42 | subject do 43 | described_class.new(issues_list_fetcher: fake_issues_list_fetcher, issues_repository: fake_issues_repository).call(repo) 44 | end 45 | 46 | it "returns success" do 47 | expect(subject.success?).to be_truthy 48 | end 49 | 50 | it "removes closed issues" do 51 | expect(fake_issues_repository).to receive(:delete).with(existing_issue2.id) 52 | subject 53 | end 54 | 55 | it "updates open issues on the list" do 56 | expect(fake_issues_repository).to receive(:update).with(existing_issue1.id, { 57 | title: "Issue1", 58 | comments: 5, 59 | url: "repo/issues/1", 60 | github_id: 100, 61 | created_at: DateTime.new(2011, 4, 22, 13, 33, 48, 0), 62 | labels: [{name: "test", color: "324532"}] 63 | }) 64 | subject 65 | end 66 | 67 | it "creates open issues on the list when not present in DB" do 68 | expect(fake_issues_repository).to receive(:create).with({ 69 | title: "Issue4", 70 | comments: 65, 71 | url: "repo/issues/4", 72 | repo_id: repo.id, 73 | github_id: 103, 74 | created_at: DateTime.new(2011, 4, 22, 13, 33, 48, 0), 75 | labels: [] 76 | }) 77 | subject 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/slices/processing/services/repo_syncer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Processing::Services::RepoSyncer do 4 | let(:fake_repo_fetcher) { instance_double(Processing::Services::Github::RepoFetcher) } 5 | let(:fake_repos_repository) { instance_double(Processing::Repositories::ReposRepository) } 6 | let(:fake_gems_repository) { instance_double(Processing::Repositories::GemsRepository) } 7 | let(:repo) { Factory.structs[:repo, name: "some/some_gem"] } 8 | 9 | subject do 10 | described_class.new( 11 | repo_fetcher: fake_repo_fetcher, 12 | repos_repository: fake_repos_repository, 13 | gems_repository: fake_gems_repository 14 | ).call(repo) 15 | end 16 | 17 | context "one of services returns failure" do 18 | before do 19 | allow(fake_repo_fetcher).to receive(:call).with("some/some_gem").and_return(Dry::Monads::Failure(:error)) 20 | end 21 | 22 | it "returns failure" do 23 | expect(subject.success?).to be_falsey 24 | expect(subject.failure).to eq(:error) 25 | end 26 | end 27 | 28 | context "repo name didn't change" do 29 | let(:github_info) do 30 | { 31 | stargazers_count: 50000, 32 | full_name: "some/some_gem", 33 | pushed_at: DateTime.new(2022, 12, 12, 12, 0, 0) 34 | } 35 | end 36 | let(:expected_attributes) do 37 | { 38 | stars: 50000, 39 | pushed_at: DateTime.new(2022, 12, 12, 12, 0, 0) 40 | } 41 | end 42 | 43 | before do 44 | allow(fake_repo_fetcher).to receive(:call).with("some/some_gem").and_return(Dry::Monads::Success(github_info)) 45 | allow(fake_repos_repository).to receive(:update).with(repo.id, expected_attributes).and_return(repo) 46 | end 47 | 48 | it "returns saved repo" do 49 | expect(subject.success?).to be_truthy 50 | expect(subject.value!).to eq(repo) 51 | end 52 | end 53 | 54 | context "repo name changed" do 55 | let(:existing_repo) { Factory.structs[:repo, name: "new/some_gem"] } 56 | let(:github_info) do 57 | { 58 | stargazers_count: 50000, 59 | full_name: "new/some_gem", 60 | pushed_at: DateTime.new(2022, 12, 12, 12, 0, 0) 61 | } 62 | end 63 | let(:expected_attributes) do 64 | { 65 | stars: 50000, 66 | pushed_at: DateTime.new(2022, 12, 12, 12, 0, 0) 67 | } 68 | end 69 | 70 | before do 71 | allow(fake_repo_fetcher).to receive(:call).with("some/some_gem").and_return(Dry::Monads::Success(github_info)) 72 | allow(fake_repos_repository).to receive(:transaction).and_yield 73 | end 74 | 75 | it "replaces old repo with new one and returns new one" do 76 | expect(fake_repos_repository).to receive(:find_or_create).with({name: "new/some_gem"}).and_return(existing_repo) 77 | expect(fake_gems_repository).to receive(:replace_repo).with(repo.id, existing_repo.id) 78 | expect(fake_repos_repository).to receive(:delete).with(repo.id) 79 | expect(fake_repos_repository).to receive(:update).with(existing_repo.id, expected_attributes).and_return(existing_repo) 80 | 81 | expect(subject.success?).to be_truthy 82 | expect(subject.value!).to eq(existing_repo) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/slices/processing/services/rubygems/gem_fetcher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Processing::Slice.prepare(:gems_api) 4 | 5 | RSpec.describe Processing::Services::Rubygems::GemFetcher do 6 | subject { described_class.new(gems_api: fake_gem_api).call("some_gem") } 7 | 8 | let(:fake_gem_api) { class_double(::Gems) } 9 | 10 | context "API returned data" do 11 | before { allow(fake_gem_api).to receive(:info).with("some_gem").and_return({"some" => "data"}) } 12 | 13 | it "returns data from API" do 14 | expect(subject.success?).to be_truthy 15 | expect(subject.value!).to eq({"some" => "data"}) 16 | end 17 | end 18 | 19 | context "API returned error" do 20 | before { allow(fake_gem_api).to receive(:info).with("some_gem").and_raise(::Gems::NotFound) } 21 | 22 | it "returns failure" do 23 | expect(subject.success?).to be_falsey 24 | expect(subject.failure).to be_instance_of(Gems::NotFound) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/slices/processing/services/rubygems/list_fetcher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Processing::Slice.prepare(:gems_api) 4 | 5 | RSpec.describe Processing::Services::Rubygems::ListFetcher do 6 | subject { described_class.new(gems_api: fake_gem_api).call(page: 2) } 7 | 8 | let(:fake_gem_api) { class_double(::Gems) } 9 | 10 | context "API returned data" do 11 | before { allow(fake_gem_api).to receive(:search).with("downloads:>1000000", {page: 2}).and_return({"some" => "data"}) } 12 | 13 | it "returns data from API" do 14 | expect(subject.success?).to be_truthy 15 | expect(subject.value!).to eq({"some" => "data"}) 16 | end 17 | end 18 | 19 | context "API returned error" do 20 | before { allow(fake_gem_api).to receive(:search).with("downloads:>1000000", {page: 2}).and_raise(::Gems::NotFound) } 21 | 22 | it "returns failure" do 23 | expect(subject.success?).to be_falsey 24 | expect(subject.failure).to be_instance_of(Gems::NotFound) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/slices/processing/services/syncer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Processing::Services::Syncer do 4 | let(:fake_rubygems_fetcher) { instance_double(Processing::Services::Rubygems::GemFetcher) } 5 | let(:fake_gem_repo) { instance_double(Processing::Repositories::GemsRepository) } 6 | let(:gem) { Factory.structs[:gem, name: "some_gem"] } 7 | let(:gem_info) do 8 | { 9 | "name" => "some_gem", 10 | "info" => "some description", 11 | "downloads" => 50000, 12 | "homepage_uri" => "https://github.com/test/some_gem", 13 | "version" => "2.0.0", 14 | "licenses" => ["MIT"] 15 | } 16 | end 17 | let(:expected_attributes) do 18 | { 19 | description: "some description", 20 | downloads: 50000, 21 | licenses: ["MIT"], 22 | version: "2.0.0" 23 | } 24 | end 25 | 26 | subject do 27 | described_class.new( 28 | gem_fetcher: fake_rubygems_fetcher, 29 | gems_repository: fake_gem_repo 30 | ).call(gem) 31 | end 32 | 33 | context "one of services returns failure" do 34 | before do 35 | allow(fake_rubygems_fetcher).to receive(:call).with("some_gem").and_return(Dry::Monads::Failure(:gem_info_fetch_failed)) 36 | end 37 | 38 | it "returns failure" do 39 | expect(subject.success?).to be_falsey 40 | expect(subject.failure).to eq(:gem_info_fetch_failed) 41 | end 42 | end 43 | 44 | context "everything goes ok" do 45 | before do 46 | allow(fake_rubygems_fetcher).to receive(:call).with("some_gem").and_return(Dry::Monads::Success(gem_info)) 47 | allow(fake_gem_repo).to receive(:update).with(gem.id, expected_attributes).and_return(gem) 48 | end 49 | 50 | it "returns saved gem" do 51 | expect(subject.success?).to be_truthy 52 | expect(subject.value!).to eq(gem) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/slices/processing/workers/discover_worker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Processing::Workers::DiscoverWorker do 4 | let(:fake_discover) { instance_double(Processing::Services::Discover) } 5 | 6 | subject { described_class.new(discover: fake_discover).perform(1) } 7 | 8 | context "no_results failure" do 9 | before { allow(fake_discover).to receive(:call).and_return(Dry::Monads::Failure(:no_results)) } 10 | 11 | it "doesn't enqueue sync jobs" do 12 | subject 13 | 14 | expect(Processing::Workers::SyncWorker).not_to have_enqueued_sidekiq_job 15 | expect(Processing::Workers::SyncIssuesWorker).not_to have_enqueued_sidekiq_job 16 | expect(Processing::Workers::SyncRepoWorker).not_to have_enqueued_sidekiq_job 17 | end 18 | 19 | it "doesn't enqueue next page job" do 20 | subject 21 | 22 | expect(described_class).not_to have_enqueued_sidekiq_job 23 | end 24 | end 25 | 26 | context "gems returned by Discover" do 27 | let(:gem) { Factory.structs[:gem, id: 1, repo: Factory.structs[:repo, id: 5]] } 28 | 29 | before { allow(fake_discover).to receive(:call).and_return(Dry::Monads::Success[gem]) } 30 | 31 | it "enqueues sync jobs" do 32 | subject 33 | 34 | expect(Processing::Workers::SyncWorker).to have_enqueued_sidekiq_job(1) 35 | expect(Processing::Workers::SyncIssuesWorker).to have_enqueued_sidekiq_job(5) 36 | expect(Processing::Workers::SyncRepoWorker).to have_enqueued_sidekiq_job(5) 37 | end 38 | 39 | it "enqueues next page job" do 40 | subject 41 | 42 | expect(described_class).to have_enqueued_sidekiq_job(2) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/slices/processing/workers/sync_all_worker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Processing::Workers::SyncAllWorker do 4 | let(:fake_gem_repo) { instance_double(Processing::Repositories::GemsRepository) } 5 | let(:fake_repos_repo) { instance_double(Processing::Repositories::ReposRepository) } 6 | 7 | subject { described_class.new(gems_repository: fake_gem_repo, repos_repository: fake_repos_repo).perform(1673908001) } 8 | 9 | before do 10 | allow(fake_gem_repo).to receive(:pluck_ids_for_hour).with(22).and_return([1, 2, 3]) 11 | allow(fake_repos_repo).to receive(:pluck_ids_for_hour).with(22).and_return([5, 6, 7]) 12 | end 13 | 14 | it "schedules single sync jobs" do 15 | subject 16 | expect(Processing::Workers::SyncWorker).to have_enqueued_sidekiq_job(1) 17 | expect(Processing::Workers::SyncWorker).to have_enqueued_sidekiq_job(2) 18 | expect(Processing::Workers::SyncWorker).to have_enqueued_sidekiq_job(3) 19 | expect(Processing::Workers::SyncIssuesWorker).to have_enqueued_sidekiq_job(5) 20 | expect(Processing::Workers::SyncIssuesWorker).to have_enqueued_sidekiq_job(6) 21 | expect(Processing::Workers::SyncIssuesWorker).to have_enqueued_sidekiq_job(7) 22 | expect(Processing::Workers::SyncRepoWorker).to have_enqueued_sidekiq_job(5) 23 | expect(Processing::Workers::SyncRepoWorker).to have_enqueued_sidekiq_job(6) 24 | expect(Processing::Workers::SyncRepoWorker).to have_enqueued_sidekiq_job(7) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/slices/processing/workers/sync_issues_worker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Processing::Slice.prepare(:octokit) 4 | 5 | RSpec.describe Processing::Workers::SyncIssuesWorker do 6 | let(:fake_repos_repository) do 7 | fake_repository(:processing, :repos) do |repository| 8 | allow(repository).to receive(:by_id).with(1, with: [:issues]).and_return(repo) 9 | end 10 | end 11 | let(:fake_syncer) { instance_double(Processing::Services::IssuesSyncer) } 12 | let(:repo) { Factory.structs[:repo] } 13 | 14 | subject { described_class.new(repos_repository: fake_repos_repository, issues_syncer: fake_syncer).perform(1) } 15 | 16 | context "update successful" do 17 | it "calls sync" do 18 | expect(fake_syncer).to receive(:call).with(repo).and_return(Dry::Monads::Success()) 19 | subject 20 | end 21 | end 22 | 23 | context "update failed" do 24 | it "raises error" do 25 | expect(fake_syncer).to receive(:call).with(repo).and_return(Dry::Monads::Failure(Octokit::Forbidden.new)) 26 | expect { subject }.to raise_error(Octokit::Forbidden) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/slices/processing/workers/sync_repo_worker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Processing::Slice.prepare(:octokit) 4 | 5 | RSpec.describe Processing::Workers::SyncRepoWorker do 6 | let(:fake_repos_repository) do 7 | fake_repository(:processing, :repos) do |repository| 8 | allow(repository).to receive(:by_id).with(1).and_return(repo) 9 | end 10 | end 11 | let(:fake_syncer) { instance_double(Processing::Services::RepoSyncer) } 12 | let(:repo) { Factory.structs[:repo] } 13 | 14 | subject { described_class.new(repos_repository: fake_repos_repository, repo_syncer: fake_syncer).perform(1) } 15 | 16 | context "sync successful" do 17 | it "calls sync" do 18 | expect(fake_syncer).to receive(:call).with(repo).and_return(Dry::Monads::Success()) 19 | subject 20 | end 21 | end 22 | 23 | context "sync failed" do 24 | it "raises error" do 25 | expect(fake_syncer).to receive(:call).with(repo).and_return(Dry::Monads::Failure(Octokit::Forbidden.new)) 26 | expect { subject }.to raise_error(Octokit::Forbidden) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/slices/processing/workers/sync_worker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Processing::Slice.prepare(:octokit) 4 | 5 | RSpec.describe Processing::Workers::SyncWorker do 6 | let(:fake_gem_repo) do 7 | fake_repository(:processing, :gems) do |repo| 8 | allow(repo).to receive(:by_id).with(1).and_return(gem) 9 | end 10 | end 11 | let(:fake_syncer) { instance_double(Processing::Services::Syncer) } 12 | let(:gem) { Factory.structs[:gem] } 13 | 14 | subject { described_class.new(gems_repository: fake_gem_repo, syncer: fake_syncer).perform(1) } 15 | 16 | context "sync successful" do 17 | it "calls sync" do 18 | expect(fake_syncer).to receive(:call).with(gem).and_return(Dry::Monads::Success()) 19 | subject 20 | end 21 | end 22 | 23 | context "sync failed" do 24 | it "raises error" do 25 | expect(fake_syncer).to receive(:call).with(gem).and_return(Dry::Monads::Failure(Gems::GemError.new)) 26 | expect { subject }.to raise_error(Gems::GemError) 27 | end 28 | end 29 | 30 | context "gem not found" do 31 | it "deletes gem" do 32 | expect(fake_syncer).to receive(:call).with(gem).and_return(Dry::Monads::Failure(Gems::NotFound.new)) 33 | expect(fake_gem_repo).to receive(:delete).with(1) 34 | subject 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/slices/web/actions/favorites/create_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Actions::Favorites::Create do 4 | include_context "authorized user" 5 | 6 | subject do 7 | described_class.new(gems_repository: fake_gem_repository, favorites_repository: fake_favorites_repository) 8 | .call(env.merge({name: "test"})) 9 | end 10 | 11 | let(:gem) { Factory.structs[:gem, name: "test"] } 12 | let(:fake_gem_repository) do 13 | fake_repository(:web, :gem) do |repository| 14 | allow(repository).to receive(:by_name).with(gem.name).and_return(gem) 15 | end 16 | end 17 | let(:fake_favorites_repository) do 18 | fake_repository(:web, :favorites) do |repository| 19 | allow(repository).to receive(:create).with(gem_id: gem.id, user_id: user.id) 20 | end 21 | end 22 | 23 | it "redirects to gem" do 24 | expect(subject.headers["Location"]).to eq("/gems/test") 25 | end 26 | 27 | it "saves favorite" do 28 | expect(fake_favorites_repository).to receive(:create).with(gem_id: gem.id, user_id: user.id) 29 | subject 30 | end 31 | 32 | it "sets flash message" do 33 | expect(subject.flash.next).to eq({success: "Gem added to favorites"}) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/slices/web/actions/favorites/delete_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Actions::Favorites::Delete do 4 | include_context "authorized user" 5 | 6 | subject do 7 | described_class.new(gems_repository: fake_gem_repository, favorites_repository: fake_favorites_repository) 8 | .call(env.merge({name: "test"})) 9 | end 10 | 11 | let(:gem) { Factory.structs[:gem, name: "test"] } 12 | let(:fake_gem_repository) do 13 | fake_repository(:web, :gem) do |repository| 14 | allow(repository).to receive(:by_name).with(gem.name).and_return(gem) 15 | end 16 | end 17 | let(:fake_favorites_repository) do 18 | fake_repository(:web, :favorites) do |repository| 19 | allow(repository).to receive(:unlink).with(gem_id: gem.id, user_id: user.id) 20 | end 21 | end 22 | 23 | it "redirects to gem" do 24 | expect(subject.headers["Location"]).to eq("/gems/test") 25 | end 26 | 27 | it "unlinks favorite" do 28 | expect(fake_favorites_repository).to receive(:unlink).with(gem_id: gem.id, user_id: user.id) 29 | subject 30 | end 31 | 32 | it "sets flash message" do 33 | expect(subject.flash.next).to eq({success: "Gem removed from favorites"}) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/slices/web/actions/favorites/index_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Actions::Favorites::Index do 4 | include_context "authorized user" 5 | 6 | let(:fake_gems_repository) do 7 | fake_repository(:web, :gems) do |repo| 8 | allow(repo).to receive(:user_favorites).with(user.id).and_return([gem]) 9 | end 10 | end 11 | let(:gem) { OpenStruct.new(**Factory.structs[:gem].attributes, issues_count: 10) } 12 | 13 | subject { described_class.new(gems_repository: fake_gems_repository).call(env) } 14 | 15 | it "calls repo with proper attributes" do 16 | expect(fake_gems_repository).to receive(:user_favorites) 17 | .with(user.id) 18 | .and_return([gem]) 19 | subject 20 | end 21 | 22 | it "is successful" do 23 | expect(subject).to be_successful 24 | end 25 | 26 | it "exposes proper data" do 27 | expect(subject[:gems]).to eq([gem]) 28 | end 29 | 30 | it "render view" do 31 | expect(subject.body[0]).to include("Favorites") 32 | expect(subject.body[0]).to include(gem.name) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/slices/web/actions/gemfile/create_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Actions::Gemfile::Create do 4 | let(:fake_parser) { instance_double(Web::Services::GemfileParser) } 5 | let(:instance) { described_class.new(gemfile_parser: fake_parser, gems_repository: fake_gems_repository) } 6 | let(:fake_gems_repository) { fake_repository(:web, :gems) } 7 | let!(:gem) { OpenStruct.new(**Factory.structs[:gem].attributes, issues_count: 10, name: "hanami") } 8 | 9 | subject { instance.call({gemfile: {tempfile: File.open("#{SPEC_ROOT}/support/files/Gemfile.test")}}) } 10 | 11 | context "gemfile parse successful" do 12 | before do 13 | allow(fake_parser).to receive(:call).with("gem \"hanami\", \"~> 2.0\"\n") 14 | .and_return(Dry::Monads::Success(["hanami"])) 15 | allow(fake_gems_repository).to receive(:by_list).with(["hanami"]).and_return([gem]) 16 | end 17 | 18 | it "exposes proper data" do 19 | expect(subject[:gems].to_a).to eq([gem]) 20 | end 21 | 22 | it "render view" do 23 | expect(subject.body[0]).to include("Gems from your Gemfile") 24 | expect(subject.body[0]).to include(gem.name) 25 | end 26 | end 27 | 28 | context "gemfile parse successful but there are no gems in DB" do 29 | before do 30 | allow(fake_parser).to receive(:call).with("gem \"hanami\", \"~> 2.0\"\n") 31 | .and_return(Dry::Monads::Success(["hanami"])) 32 | allow(fake_gems_repository).to receive(:by_list).with(["hanami"]).and_return([]) 33 | end 34 | 35 | it "exposes proper data" do 36 | expect(subject[:gems].to_a).to eq([]) 37 | end 38 | 39 | it "render view" do 40 | expect(subject.body[0]).to include("Gems from your Gemfile") 41 | expect(subject.body[0]).to include("Couldn't find any gems from your gemfile in our database") 42 | end 43 | end 44 | 45 | context "gemfile parse failed" do 46 | before do 47 | allow(fake_parser).to receive(:call).with("gem \"hanami\", \"~> 2.0\"\n") 48 | .and_return(Dry::Monads::Failure(:no_gems_in_gemfile)) 49 | end 50 | 51 | it "redirects to gem index" do 52 | expect(subject.headers["Location"]).to eq("/gems") 53 | end 54 | 55 | it "sets flash" do 56 | expect(subject.flash.next).to eq({warning: "No gems found in file, are you sure it's a correct Gemfile?"}) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/slices/web/actions/gems/index_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Actions::Gems::Index do 4 | let(:fake_gems_repository) do 5 | fake_repository(:web, :gems) do |repo| 6 | allow(repo).to receive(:index).with(any_args).and_return(result) 7 | end 8 | end 9 | let!(:gem) { OpenStruct.new(**Factory.structs[:gem].attributes, issues_count: 10) } 10 | let(:result) { double(to_a: [gem], pager: pager_dbl) } 11 | let(:pager_dbl) { instance_double(ROM::SQL::Plugin::Pagination::Pager, total_pages: 3, current_page: 1) } 12 | 13 | subject { described_class.new(gems_repository: fake_gems_repository).call(env) } 14 | 15 | context "invalid params" do 16 | let(:env) { {page: "hehe"} } 17 | 18 | it "returns bad request" do 19 | puts gem.attributes 20 | expect(subject.status).to eq(400) 21 | end 22 | end 23 | 24 | context "valid params" do 25 | let(:env) { {page: 1, sort_by: "name"} } 26 | 27 | it "calls repo with proper attributes" do 28 | expect(fake_gems_repository).to receive(:index) 29 | .with(page: 1, order: "name") 30 | .and_return(result) 31 | subject 32 | end 33 | 34 | it "is successful" do 35 | expect(subject).to be_successful 36 | end 37 | 38 | it "exposes proper data" do 39 | expect(subject[:gems].to_a).to eq([gem]) 40 | end 41 | 42 | it "render view" do 43 | expect(subject.body[0]).to include("Browse gems") 44 | expect(subject.body[0]).to include(gem.name) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/slices/web/actions/gems/show_old_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Actions::Gems::ShowOld do 4 | let(:fake_gems_repository) do 5 | fake_repository(:web, :gems) do |repo| 6 | allow(repo).to receive(:by_id).with(gem.id).and_return(gem) 7 | end 8 | end 9 | let(:gem) { Factory.structs[:gem, name: "test"] } 10 | 11 | subject { described_class.new(gems_repository: fake_gems_repository).call({id: gem.id}) } 12 | 13 | it "is redirects to new endpoint" do 14 | expect(subject.status).to eq(301) 15 | expect(subject.headers["Location"]).to eq("/gems/test") 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/slices/web/actions/gems/show_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Actions::Gems::Show do 4 | include_context "authorized user" 5 | 6 | let(:fake_gems_repository) do 7 | fake_repository(:web, :gems) do |repo| 8 | allow(repo).to receive(:by_name).with(gem.name, with: {repo: [:issues, :gems]}).and_return(gem) 9 | allow(repo).to receive(:by_name).with("eo", with: {repo: [:issues, :gems]}).and_return(nil) 10 | end 11 | end 12 | let(:fake_favorites_repository) do 13 | fake_repository(:web, :favorites) do |repo| 14 | allow(repo).to receive(:favorite?).with(user_id: user.id, gem_id: gem.id).and_return(true) 15 | allow(repo).to receive(:total_favorites).with(gem.id).and_return(5) 16 | end 17 | end 18 | let(:repo) { Factory.structs[:repo] } 19 | let(:gem) { Factory.structs[:gem, repo: repo] } 20 | let(:instance) { described_class.new(gems_repository: fake_gems_repository, favorites_repository: fake_favorites_repository) } 21 | 22 | before do 23 | # bug in factory? 24 | allow(gem).to receive(:repo).and_return(repo) 25 | end 26 | 27 | context "gem not found" do 28 | subject { instance.call(env.merge({name: "eo"})) } 29 | 30 | it "returns not found" do 31 | expect(subject.status).to eq(404) 32 | end 33 | end 34 | 35 | context "gem exists" do 36 | subject { instance.call(env.merge({name: gem.name})) } 37 | 38 | it "is successful" do 39 | expect(subject).to be_successful 40 | end 41 | 42 | it "exposes proper data" do 43 | expect(subject[:current_gem]).to eq(gem) 44 | expect(subject[:favorite]).to eq(true) 45 | expect(subject[:total_favorites]).to eq(5) 46 | end 47 | 48 | it "render view" do 49 | expect(subject.body[0]).to include(gem.name) 50 | expect(subject.body[0]).to include(repo.issues[0].title) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/slices/web/actions/pages/index_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Actions::Pages::Index do 4 | let(:fake_gems_repository) do 5 | fake_repository(:web, :gems) do |repo| 6 | allow(repo).to receive(:random).with(3).and_return(result) 7 | end 8 | end 9 | let!(:gem) { OpenStruct.new(**Factory.structs[:gem].attributes, issues_count: 10) } 10 | let(:result) { [gem] } 11 | 12 | subject { described_class.new(gems_repository: fake_gems_repository).call({}) } 13 | 14 | it "calls repo with proper attributes" do 15 | expect(fake_gems_repository).to receive(:random) 16 | .with(3) 17 | .and_return(result) 18 | subject 19 | end 20 | 21 | it "is successful" do 22 | expect(subject).to be_successful 23 | end 24 | 25 | it "exposes proper data" do 26 | expect(subject[:random_gems]).to eq([gem]) 27 | end 28 | 29 | it "render view" do 30 | expect(subject.body[0]).to include("Help maintain your favourite gems") 31 | expect(subject.body[0]).to include(gem.name) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/slices/web/actions/session/create_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Actions::Session::Create do 4 | subject { described_class.new(users_repository: fake_repo).call(env) } 5 | 6 | let(:auth_mock) { OmniAuth.config.mock_auth[:github] } 7 | let(:user) { Factory.structs[:user, id: 2] } 8 | let(:fake_repo) { instance_double(Web::Repositories::UsersRepository, {auth: user}) } 9 | let(:env) { {"omniauth.auth" => OmniAuth.config.mock_auth[:github]} } 10 | 11 | it "redirects to homepage" do 12 | expect(subject.headers["Location"]).to eq("/") 13 | end 14 | 15 | it "saves user id returned from repo to session" do 16 | expect(fake_repo).to receive(:auth).with(auth_mock).and_return(user) 17 | expect(subject.session[:user_id]).to eq(2) 18 | end 19 | 20 | it "sets flash message" do 21 | expect(subject.flash.next).to eq({success: "Successfully signed in"}) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/slices/web/actions/session/destroy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Actions::Session::Destroy do 4 | subject { described_class.new.call(env) } 5 | 6 | let(:env) { {"rack.session" => {"user_id" => 3}} } 7 | 8 | it "redirects to homepage" do 9 | expect(subject.headers["Location"]).to eq("/") 10 | end 11 | 12 | it "clears user id from session" do 13 | expect(subject.session[:user_id]).to be_nil 14 | end 15 | 16 | it "sets flash message" do 17 | expect(subject.flash.next).to eq({success: "Successfully signed out"}) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/slices/web/actions/session/failure_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Actions::Session::Failure do 4 | subject { described_class.new.call({}) } 5 | 6 | it "redirects to homepage" do 7 | expect(subject.headers["Location"]).to eq("/") 8 | end 9 | 10 | it "sets flash message" do 11 | expect(subject.flash.next).to eq({warning: "Error: couldn't sign in"}) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/slices/web/lib/errors_mapper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::ErrorsMapper do 4 | it "returns human error for key" do 5 | expect(described_class.new.call(:gemfile_parse_failed)).to eq("Couldn't parse gemfile") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/slices/web/lib/formatter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Formatter do 4 | let(:instance) { described_class.new } 5 | 6 | describe "#separator" do 7 | it "returns number with separators" do 8 | expect(instance.separator(2342315)).to eq("2,342,315") 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/slices/web/repositories/favorites_repository_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Repositories::FavoritesRepository, type: :database do 4 | subject(:repository) { described_class.new } 5 | 6 | describe "#favorite?" do 7 | let(:gem) { Factory[:gem] } 8 | let(:user) { Factory[:user] } 9 | 10 | context "when user has favorite" do 11 | before { Factory[:favorite, user_id: user.id, gem_id: gem.id] } 12 | 13 | it "returns true" do 14 | expect(repository.favorite?(user_id: user.id, gem_id: gem.id)).to be_truthy 15 | end 16 | end 17 | 18 | context "without favorite" do 19 | it "returns false" do 20 | expect(repository.favorite?(user_id: user.id, gem_id: gem.id)).to be_falsey 21 | end 22 | end 23 | end 24 | 25 | describe "#total_favorites" do 26 | let(:gem1) { Factory[:gem] } 27 | let(:gem2) { Factory[:gem] } 28 | let(:user1) { Factory[:user] } 29 | let(:user2) { Factory[:user] } 30 | let(:user3) { Factory[:user] } 31 | 32 | before do 33 | Factory[:favorite, user_id: user1.id, gem_id: gem1.id] 34 | Factory[:favorite, user_id: user2.id, gem_id: gem1.id] 35 | Factory[:favorite, user_id: user3.id, gem_id: gem2.id] 36 | end 37 | 38 | it "returns proper count of favorites" do 39 | expect(repository.total_favorites(gem1.id)).to eq(2) 40 | expect(repository.total_favorites(gem2.id)).to eq(1) 41 | end 42 | end 43 | 44 | describe "#unlink" do 45 | let(:gem) { Factory[:gem] } 46 | let(:user) { Factory[:user] } 47 | 48 | before { Factory[:favorite, user_id: user.id, gem_id: gem.id] } 49 | 50 | it "removes association" do 51 | repository.unlink(user_id: user.id, gem_id: gem.id) 52 | expect(repository.favorite?(user_id: user.id, gem_id: gem.id)).to be_falsey 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/slices/web/repositories/gems_repository_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Repositories::GemsRepository, type: :database do 4 | subject(:repository) { described_class.new } 5 | 6 | describe "#by_id" do 7 | let!(:repo) { Factory[:repo] } 8 | let!(:gem) { Factory[:gem, repo_id: repo.id] } 9 | 10 | context "gem exists" do 11 | it "returns gem" do 12 | expect(repository.by_id(gem.id)).to match_entity(gem) 13 | end 14 | end 15 | 16 | context "gem doesnt exist" do 17 | it "returns nil" do 18 | expect(repository.by_id(gem.id + 3)).to be_nil 19 | end 20 | end 21 | 22 | context "when 'with' present" do 23 | it "returns gem with associations" do 24 | result = repository.by_id(gem.id, with: [:repo]) 25 | expect(result.name).to eq(gem.name) 26 | expect(result.repo).to match(match_entity(repo)) 27 | end 28 | end 29 | end 30 | 31 | describe "#by_name" do 32 | let!(:repo) { Factory[:repo] } 33 | let!(:gem) { Factory[:gem, repo_id: repo.id] } 34 | 35 | context "gem exists" do 36 | it "returns gem" do 37 | expect(repository.by_name(gem.name)).to match_entity(gem) 38 | end 39 | end 40 | 41 | context "gem doesnt exist" do 42 | it "returns nil" do 43 | expect(repository.by_name("invalid")).to be_nil 44 | end 45 | end 46 | 47 | context "when 'with' present" do 48 | it "returns gem with associations" do 49 | result = repository.by_name(gem.name, with: [:repo]) 50 | expect(result.name).to eq(gem.name) 51 | expect(result.repo).to match(match_entity(repo)) 52 | end 53 | end 54 | end 55 | 56 | describe "#index" do 57 | let!(:gem1) { Factory[:gem, repo: Factory[:repo], downloads: 3000] } 58 | let!(:gem2) { Factory[:gem, repo: Factory[:repo], downloads: 5000] } 59 | let!(:gem3) { Factory[:gem, repo: Factory[:repo], downloads: 4000] } 60 | let!(:gem4) { Factory[:gem, repo: Factory[:repo, issues: []]] } 61 | let!(:gem5) { Factory[:gem, repo: Factory[:repo, pushed_at: DateTime.now - 400]] } 62 | 63 | it "returns paginated data except for gems without issues or outdated" do 64 | expect(subject.index(per_page: 2, page: 1).to_a.map(&:id)) 65 | .to eq([gem2.id, gem3.id]) 66 | expect(subject.index(per_page: 2, page: 2).to_a.map(&:id)) 67 | .to eq([gem1.id]) 68 | end 69 | 70 | it "returns pager" do 71 | expect(subject.index(per_page: 2, page: 1).pager).not_to be_nil 72 | end 73 | end 74 | 75 | describe "#by_list" do 76 | let!(:gem1) { Factory[:gem, repo: Factory[:repo]] } 77 | let!(:gem2) { Factory[:gem, repo: Factory[:repo]] } 78 | let!(:gem3) { Factory[:gem, repo: Factory[:repo, stars: 20, issues: []]] } 79 | let!(:gem4) { Factory[:gem, repo: Factory[:repo, pushed_at: DateTime.now - 400]] } 80 | 81 | it "returns gems from given list except for gems without issues or outdated" do 82 | expect(subject.by_list([gem1.name, gem2.name, gem3.name, gem4.name]).to_a.map(&:id)) 83 | .to eq([gem1.id, gem2.id]) 84 | end 85 | end 86 | 87 | describe "#user_favorites" do 88 | let!(:gem1) { Factory[:gem, repo: Factory[:repo]] } 89 | let!(:gem2) { Factory[:gem, repo: Factory[:repo]] } 90 | let!(:gem3) { Factory[:gem, repo: Factory[:repo, stars: 20, issues: []]] } 91 | let!(:gem4) { Factory[:gem, repo: Factory[:repo, pushed_at: DateTime.now - 400]] } 92 | let!(:user) { Factory[:user] } 93 | 94 | before { Factory[:favorite, user_id: user.id, gem_id: gem1.id] } 95 | 96 | it "returns favorite gems from user id except for gems without issues or outdated" do 97 | expect(subject.user_favorites(user.id).to_a.map(&:id)) 98 | .to eq([gem1.id]) 99 | end 100 | end 101 | 102 | describe "#random" do 103 | let!(:gem1) { Factory[:gem, repo: Factory[:repo]] } 104 | let!(:gem2) { Factory[:gem, repo: Factory[:repo]] } 105 | 106 | it "returns random N gems" do 107 | expect(subject.random(1).to_a.map(&:id)).to eq([gem1.id]).or(eq([gem2.id])) 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/slices/web/repositories/users_repository_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Repositories::UsersRepository, type: :database do 4 | subject(:repo) { described_class.new } 5 | 6 | describe "#by_id" do 7 | let!(:user) { Factory[:user] } 8 | 9 | context "user exists" do 10 | it "returns user" do 11 | expect(repo.by_id(user.id)).to eq(user) 12 | end 13 | end 14 | 15 | context "user doesnt exist" do 16 | it "returns nil" do 17 | expect(repo.by_id(user.id + 3)).to be_nil 18 | end 19 | end 20 | end 21 | 22 | describe "#auth" do 23 | let(:expected_attributes) do 24 | { 25 | username: "test", 26 | avatar: "http://localhost/avatar.png", 27 | github_id: 235352, 28 | email: "test@test.com" 29 | } 30 | end 31 | 32 | context "user exists" do 33 | let!(:user) { Factory[:user, github_id: 235352] } 34 | 35 | it "returns updated user" do 36 | user = repo.auth(OmniAuth.config.mock_auth[:github]) 37 | expect(user.attributes).to include(expected_attributes) 38 | end 39 | 40 | it "doesnt create new user" do 41 | expect { repo.auth(OmniAuth.config.mock_auth[:github]) }.not_to change { repo.users.to_a.size } 42 | end 43 | end 44 | 45 | context "user doesnt exist" do 46 | it "returns user" do 47 | user = repo.auth(OmniAuth.config.mock_auth[:github]) 48 | expect(user.attributes).to include(expected_attributes) 49 | end 50 | 51 | it "saves user to DB" do 52 | expect { repo.auth(OmniAuth.config.mock_auth[:github]) }.to change { repo.users.to_a.size }.by(1) 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/slices/web/services/gemfile_parser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Services::GemfileParser do 4 | subject { described_class.new.call(content) } 5 | 6 | context "content is valid gemfile" do 7 | let(:content) do 8 | 'source "https://rubygems.org" 9 | 10 | gem "hanami", "~> 2.0" 11 | gem "hanami-router", "~> 2.0"' 12 | end 13 | 14 | it "returns gems" do 15 | expect(subject.success?).to be_truthy 16 | expect(subject.value!).to eq(["hanami", "hanami-router"]) 17 | end 18 | end 19 | 20 | context "content is valid gemfile but empty" do 21 | let(:content) do 22 | 'source "https://rubygems.org"' 23 | end 24 | 25 | it "returns gems" do 26 | expect(subject.success?).to be_falsey 27 | expect(subject.failure).to eq(:no_gems_in_gemfile) 28 | end 29 | end 30 | 31 | context "content is not a valid ruby code" do 32 | let(:content) do 33 | 'source https://rubygems.org" 34 | 35 | gem "hanami", "~> 2.0" 36 | gem "hanami-router", "~> 2.0"' 37 | end 38 | 39 | it "returns failure" do 40 | expect(subject.success?).to be_falsey 41 | expect(subject.failure).to eq(:gemfile_parse_failed) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/slices/web/views/gems/index_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Views::Gems::Index do 4 | describe "pages exposure" do 5 | subject { described_class.new.call(pager: fake_pager, sort_by: "downloads", gems: [], context: context) } 6 | 7 | let(:context) { Web::Views::Context.new(request: request_dbl, response: response_dbl) } 8 | let(:response_dbl) { instance_double(Hanami::Action::Response, flash: []) } 9 | let(:request_dbl) { instance_double(Hanami::Action::Request, session: {}, flash: {}) } 10 | let(:fake_pager) { instance_double(ROM::SQL::Plugin::Pagination::Pager, current_page: 5, total_pages: 25) } 11 | 12 | before do 13 | allow(response_dbl).to receive(:[]).with(:current_user).and_return(nil) 14 | end 15 | 16 | it "returns array with proper gaps" do 17 | expect(subject[:pages]).to eq([1, :gap, 4, 5, 6, :gap, 25]) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/slices/web/views/pages/error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Views::Pages::Error do 4 | describe "exposures" do 5 | subject { described_class.new.call(code: 404, context: context) } 6 | 7 | let(:context) { Web::Views::Context.new(request: request_dbl, response: response_dbl) } 8 | let(:response_dbl) { instance_double(Hanami::Action::Response, flash: []) } 9 | let(:request_dbl) { instance_double(Hanami::Action::Request, session: {}, flash: {}) } 10 | 11 | before do 12 | allow(response_dbl).to receive(:[]).with(:current_user).and_return(nil) 13 | end 14 | 15 | it "exposes code and message" do 16 | expect(subject[:code].value).to eq(404) 17 | expect(subject[:msg].value).to eq("Not Found") 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/slices/web/views/parts/gem_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Views::Parts::Gem do 4 | let(:gem) { Factory.structs[:gem, downloads: 34522342, name: "test", licenses: ["MIT", "ABC"]] } 5 | let(:instance) { described_class.new(value: gem) } 6 | 7 | describe "#downloads" do 8 | it "returns formatted value" do 9 | expect(instance.downloads).to eq("34,522,342") 10 | end 11 | end 12 | 13 | describe "#url" do 14 | it "returns formatted value" do 15 | expect(instance.url).to eq("https://rubygems.org/gems/test") 16 | end 17 | end 18 | 19 | describe "#license" do 20 | it "returns joined licenses" do 21 | expect(instance.license).to eq("MIT, ABC") 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/slices/web/views/parts/label_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Views::Parts::Label do 4 | let(:label_light) { {"name" => "test", "color" => "dddddd"} } 5 | let(:label_dark) { {"name" => "dark", "color" => "222222"} } 6 | let(:instance_light) { described_class.new(value: label_light) } 7 | let(:instance_dark) { described_class.new(value: label_dark) } 8 | 9 | describe "#name" do 10 | it "returns name" do 11 | expect(instance_light.name).to eq("test") 12 | expect(instance_dark.name).to eq("dark") 13 | end 14 | end 15 | 16 | describe "#bg_color" do 17 | it "returns formatter color" do 18 | expect(instance_light.bg_color).to eq("#dddddd") 19 | expect(instance_dark.bg_color).to eq("#222222") 20 | end 21 | end 22 | 23 | describe "#bg_light?" do 24 | it "returns if color is light" do 25 | expect(instance_light.bg_light?).to be_truthy 26 | expect(instance_dark.bg_light?).to be_falsey 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/slices/web/views/parts/repo_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Web::Views::Parts::Repo do 4 | let(:repo) { Factory.structs[:repo, stars: 34522342, name: "test/test"] } 5 | let(:instance) { described_class.new(value: repo) } 6 | 7 | describe "#stars" do 8 | it "returns formatted value" do 9 | expect(instance.stars).to eq("34,522,342") 10 | end 11 | end 12 | 13 | describe "#url" do 14 | it "returns formatted value" do 15 | expect(instance.url).to eq("https://github.com/test/test") 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pathname" 4 | SPEC_ROOT = Pathname(__dir__).realpath.freeze 5 | 6 | ENV["HANAMI_ENV"] ||= "test" 7 | 8 | require_relative "support/cov" 9 | require "hanami/prepare" 10 | require "dry/system/stubs" 11 | require "ostruct" 12 | 13 | Web::Slice.container.enable_stubs! 14 | 15 | RSpec.configure do |config| 16 | config.after do 17 | Web::Slice.container.unstub 18 | end 19 | end 20 | 21 | require_relative "support/rspec" 22 | require_relative "support/database_cleaner" 23 | require_relative "support/factory" 24 | require_relative "support/capybara" 25 | require_relative "support/sidekiq" 26 | require_relative "support/matchers" 27 | require_relative "support/fake_repositories" 28 | require_relative "support/webmock" 29 | require_relative "support/csrf_fix" 30 | require_relative "support/omniauth_mock" 31 | require_relative "support/auth" 32 | -------------------------------------------------------------------------------- /spec/support/auth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context "authorized user" do 4 | let!(:user) { Factory.structs[:user] } 5 | let!(:fake_auth_repo) do 6 | fake_repository(:web, :users, stub_container: true) do |repo| 7 | allow(repo).to receive(:by_id).with(user.id).and_return(user) 8 | end 9 | end 10 | let!(:env) { {"rack.session" => {user_id: user.id}} } 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/capybara.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara/rspec" 4 | require "capybara/cuprite" 5 | 6 | Capybara.app = Hanami.app 7 | Capybara.javascript_driver = :cuprite 8 | Capybara.register_driver(:cuprite) do |app| 9 | Capybara::Cuprite::Driver.new(app, window_size: [1200, 800], browser_options: {"disable-smooth-scrolling" => true}) 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/cov.rb: -------------------------------------------------------------------------------- 1 | require "simplecov" 2 | 3 | SimpleCov.start do 4 | add_filter "/spec/" 5 | minimum_coverage 95 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/csrf_fix.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveCsrfParamMiddleware 4 | def initialize(app) 5 | @app = app 6 | end 7 | 8 | def call(env) 9 | return @app.call(env) if env["CONTENT_TYPE"]&.start_with?("multipart") 10 | 11 | params = Rack::Utils.parse_query(env["rack.input"].read, "&") 12 | params.delete("_csrf_token") 13 | env["rack.input"] = StringIO.new(Rack::Utils.build_query(params)) 14 | @app.call(env) 15 | end 16 | end 17 | 18 | class ShinyGems::App 19 | config.middleware.use RemoveCsrfParamMiddleware 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/database_cleaner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "database_cleaner-sequel" 4 | 5 | Hanami.app.prepare(:persistence) 6 | DatabaseCleaner[:sequel, db: Hanami.app["persistence.db"]] 7 | 8 | RSpec.configure do |config| 9 | config.before(:suite) do 10 | DatabaseCleaner.clean_with(:truncation) 11 | end 12 | 13 | config.around(:each, type: :database) do |example| 14 | DatabaseCleaner.strategy = :transaction 15 | DatabaseCleaner.cleaning do 16 | example.run 17 | end 18 | end 19 | 20 | config.around(:each, type: :feature) do |example| 21 | DatabaseCleaner.strategy = :truncation 22 | DatabaseCleaner.cleaning do 23 | example.run 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/support/factories/favorite.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Factory.define(:favorite) do |f| 4 | f.association(:gem) 5 | f.association(:user) 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/factories/gem.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Factory.define(:gem) do |f| 4 | f.sequence(:name) { |n| "gem#{n}" } 5 | f.description "This is a gem" 6 | f.downloads { 2453432 } 7 | f.association(:repo) 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/factories/issue.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Factory.define(:issue) do |f| 4 | f.sequence(:title) { |n| "Some issue #{n}" } 5 | f.sequence(:url) { |n| "https://github.com/repo/gem/issues/#{n}" } 6 | f.sequence(:github_id) { |n| 1000 + n } 7 | f.comments 5 8 | f.labels { [{"name" => "test", "color" => "dddddd"}] } 9 | f.created_at { DateTime.now } 10 | 11 | f.trait :with_repo do |t| 12 | t.association(:repo) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/factories/repo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Factory.define(:repo) do |f| 4 | f.sequence(:name) { |n| "repo/gem#{n}" } 5 | f.stars { 1521 } 6 | f.association(:issues, count: 3) 7 | f.pushed_at { DateTime.now - 30 } 8 | f.association(:gems, count: 0) 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/factories/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Factory.define(:user) do |f| 4 | f.username "testname" 5 | f.sequence(:github_id) { |n| 1000 + n } 6 | f.email "test@test.com" 7 | f.avatar "http://localhost/avatar.png" 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/factory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rom-factory" 4 | 5 | Factory = ROM::Factory.configure do |config| 6 | config.rom = Hanami.app["persistence.rom"] 7 | end 8 | 9 | Dir[File.dirname(__FILE__) + "/factories/*.rb"].each { |file| require file } 10 | -------------------------------------------------------------------------------- /spec/support/fake_repositories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ShinyGems 4 | module Support 5 | module FakeRepositories 6 | def fake_repository(namespace, name, stub_container: false, &block) 7 | instance_double("#{namespace.capitalize}::Repositories::#{name.capitalize}Repository").tap do |double| 8 | block&.call(double) 9 | Object.const_get("#{namespace.capitalize}::Slice").container.stub("repositories.#{name}_repository", double) if stub_container 10 | end 11 | end 12 | end 13 | end 14 | end 15 | 16 | RSpec.configure do |config| 17 | config.include ShinyGems::Support::FakeRepositories 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/files/Gemfile.test: -------------------------------------------------------------------------------- 1 | gem "hanami", "~> 2.0" 2 | -------------------------------------------------------------------------------- /spec/support/matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :match_entity do |expected| 4 | match do |actual| 5 | filtered(actual) == filtered(expected) 6 | end 7 | 8 | def filtered(entity) 9 | entity.attributes.reject { |_, value| value.is_a?(ROM::Struct) || (value.is_a?(Array) && value.all? { |el| el.is_a?(ROM::Struct) }) } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/omniauth_mock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "omniauth" 4 | 5 | OmniAuth.config.test_mode = true 6 | OmniAuth.config.request_validation_phase = false 7 | OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new({ 8 | provider: "github", 9 | uid: 235352, 10 | info: { 11 | nickname: "test", 12 | image: "http://localhost/avatar.png", 13 | email: "test@test.com" 14 | }, 15 | credentials: { 16 | token: "abc" 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /spec/support/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.expect_with :rspec do |expectations| 5 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 6 | end 7 | 8 | config.mock_with :rspec do |mocks| 9 | mocks.verify_partial_doubles = true 10 | end 11 | 12 | config.shared_context_metadata_behavior = :apply_to_host_groups 13 | 14 | config.filter_run_when_matching :focus 15 | 16 | config.disable_monkey_patching! 17 | config.warnings = true 18 | 19 | if config.files_to_run.one? 20 | config.default_formatter = "doc" 21 | end 22 | 23 | config.order = :random 24 | Kernel.srand config.seed 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/sidekiq.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rspec-sidekiq" 4 | 5 | Sidekiq.configure_client do |config| 6 | config.logger.level = Logger::WARN 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/webmock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webmock/rspec" 4 | 5 | WebMock.disable_net_connect!(allow_localhost: true) 6 | -------------------------------------------------------------------------------- /web.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | HANAMI_SLICES=web bundle exec puma -C config/puma.rb 4 | -------------------------------------------------------------------------------- /worker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | HANAMI_SLICES=processing bundle exec sidekiq -r ./config/sidekiq.rb -e production 4 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@esbuild/android-arm64@0.19.5": 6 | version "0.19.5" 7 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.5.tgz#276c5f99604054d3dbb733577e09adae944baa90" 8 | integrity sha512-5d1OkoJxnYQfmC+Zd8NBFjkhyCNYwM4n9ODrycTFY6Jk1IGiZ+tjVJDDSwDt77nK+tfpGP4T50iMtVi4dEGzhQ== 9 | 10 | "@esbuild/android-arm@0.19.5": 11 | version "0.19.5" 12 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.5.tgz#4a3cbf14758166abaae8ba9c01a80e68342a4eec" 13 | integrity sha512-bhvbzWFF3CwMs5tbjf3ObfGqbl/17ict2/uwOSfr3wmxDE6VdS2GqY/FuzIPe0q0bdhj65zQsvqfArI9MY6+AA== 14 | 15 | "@esbuild/android-x64@0.19.5": 16 | version "0.19.5" 17 | resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.5.tgz#21a3d11cd4613d2d3c5ccb9e746c254eb9265b0a" 18 | integrity sha512-9t+28jHGL7uBdkBjL90QFxe7DVA+KGqWlHCF8ChTKyaKO//VLuoBricQCgwhOjA1/qOczsw843Fy4cbs4H3DVA== 19 | 20 | "@esbuild/darwin-arm64@0.19.5": 21 | version "0.19.5" 22 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.5.tgz#714cb839f467d6a67b151ee8255886498e2b9bf6" 23 | integrity sha512-mvXGcKqqIqyKoxq26qEDPHJuBYUA5KizJncKOAf9eJQez+L9O+KfvNFu6nl7SCZ/gFb2QPaRqqmG0doSWlgkqw== 24 | 25 | "@esbuild/darwin-x64@0.19.5": 26 | version "0.19.5" 27 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.5.tgz#2c553e97a6d2b4ae76a884e35e6cbab85a990bbf" 28 | integrity sha512-Ly8cn6fGLNet19s0X4unjcniX24I0RqjPv+kurpXabZYSXGM4Pwpmf85WHJN3lAgB8GSth7s5A0r856S+4DyiA== 29 | 30 | "@esbuild/freebsd-arm64@0.19.5": 31 | version "0.19.5" 32 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.5.tgz#d554f556718adb31917a0da24277bf84b6ee87f3" 33 | integrity sha512-GGDNnPWTmWE+DMchq1W8Sd0mUkL+APvJg3b11klSGUDvRXh70JqLAO56tubmq1s2cgpVCSKYywEiKBfju8JztQ== 34 | 35 | "@esbuild/freebsd-x64@0.19.5": 36 | version "0.19.5" 37 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.5.tgz#288f7358a3bb15d99e73c65c9adaa3dabb497432" 38 | integrity sha512-1CCwDHnSSoA0HNwdfoNY0jLfJpd7ygaLAp5EHFos3VWJCRX9DMwWODf96s9TSse39Br7oOTLryRVmBoFwXbuuQ== 39 | 40 | "@esbuild/linux-arm64@0.19.5": 41 | version "0.19.5" 42 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.5.tgz#95933ae86325c93cb6b5e8333d22120ecfdc901b" 43 | integrity sha512-o3vYippBmSrjjQUCEEiTZ2l+4yC0pVJD/Dl57WfPwwlvFkrxoSO7rmBZFii6kQB3Wrn/6GwJUPLU5t52eq2meA== 44 | 45 | "@esbuild/linux-arm@0.19.5": 46 | version "0.19.5" 47 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.5.tgz#0acef93aa3e0579e46d33b666627bddb06636664" 48 | integrity sha512-lrWXLY/vJBzCPC51QN0HM71uWgIEpGSjSZZADQhq7DKhPcI6NH1IdzjfHkDQws2oNpJKpR13kv7/pFHBbDQDwQ== 49 | 50 | "@esbuild/linux-ia32@0.19.5": 51 | version "0.19.5" 52 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.5.tgz#b6e5c9e80b42131cbd6b1ddaa48c92835f1ed67f" 53 | integrity sha512-MkjHXS03AXAkNp1KKkhSKPOCYztRtK+KXDNkBa6P78F8Bw0ynknCSClO/ztGszILZtyO/lVKpa7MolbBZ6oJtQ== 54 | 55 | "@esbuild/linux-loong64@0.19.5": 56 | version "0.19.5" 57 | resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.5.tgz#e5f0cf95a180158b01ff5f417da796a1c09dfbea" 58 | integrity sha512-42GwZMm5oYOD/JHqHska3Jg0r+XFb/fdZRX+WjADm3nLWLcIsN27YKtqxzQmGNJgu0AyXg4HtcSK9HuOk3v1Dw== 59 | 60 | "@esbuild/linux-mips64el@0.19.5": 61 | version "0.19.5" 62 | resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.5.tgz#ae36fb86c7d5f641f3a0c8472e83dcb6ea36a408" 63 | integrity sha512-kcjndCSMitUuPJobWCnwQ9lLjiLZUR3QLQmlgaBfMX23UEa7ZOrtufnRds+6WZtIS9HdTXqND4yH8NLoVVIkcg== 64 | 65 | "@esbuild/linux-ppc64@0.19.5": 66 | version "0.19.5" 67 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.5.tgz#7960cb1666f0340ddd9eef7b26dcea3835d472d0" 68 | integrity sha512-yJAxJfHVm0ZbsiljbtFFP1BQKLc8kUF6+17tjQ78QjqjAQDnhULWiTA6u0FCDmYT1oOKS9PzZ2z0aBI+Mcyj7Q== 69 | 70 | "@esbuild/linux-riscv64@0.19.5": 71 | version "0.19.5" 72 | resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.5.tgz#32207df26af60a3a9feea1783fc21b9817bade19" 73 | integrity sha512-5u8cIR/t3gaD6ad3wNt1MNRstAZO+aNyBxu2We8X31bA8XUNyamTVQwLDA1SLoPCUehNCymhBhK3Qim1433Zag== 74 | 75 | "@esbuild/linux-s390x@0.19.5": 76 | version "0.19.5" 77 | resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.5.tgz#b38d5681db89a3723862dfa792812397b1510a7d" 78 | integrity sha512-Z6JrMyEw/EmZBD/OFEFpb+gao9xJ59ATsoTNlj39jVBbXqoZm4Xntu6wVmGPB/OATi1uk/DB+yeDPv2E8PqZGw== 79 | 80 | "@esbuild/linux-x64@0.19.5": 81 | version "0.19.5" 82 | resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.5.tgz#46feba2ad041a241379d150f415b472fe3885075" 83 | integrity sha512-psagl+2RlK1z8zWZOmVdImisMtrUxvwereIdyJTmtmHahJTKb64pAcqoPlx6CewPdvGvUKe2Jw+0Z/0qhSbG1A== 84 | 85 | "@esbuild/netbsd-x64@0.19.5": 86 | version "0.19.5" 87 | resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.5.tgz#3b5c1fb068f26bfc681d31f682adf1bea4ef0702" 88 | integrity sha512-kL2l+xScnAy/E/3119OggX8SrWyBEcqAh8aOY1gr4gPvw76la2GlD4Ymf832UCVbmuWeTf2adkZDK+h0Z/fB4g== 89 | 90 | "@esbuild/openbsd-x64@0.19.5": 91 | version "0.19.5" 92 | resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.5.tgz#ca6830316ca68056c5c88a875f103ad3235e00db" 93 | integrity sha512-sPOfhtzFufQfTBgRnE1DIJjzsXukKSvZxloZbkJDG383q0awVAq600pc1nfqBcl0ice/WN9p4qLc39WhBShRTA== 94 | 95 | "@esbuild/sunos-x64@0.19.5": 96 | version "0.19.5" 97 | resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.5.tgz#9efc4eb9539a7be7d5a05ada52ee43cda0d8e2dd" 98 | integrity sha512-dGZkBXaafuKLpDSjKcB0ax0FL36YXCvJNnztjKV+6CO82tTYVDSH2lifitJ29jxRMoUhgkg9a+VA/B03WK5lcg== 99 | 100 | "@esbuild/win32-arm64@0.19.5": 101 | version "0.19.5" 102 | resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.5.tgz#29f8184afa7a02a956ebda4ed638099f4b8ff198" 103 | integrity sha512-dWVjD9y03ilhdRQ6Xig1NWNgfLtf2o/STKTS+eZuF90fI2BhbwD6WlaiCGKptlqXlURVB5AUOxUj09LuwKGDTg== 104 | 105 | "@esbuild/win32-ia32@0.19.5": 106 | version "0.19.5" 107 | resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.5.tgz#f3de07afb292ecad651ae4bb8727789de2d95b05" 108 | integrity sha512-4liggWIA4oDgUxqpZwrDhmEfAH4d0iljanDOK7AnVU89T6CzHon/ony8C5LeOdfgx60x5cnQJFZwEydVlYx4iw== 109 | 110 | "@esbuild/win32-x64@0.19.5": 111 | version "0.19.5" 112 | resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.5.tgz#faad84c41ba12e3a0acb52571df9bff37bee75f6" 113 | integrity sha512-czTrygUsB/jlM8qEW5MD8bgYU2Xg14lo6kBDXW6HdxKjh8M5PzETGiSHaz9MtbXBYDloHNUAUW2tMiKW4KM9Mw== 114 | 115 | "@isaacs/cliui@^8.0.2": 116 | version "8.0.2" 117 | resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" 118 | integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== 119 | dependencies: 120 | string-width "^5.1.2" 121 | string-width-cjs "npm:string-width@^4.2.0" 122 | strip-ansi "^7.0.1" 123 | strip-ansi-cjs "npm:strip-ansi@^6.0.1" 124 | wrap-ansi "^8.1.0" 125 | wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" 126 | 127 | "@pkgjs/parseargs@^0.11.0": 128 | version "0.11.0" 129 | resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" 130 | integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== 131 | 132 | "@popperjs/core@^2.11.8": 133 | version "2.11.8" 134 | resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" 135 | integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== 136 | 137 | ansi-regex@^5.0.1: 138 | version "5.0.1" 139 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" 140 | integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== 141 | 142 | ansi-regex@^6.0.1: 143 | version "6.0.1" 144 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" 145 | integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== 146 | 147 | ansi-styles@^4.0.0: 148 | version "4.3.0" 149 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" 150 | integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== 151 | dependencies: 152 | color-convert "^2.0.1" 153 | 154 | ansi-styles@^6.1.0: 155 | version "6.2.1" 156 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" 157 | integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== 158 | 159 | anymatch@~3.1.2: 160 | version "3.1.3" 161 | resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" 162 | integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== 163 | dependencies: 164 | normalize-path "^3.0.0" 165 | picomatch "^2.0.4" 166 | 167 | balanced-match@^1.0.0: 168 | version "1.0.2" 169 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" 170 | integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== 171 | 172 | binary-extensions@^2.0.0: 173 | version "2.2.0" 174 | resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" 175 | integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== 176 | 177 | bootstrap-icons@^1.11.1: 178 | version "1.11.1" 179 | resolved "https://registry.yarnpkg.com/bootstrap-icons/-/bootstrap-icons-1.11.1.tgz#79e32494871d8c98e9d14f4bcdc278cee9b1dafd" 180 | integrity sha512-F0DDp7nKUX+x/QtpfRZ+XHFya60ng9nfdpdS59vDDfs4Uhuxp7zym/QavMsu/xx51txkoM9eVmpE7D08N35blw== 181 | 182 | bootstrap@^5.3.2: 183 | version "5.3.2" 184 | resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.2.tgz#97226583f27aae93b2b28ab23f4c114757ff16ae" 185 | integrity sha512-D32nmNWiQHo94BKHLmOrdjlL05q1c8oxbtBphQFb9Z5to6eGRDCm0QgeaZ4zFBHzfg2++rqa2JkqCcxDy0sH0g== 186 | 187 | brace-expansion@^2.0.1: 188 | version "2.0.1" 189 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" 190 | integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== 191 | dependencies: 192 | balanced-match "^1.0.0" 193 | 194 | braces@~3.0.2: 195 | version "3.0.3" 196 | resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" 197 | integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== 198 | dependencies: 199 | fill-range "^7.1.1" 200 | 201 | "chokidar@>=3.0.0 <4.0.0": 202 | version "3.5.3" 203 | resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" 204 | integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== 205 | dependencies: 206 | anymatch "~3.1.2" 207 | braces "~3.0.2" 208 | glob-parent "~5.1.2" 209 | is-binary-path "~2.1.0" 210 | is-glob "~4.0.1" 211 | normalize-path "~3.0.0" 212 | readdirp "~3.6.0" 213 | optionalDependencies: 214 | fsevents "~2.3.2" 215 | 216 | color-convert@^2.0.1: 217 | version "2.0.1" 218 | resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" 219 | integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== 220 | dependencies: 221 | color-name "~1.1.4" 222 | 223 | color-name@~1.1.4: 224 | version "1.1.4" 225 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" 226 | integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== 227 | 228 | cross-spawn@^7.0.0: 229 | version "7.0.3" 230 | resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" 231 | integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== 232 | dependencies: 233 | path-key "^3.1.0" 234 | shebang-command "^2.0.0" 235 | which "^2.0.1" 236 | 237 | eastasianwidth@^0.2.0: 238 | version "0.2.0" 239 | resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" 240 | integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== 241 | 242 | emoji-regex@^8.0.0: 243 | version "8.0.0" 244 | resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" 245 | integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== 246 | 247 | emoji-regex@^9.2.2: 248 | version "9.2.2" 249 | resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" 250 | integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== 251 | 252 | esbuild-sass-plugin@^2.16.0: 253 | version "2.16.0" 254 | resolved "https://registry.yarnpkg.com/esbuild-sass-plugin/-/esbuild-sass-plugin-2.16.0.tgz#2908ab5e104cfc980118c46d0b409cbab8aa32dd" 255 | integrity sha512-mGCe9MxNYvZ+j77Q/QFO+rwUGA36mojDXkOhtVmoyz1zwYbMaNrtVrmXwwYDleS/UMKTNU3kXuiTtPiAD3K+Pw== 256 | dependencies: 257 | resolve "^1.22.6" 258 | sass "^1.7.3" 259 | 260 | esbuild@^0.19.0: 261 | version "0.19.5" 262 | resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.5.tgz#53a0e19dfbf61ba6c827d51a80813cf071239a8c" 263 | integrity sha512-bUxalY7b1g8vNhQKdB24QDmHeY4V4tw/s6Ak5z+jJX9laP5MoQseTOMemAr0gxssjNcH0MCViG8ONI2kksvfFQ== 264 | optionalDependencies: 265 | "@esbuild/android-arm" "0.19.5" 266 | "@esbuild/android-arm64" "0.19.5" 267 | "@esbuild/android-x64" "0.19.5" 268 | "@esbuild/darwin-arm64" "0.19.5" 269 | "@esbuild/darwin-x64" "0.19.5" 270 | "@esbuild/freebsd-arm64" "0.19.5" 271 | "@esbuild/freebsd-x64" "0.19.5" 272 | "@esbuild/linux-arm" "0.19.5" 273 | "@esbuild/linux-arm64" "0.19.5" 274 | "@esbuild/linux-ia32" "0.19.5" 275 | "@esbuild/linux-loong64" "0.19.5" 276 | "@esbuild/linux-mips64el" "0.19.5" 277 | "@esbuild/linux-ppc64" "0.19.5" 278 | "@esbuild/linux-riscv64" "0.19.5" 279 | "@esbuild/linux-s390x" "0.19.5" 280 | "@esbuild/linux-x64" "0.19.5" 281 | "@esbuild/netbsd-x64" "0.19.5" 282 | "@esbuild/openbsd-x64" "0.19.5" 283 | "@esbuild/sunos-x64" "0.19.5" 284 | "@esbuild/win32-arm64" "0.19.5" 285 | "@esbuild/win32-ia32" "0.19.5" 286 | "@esbuild/win32-x64" "0.19.5" 287 | 288 | fill-range@^7.1.1: 289 | version "7.1.1" 290 | resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" 291 | integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== 292 | dependencies: 293 | to-regex-range "^5.0.1" 294 | 295 | foreground-child@^3.1.0: 296 | version "3.1.1" 297 | resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" 298 | integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg== 299 | dependencies: 300 | cross-spawn "^7.0.0" 301 | signal-exit "^4.0.1" 302 | 303 | fs-extra@^11.1.0: 304 | version "11.1.1" 305 | resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.1.tgz#da69f7c39f3b002378b0954bb6ae7efdc0876e2d" 306 | integrity sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ== 307 | dependencies: 308 | graceful-fs "^4.2.0" 309 | jsonfile "^6.0.1" 310 | universalify "^2.0.0" 311 | 312 | fsevents@~2.3.2: 313 | version "2.3.3" 314 | resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" 315 | integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== 316 | 317 | function-bind@^1.1.2: 318 | version "1.1.2" 319 | resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" 320 | integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== 321 | 322 | glob-parent@~5.1.2: 323 | version "5.1.2" 324 | resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" 325 | integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== 326 | dependencies: 327 | is-glob "^4.0.1" 328 | 329 | glob@^10.3.3: 330 | version "10.3.10" 331 | resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.10.tgz#0351ebb809fd187fe421ab96af83d3a70715df4b" 332 | integrity sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g== 333 | dependencies: 334 | foreground-child "^3.1.0" 335 | jackspeak "^2.3.5" 336 | minimatch "^9.0.1" 337 | minipass "^5.0.0 || ^6.0.2 || ^7.0.0" 338 | path-scurry "^1.10.1" 339 | 340 | graceful-fs@^4.1.6, graceful-fs@^4.2.0: 341 | version "4.2.11" 342 | resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" 343 | integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== 344 | 345 | hanami-assets@^2.2.1: 346 | version "2.2.1" 347 | resolved "https://registry.yarnpkg.com/hanami-assets/-/hanami-assets-2.2.1.tgz#c2ef2e7da75df61d7b8b2d94729178cb8663c296" 348 | integrity sha512-/zE7COY/seadmeBR+TVGD29DnJClxWi/En6YQQIH51s5/xhUHiuucbLdicmTmhw2ousqQ1mN9uX27RPkLujs+g== 349 | dependencies: 350 | esbuild "^0.19.0" 351 | fs-extra "^11.1.0" 352 | glob "^10.3.3" 353 | 354 | hasown@^2.0.0: 355 | version "2.0.0" 356 | resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c" 357 | integrity sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA== 358 | dependencies: 359 | function-bind "^1.1.2" 360 | 361 | immutable@^4.0.0: 362 | version "4.3.4" 363 | resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.4.tgz#2e07b33837b4bb7662f288c244d1ced1ef65a78f" 364 | integrity sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA== 365 | 366 | is-binary-path@~2.1.0: 367 | version "2.1.0" 368 | resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" 369 | integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== 370 | dependencies: 371 | binary-extensions "^2.0.0" 372 | 373 | is-core-module@^2.13.0: 374 | version "2.13.1" 375 | resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" 376 | integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== 377 | dependencies: 378 | hasown "^2.0.0" 379 | 380 | is-extglob@^2.1.1: 381 | version "2.1.1" 382 | resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" 383 | integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== 384 | 385 | is-fullwidth-code-point@^3.0.0: 386 | version "3.0.0" 387 | resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" 388 | integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== 389 | 390 | is-glob@^4.0.1, is-glob@~4.0.1: 391 | version "4.0.3" 392 | resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" 393 | integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== 394 | dependencies: 395 | is-extglob "^2.1.1" 396 | 397 | is-number@^7.0.0: 398 | version "7.0.0" 399 | resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" 400 | integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== 401 | 402 | isexe@^2.0.0: 403 | version "2.0.0" 404 | resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" 405 | integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== 406 | 407 | jackspeak@^2.3.5: 408 | version "2.3.6" 409 | resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" 410 | integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== 411 | dependencies: 412 | "@isaacs/cliui" "^8.0.2" 413 | optionalDependencies: 414 | "@pkgjs/parseargs" "^0.11.0" 415 | 416 | jsonfile@^6.0.1: 417 | version "6.1.0" 418 | resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" 419 | integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== 420 | dependencies: 421 | universalify "^2.0.0" 422 | optionalDependencies: 423 | graceful-fs "^4.1.6" 424 | 425 | "lru-cache@^9.1.1 || ^10.0.0": 426 | version "10.0.1" 427 | resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.1.tgz#0a3be479df549cca0e5d693ac402ff19537a6b7a" 428 | integrity sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g== 429 | 430 | minimatch@^9.0.1: 431 | version "9.0.3" 432 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" 433 | integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== 434 | dependencies: 435 | brace-expansion "^2.0.1" 436 | 437 | "minipass@^5.0.0 || ^6.0.2 || ^7.0.0": 438 | version "7.0.4" 439 | resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" 440 | integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== 441 | 442 | normalize-path@^3.0.0, normalize-path@~3.0.0: 443 | version "3.0.0" 444 | resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" 445 | integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== 446 | 447 | path-key@^3.1.0: 448 | version "3.1.1" 449 | resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" 450 | integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== 451 | 452 | path-parse@^1.0.7: 453 | version "1.0.7" 454 | resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" 455 | integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== 456 | 457 | path-scurry@^1.10.1: 458 | version "1.10.1" 459 | resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.10.1.tgz#9ba6bf5aa8500fe9fd67df4f0d9483b2b0bfc698" 460 | integrity sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ== 461 | dependencies: 462 | lru-cache "^9.1.1 || ^10.0.0" 463 | minipass "^5.0.0 || ^6.0.2 || ^7.0.0" 464 | 465 | picomatch@^2.0.4, picomatch@^2.2.1: 466 | version "2.3.1" 467 | resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" 468 | integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== 469 | 470 | readdirp@~3.6.0: 471 | version "3.6.0" 472 | resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" 473 | integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== 474 | dependencies: 475 | picomatch "^2.2.1" 476 | 477 | resolve@^1.22.6: 478 | version "1.22.8" 479 | resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" 480 | integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== 481 | dependencies: 482 | is-core-module "^2.13.0" 483 | path-parse "^1.0.7" 484 | supports-preserve-symlinks-flag "^1.0.0" 485 | 486 | sass@^1.7.3: 487 | version "1.69.5" 488 | resolved "https://registry.yarnpkg.com/sass/-/sass-1.69.5.tgz#23e18d1c757a35f2e52cc81871060b9ad653dfde" 489 | integrity sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ== 490 | dependencies: 491 | chokidar ">=3.0.0 <4.0.0" 492 | immutable "^4.0.0" 493 | source-map-js ">=0.6.2 <2.0.0" 494 | 495 | shebang-command@^2.0.0: 496 | version "2.0.0" 497 | resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" 498 | integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== 499 | dependencies: 500 | shebang-regex "^3.0.0" 501 | 502 | shebang-regex@^3.0.0: 503 | version "3.0.0" 504 | resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" 505 | integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== 506 | 507 | signal-exit@^4.0.1: 508 | version "4.1.0" 509 | resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" 510 | integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== 511 | 512 | "source-map-js@>=0.6.2 <2.0.0": 513 | version "1.0.2" 514 | resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" 515 | integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== 516 | 517 | "string-width-cjs@npm:string-width@^4.2.0": 518 | version "4.2.3" 519 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 520 | integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== 521 | dependencies: 522 | emoji-regex "^8.0.0" 523 | is-fullwidth-code-point "^3.0.0" 524 | strip-ansi "^6.0.1" 525 | 526 | string-width@^4.1.0: 527 | version "4.2.3" 528 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 529 | integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== 530 | dependencies: 531 | emoji-regex "^8.0.0" 532 | is-fullwidth-code-point "^3.0.0" 533 | strip-ansi "^6.0.1" 534 | 535 | string-width@^5.0.1, string-width@^5.1.2: 536 | version "5.1.2" 537 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" 538 | integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== 539 | dependencies: 540 | eastasianwidth "^0.2.0" 541 | emoji-regex "^9.2.2" 542 | strip-ansi "^7.0.1" 543 | 544 | "strip-ansi-cjs@npm:strip-ansi@^6.0.1": 545 | version "6.0.1" 546 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 547 | integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== 548 | dependencies: 549 | ansi-regex "^5.0.1" 550 | 551 | strip-ansi@^6.0.0, strip-ansi@^6.0.1: 552 | version "6.0.1" 553 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 554 | integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== 555 | dependencies: 556 | ansi-regex "^5.0.1" 557 | 558 | strip-ansi@^7.0.1: 559 | version "7.1.0" 560 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" 561 | integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== 562 | dependencies: 563 | ansi-regex "^6.0.1" 564 | 565 | supports-preserve-symlinks-flag@^1.0.0: 566 | version "1.0.0" 567 | resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" 568 | integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== 569 | 570 | to-regex-range@^5.0.1: 571 | version "5.0.1" 572 | resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" 573 | integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== 574 | dependencies: 575 | is-number "^7.0.0" 576 | 577 | universalify@^2.0.0: 578 | version "2.0.1" 579 | resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" 580 | integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== 581 | 582 | which@^2.0.1: 583 | version "2.0.2" 584 | resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" 585 | integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== 586 | dependencies: 587 | isexe "^2.0.0" 588 | 589 | "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": 590 | version "7.0.0" 591 | resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" 592 | integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== 593 | dependencies: 594 | ansi-styles "^4.0.0" 595 | string-width "^4.1.0" 596 | strip-ansi "^6.0.0" 597 | 598 | wrap-ansi@^8.1.0: 599 | version "8.1.0" 600 | resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" 601 | integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== 602 | dependencies: 603 | ansi-styles "^6.1.0" 604 | string-width "^5.0.1" 605 | strip-ansi "^7.0.1" 606 | --------------------------------------------------------------------------------