From 1686c84b08a12eec101018a244765b466c40dc42 Mon Sep 17 00:00:00 2001 From: Chris Gaffney Date: Mon, 9 Mar 2026 20:24:06 -0400 Subject: [PATCH] Rewrite it in Zig --- .github/workflows/ci.yml | 42 +- .gitignore | 26 +- .mise.toml | 2 + .node-version | 1 - .rspec | 2 - .ruby-version | 1 - .standard.yml | 3 - .travis.yml | 18 - Dockerfile | 104 +--- Gemfile | 32 -- Gemfile.lock | 500 ------------------ Procfile | 2 - Procfile.dev | 2 - README.md | 140 ++++- app.json | 24 - app/assets/builds/.keep | 0 app/assets/config/manifest.js | 3 - app/assets/stylesheets/application.sass.scss | 7 - app/assets/stylesheets/base/_base.scss | 4 - app/assets/stylesheets/base/_elements.scss | 28 - app/assets/stylesheets/base/_keyframes.scss | 3 - app/assets/stylesheets/base/_reset.scss | 28 - app/assets/stylesheets/base/_variables.scss | 33 -- app/assets/stylesheets/components/_bulb.scss | 91 ---- app/assets/stylesheets/components/_light.scss | 44 -- .../stylesheets/components/_message.scss | 46 -- app/channels/application_cable/channel.rb | 4 - app/channels/application_cable/connection.rb | 4 - app/channels/colors_channel.rb | 9 - app/channels/device_channel.rb | 28 - app/controllers/api/application_controller.rb | 2 - app/controllers/api/devices_controller.rb | 12 - app/controllers/api/red_controller.rb | 10 - app/controllers/application_controller.rb | 2 - app/controllers/colors_controller.rb | 33 -- app/controllers/devices_controller.rb | 13 - app/controllers/webhooks_controller.rb | 17 - app/helpers/color_helper.rb | 34 -- app/interactors/parse_circle.rb | 20 - app/interactors/parse_github.rb | 26 - app/interactors/parse_travis.rb | 26 - app/interactors/trigger_particle.rb | 5 - app/interactors/trigger_webhook.rb | 11 - app/javascript/application.js | 3 - app/javascript/channels/colors_channel.js | 55 -- app/javascript/channels/consumer.js | 6 - app/javascript/channels/index.js | 2 - app/mailers/.gitkeep | 0 app/models/.gitkeep | 0 app/models/application_record.rb | 3 - app/models/device.rb | 46 -- app/models/status.rb | 47 -- app/views/api/red/show.html.erb | 18 - app/views/colors/index.html.erb | 39 -- app/views/layouts/application.html.erb | 18 - bin/bundle | 3 - bin/dev | 3 - bin/docker-entrypoint | 11 - bin/importmap | 4 - bin/rails | 4 - bin/rake | 4 - bin/rubocop | 8 - bin/setup | 37 -- bin/thrust | 5 - bin/update | 31 -- build.zig | 59 +++ build.zig.zon | 16 + config/application.example.yml | 5 - config/application.rb | 44 -- config/boot.rb | 4 - config/cable.yml | 6 - config/database.yml | 16 - config/dockerfile.yml | 16 - config/environment.rb | 5 - config/environments/development.rb | 69 --- config/environments/production.rb | 89 ---- config/environments/test.rb | 50 -- config/importmap.rb | 5 - .../application_controller_renderer.rb | 8 - config/initializers/assets.rb | 7 - config/initializers/backtrace_silencers.rb | 8 - .../initializers/content_security_policy.rb | 25 - config/initializers/cookies_serializer.rb | 5 - .../initializers/filter_parameter_logging.rb | 8 - config/initializers/inflections.rb | 16 - config/initializers/locale.rb | 9 - config/initializers/mime_types.rb | 5 - .../new_framework_defaults_5_2.rb | 38 -- .../new_framework_defaults_8_0.rb | 30 -- config/initializers/particle.rb | 3 - config/initializers/permissions_policy.rb | 13 - config/initializers/session_store.rb | 3 - config/initializers/wrap_parameters.rb | 14 - config/locales/en.yml | 33 -- config/puma.rb | 41 -- config/routes.rb | 19 - config/secrets.yml | 22 - config/storage.yml | 34 -- db/migrate/20121123160543_create_statuses.rb | 17 - .../20121123172057_add_payload_to_statuses.rb | 5 - ...2506_rename_status_to_color_on_statuses.rb | 13 - ...20121123195427_split_colors_on_statuses.rb | 16 - .../20121124190606_add_user_to_statuses.rb | 9 - db/migrate/20160510201736_create_devices.rb | 12 - ...0160510212722_add_identifier_to_devices.rb | 6 - .../20160510213407_add_name_to_devices.rb | 6 - .../20161012193415_add_service_to_status.rb | 14 - ...230303181951_add_webhook_url_to_devices.rb | 5 - .../20230304135152_add_slug_to_devices.rb | 7 - ...230_make_identifier_nullable_on_devices.rb | 5 - .../20230305131208_add_status_to_devices.rb | 8 - .../20230311115915_add_workflow_to_statues.rb | 5 - db/schema.rb | 56 -- db/seeds.rb | 7 - fly.toml | 45 +- lib/assets/.gitkeep | 0 lib/tasks/.gitkeep | 0 log/.gitkeep | 0 package-lock.json | 494 ----------------- package.json | 13 - public/application.css | 256 +++++++++ public/websocket.js | 76 +++ .../api/devices_controller_spec.rb | 31 -- spec/controllers/api/red_controller_spec.rb | 30 -- spec/controllers/colors_controller_spec.rb | 48 -- spec/controllers/devices_controller_spec.rb | 23 - spec/controllers/webhooks_controller_spec.rb | 73 --- spec/factories.rb | 20 - spec/fixtures/circle.json | 1 - spec/fixtures/circle_pr.json | 1 - spec/fixtures/github.json | 5 - spec/fixtures/travis.json | 1 - spec/interactors/parse_circle_spec.rb | 21 - spec/interactors/parse_github_spec.rb | 41 -- spec/interactors/parse_travis_spec.rb | 38 -- spec/interactors/trigger_webhook_spec.rb | 21 - spec/models/device_spec.rb | 75 --- spec/models/status_spec.rb | 105 ---- spec/rails_helper.rb | 50 -- spec/requests/circle_webhooks_spec.rb | 35 -- spec/spec_helper.rb | 85 --- spec/support/json_helpers.rb | 11 - src/db.zig | 50 ++ src/handlers.zig | 407 ++++++++++++++ src/main.zig | 134 +++++ src/migrations/001_initial_schema.sql | 43 ++ src/models.zig | 385 ++++++++++++++ src/parsers.zig | 331 ++++++++++++ src/templates.zig | 105 ++++ src/triggers.zig | 88 +++ src/websocket.zig | 140 +++++ templates/layout.html | 55 ++ templates/red.html | 3 + vendor/javascript/.keep | 0 154 files changed, 2338 insertions(+), 3707 deletions(-) create mode 100644 .mise.toml delete mode 100644 .node-version delete mode 100644 .rspec delete mode 100644 .ruby-version delete mode 100644 .standard.yml delete mode 100644 .travis.yml delete mode 100644 Gemfile delete mode 100644 Gemfile.lock delete mode 100644 Procfile delete mode 100644 Procfile.dev delete mode 100644 app.json delete mode 100644 app/assets/builds/.keep delete mode 100644 app/assets/config/manifest.js delete mode 100644 app/assets/stylesheets/application.sass.scss delete mode 100644 app/assets/stylesheets/base/_base.scss delete mode 100644 app/assets/stylesheets/base/_elements.scss delete mode 100644 app/assets/stylesheets/base/_keyframes.scss delete mode 100644 app/assets/stylesheets/base/_reset.scss delete mode 100644 app/assets/stylesheets/base/_variables.scss delete mode 100644 app/assets/stylesheets/components/_bulb.scss delete mode 100644 app/assets/stylesheets/components/_light.scss delete mode 100644 app/assets/stylesheets/components/_message.scss delete mode 100644 app/channels/application_cable/channel.rb delete mode 100644 app/channels/application_cable/connection.rb delete mode 100644 app/channels/colors_channel.rb delete mode 100644 app/channels/device_channel.rb delete mode 100644 app/controllers/api/application_controller.rb delete mode 100644 app/controllers/api/devices_controller.rb delete mode 100644 app/controllers/api/red_controller.rb delete mode 100644 app/controllers/application_controller.rb delete mode 100644 app/controllers/colors_controller.rb delete mode 100644 app/controllers/devices_controller.rb delete mode 100644 app/controllers/webhooks_controller.rb delete mode 100644 app/helpers/color_helper.rb delete mode 100644 app/interactors/parse_circle.rb delete mode 100644 app/interactors/parse_github.rb delete mode 100644 app/interactors/parse_travis.rb delete mode 100644 app/interactors/trigger_particle.rb delete mode 100644 app/interactors/trigger_webhook.rb delete mode 100644 app/javascript/application.js delete mode 100644 app/javascript/channels/colors_channel.js delete mode 100644 app/javascript/channels/consumer.js delete mode 100644 app/javascript/channels/index.js delete mode 100644 app/mailers/.gitkeep delete mode 100644 app/models/.gitkeep delete mode 100644 app/models/application_record.rb delete mode 100644 app/models/device.rb delete mode 100644 app/models/status.rb delete mode 100644 app/views/api/red/show.html.erb delete mode 100644 app/views/colors/index.html.erb delete mode 100644 app/views/layouts/application.html.erb delete mode 100755 bin/bundle delete mode 100755 bin/dev delete mode 100755 bin/docker-entrypoint delete mode 100755 bin/importmap delete mode 100755 bin/rails delete mode 100755 bin/rake delete mode 100755 bin/rubocop delete mode 100755 bin/setup delete mode 100755 bin/thrust delete mode 100755 bin/update create mode 100644 build.zig create mode 100644 build.zig.zon delete mode 100644 config/application.example.yml delete mode 100644 config/application.rb delete mode 100644 config/boot.rb delete mode 100644 config/cable.yml delete mode 100644 config/database.yml delete mode 100644 config/dockerfile.yml delete mode 100644 config/environment.rb delete mode 100644 config/environments/development.rb delete mode 100644 config/environments/production.rb delete mode 100644 config/environments/test.rb delete mode 100644 config/importmap.rb delete mode 100644 config/initializers/application_controller_renderer.rb delete mode 100644 config/initializers/assets.rb delete mode 100644 config/initializers/backtrace_silencers.rb delete mode 100644 config/initializers/content_security_policy.rb delete mode 100644 config/initializers/cookies_serializer.rb delete mode 100644 config/initializers/filter_parameter_logging.rb delete mode 100644 config/initializers/inflections.rb delete mode 100644 config/initializers/locale.rb delete mode 100644 config/initializers/mime_types.rb delete mode 100644 config/initializers/new_framework_defaults_5_2.rb delete mode 100644 config/initializers/new_framework_defaults_8_0.rb delete mode 100644 config/initializers/particle.rb delete mode 100644 config/initializers/permissions_policy.rb delete mode 100644 config/initializers/session_store.rb delete mode 100644 config/initializers/wrap_parameters.rb delete mode 100644 config/locales/en.yml delete mode 100644 config/puma.rb delete mode 100644 config/routes.rb delete mode 100644 config/secrets.yml delete mode 100644 config/storage.yml delete mode 100644 db/migrate/20121123160543_create_statuses.rb delete mode 100644 db/migrate/20121123172057_add_payload_to_statuses.rb delete mode 100644 db/migrate/20121123182506_rename_status_to_color_on_statuses.rb delete mode 100644 db/migrate/20121123195427_split_colors_on_statuses.rb delete mode 100644 db/migrate/20121124190606_add_user_to_statuses.rb delete mode 100644 db/migrate/20160510201736_create_devices.rb delete mode 100644 db/migrate/20160510212722_add_identifier_to_devices.rb delete mode 100644 db/migrate/20160510213407_add_name_to_devices.rb delete mode 100644 db/migrate/20161012193415_add_service_to_status.rb delete mode 100644 db/migrate/20230303181951_add_webhook_url_to_devices.rb delete mode 100644 db/migrate/20230304135152_add_slug_to_devices.rb delete mode 100644 db/migrate/20230304144230_make_identifier_nullable_on_devices.rb delete mode 100644 db/migrate/20230305131208_add_status_to_devices.rb delete mode 100644 db/migrate/20230311115915_add_workflow_to_statues.rb delete mode 100644 db/schema.rb delete mode 100644 db/seeds.rb delete mode 100644 lib/assets/.gitkeep delete mode 100644 lib/tasks/.gitkeep delete mode 100644 log/.gitkeep delete mode 100644 package-lock.json delete mode 100644 package.json create mode 100644 public/application.css create mode 100644 public/websocket.js delete mode 100644 spec/controllers/api/devices_controller_spec.rb delete mode 100644 spec/controllers/api/red_controller_spec.rb delete mode 100644 spec/controllers/colors_controller_spec.rb delete mode 100644 spec/controllers/devices_controller_spec.rb delete mode 100644 spec/controllers/webhooks_controller_spec.rb delete mode 100644 spec/factories.rb delete mode 100644 spec/fixtures/circle.json delete mode 100644 spec/fixtures/circle_pr.json delete mode 100644 spec/fixtures/github.json delete mode 100644 spec/fixtures/travis.json delete mode 100644 spec/interactors/parse_circle_spec.rb delete mode 100644 spec/interactors/parse_github_spec.rb delete mode 100644 spec/interactors/parse_travis_spec.rb delete mode 100644 spec/interactors/trigger_webhook_spec.rb delete mode 100644 spec/models/device_spec.rb delete mode 100644 spec/models/status_spec.rb delete mode 100644 spec/rails_helper.rb delete mode 100644 spec/requests/circle_webhooks_spec.rb delete mode 100644 spec/spec_helper.rb delete mode 100644 spec/support/json_helpers.rb create mode 100644 src/db.zig create mode 100644 src/handlers.zig create mode 100644 src/main.zig create mode 100644 src/migrations/001_initial_schema.sql create mode 100644 src/models.zig create mode 100644 src/parsers.zig create mode 100644 src/templates.zig create mode 100644 src/triggers.zig create mode 100644 src/websocket.zig create mode 100644 templates/layout.html create mode 100644 templates/red.html delete mode 100644 vendor/javascript/.keep diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83a2d42..e7434d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,46 +13,28 @@ jobs: services: postgres: - image: postgres:14 + image: postgres:16 env: - POSTGRES_USER: test - POSTGRES_PASSWORD: correcthorsebatterystaple - POSTGRES_DB: test + POSTGRES_USER: buildlight + POSTGRES_PASSWORD: buildlight + POSTGRES_DB: buildlight_test ports: - 5432:5432 - # needed because the postgres container does not provide a healthcheck options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 env: - DATABASE_URL: postgres://test:correcthorsebatterystaple@localhost:5432/test - RAILS_ENV: test - RUBYOPT: --enable=frozen-string-literal + TEST_DATABASE_URL: postgresql://buildlight:buildlight@localhost:5432/buildlight_test steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 + - name: Set up Zig + uses: mlugg/setup-zig@v2 with: - bundler-cache: true + version: 0.15.2 - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version-file: 'package.json' - cache: 'npm' - - - name: NPM Install - run: npm ci - - - name: App Setup - run: bin/setup - - - name: Build CSS - run: npm run build:css - - - name: Standard - run: bundle exec rake standard + - name: Build + run: zig build - name: Run Tests - run: bundle exec rspec --format progress --color + run: zig build test diff --git a/.gitignore b/.gitignore index bd63473..e431527 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,3 @@ -# See http://help.github.com/ignore-files/ for more about ignoring files. -# -# If you find yourself ignoring temporary files generated by your text editor -# or operating system, you probably want to add a global ignore instead: -# git config --global core.excludesfile ~/.gitignore_global - -# Ignore bundler config -/.bundle - -# Ignore the default SQLite database. -/db/*.sqlite3 - -# Ignore all logfiles and tempfiles. -/log/*.log -/tmp - -# Ignore application configuration -/config/application.yml - -/app/assets/builds/* -!/app/assets/builds/.keep - -/node_modules +# Zig build artifacts +/.zig-cache/ +/zig-out/ diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..d15bec3 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +zig = "0.15" diff --git a/.node-version b/.node-version deleted file mode 100644 index 58a1f09..0000000 --- a/.node-version +++ /dev/null @@ -1 +0,0 @@ -22.4.0 diff --git a/.rspec b/.rspec deleted file mode 100644 index 83e16f8..0000000 --- a/.rspec +++ /dev/null @@ -1,2 +0,0 @@ ---color ---require spec_helper diff --git a/.ruby-version b/.ruby-version deleted file mode 100644 index f989260..0000000 --- a/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -3.4.4 diff --git a/.standard.yml b/.standard.yml deleted file mode 100644 index 2d3d5c4..0000000 --- a/.standard.yml +++ /dev/null @@ -1,3 +0,0 @@ -plugins: - - standard-performance - - standard-rails diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6422ecd..0000000 --- a/.travis.yml +++ /dev/null @@ -1,18 +0,0 @@ -language: ruby -rvm: - - 3.3.1 -sudo: false -addons: - postgresql: '9.4' -branches: - only: - - master -before_script: - - bin/setup - - bundle exec rake db:migrate -notifications: - webhooks: - urls: - - https://buildlight.collectiveidea.com/ - on_start: always -cache: bundler diff --git a/Dockerfile b/Dockerfile index 6392aef..8182028 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,96 +1,24 @@ -# syntax=docker/dockerfile:1 -# check=error=true +FROM debian:stable-slim AS builder -# Make sure RUBY_VERSION matches the Ruby version in .ruby-version -ARG RUBY_VERSION=3.4.4 -FROM ruby:$RUBY_VERSION-alpine AS base +# Install Zig +RUN apt-get update && apt-get install -y curl xz-utils && \ + curl -L https://ziglang.org/download/0.15.2/zig-x86_64-linux.tar.xz | tar xJ && \ + mv zig-* /opt/zig -LABEL fly_launch_runtime="rails" - -# Rails app lives here -WORKDIR /rails - -# Update gems and bundler -RUN gem update --system --no-document && \ - gem install -N bundler - -# Install base packages -RUN apk add --no-cache curl jemalloc postgresql-client tzdata - -# Set production environment -ENV BUNDLE_DEPLOYMENT="1" \ - BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development:test" \ - RAILS_ENV="production" - - -# Throw-away build stages to reduce size of final image -FROM base AS prebuild - -# Install packages needed to build gems and node modules -RUN apk add --no-cache build-base git gyp libpq-dev pkgconfig python3 yaml-dev - - -FROM prebuild AS node - -# Install Node.js -ARG NODE_VERSION=22.4.0 -ENV PATH=/usr/local/node/bin:$PATH -RUN curl -sL https://unofficial-builds.nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64-musl.tar.gz | tar xz -C /tmp/ && \ - mkdir /usr/local/node && \ - cp -rp /tmp/node-v${NODE_VERSION}-linux-x64-musl/* /usr/local/node/ && \ - rm -rf /tmp/node-v${NODE_VERSION}-linux-x64-musl - -# Install node modules -COPY package.json ./ -RUN npm install - - -FROM prebuild AS build - -# Install application gems -COPY Gemfile Gemfile.lock .ruby-version ./ -RUN bundle install && \ - rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ - bundle exec bootsnap precompile --gemfile - -# Copy node modules -COPY --from=node /rails/node_modules /rails/node_modules -COPY --from=node /usr/local/node /usr/local/node -ENV PATH=/usr/local/node/bin:$PATH - -# Copy application code +WORKDIR /app COPY . . -# Precompile bootsnap code for faster boot times -RUN bundle exec bootsnap precompile app/ lib/ - -# Adjust binfiles to set current working directory -RUN grep -l '#!/usr/bin/env ruby' /rails/bin/* | xargs sed -i '/^#!/aDir.chdir File.expand_path("..", __dir__)' - -# Precompiling assets for production without requiring secret RAILS_MASTER_KEY -RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile - - -# Final stage for app image -FROM base - -# Install packages needed for deployment -RUN apk add --no-cache gzip libpq +ENV PATH="/opt/zig:$PATH" +RUN zig build -Doptimize=ReleaseSafe -# Copy built artifacts: gems, application -COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" -COPY --from=build /rails /rails +FROM debian:stable-slim -# Run and own only the runtime files as a non-root user for security -RUN addgroup --system --gid 1000 rails && \ - adduser --system rails --uid 1000 --ingroup rails --home /home/rails --shell /bin/sh rails && \ - chown -R 1000:1000 db log tmp -USER 1000:1000 +# Install CA certificates for outbound HTTPS (triggers) +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* -# Entrypoint sets up the container. -ENTRYPOINT ["/rails/bin/docker-entrypoint"] +COPY --from=builder /app/zig-out/bin/buildlight /usr/local/bin/buildlight +# Copy public files for Fly.io [[statics]] to serve +COPY --from=builder /app/public /app/public -# Start the server by default, this can be overwritten at runtime -EXPOSE 3000 -CMD ["./bin/rails", "server"] +EXPOSE 8080 +CMD ["buildlight"] diff --git a/Gemfile b/Gemfile deleted file mode 100644 index f7abd31..0000000 --- a/Gemfile +++ /dev/null @@ -1,32 +0,0 @@ -source "https://rubygems.org" - -ruby file: ".ruby-version" - -gem "rails", "~> 8.0.1" - -gem "pg" - -gem "bootsnap" -gem "dockerfile-rails" -gem "honeybadger" -gem "ostruct" # Required by particlerb -gem "particlerb" -gem "puma" - -gem "importmap-rails" -gem "cssbundling-rails" -gem "propshaft" - -group :development, :test do - gem "debug" - gem "factory_bot_rails" - gem "figaro" - gem "rspec-rails" - gem "standard" - gem "standard-performance" - gem "standard-rails" -end - -group :test do - gem "rspec-ontap", require: false -end diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index d57b255..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,500 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - actioncable (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) - nio4r (~> 2.0) - websocket-driver (>= 0.6.1) - zeitwerk (~> 2.6) - actionmailbox (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) - mail (>= 2.8.0) - actionmailer (8.0.2) - actionpack (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activesupport (= 8.0.2) - mail (>= 2.8.0) - rails-dom-testing (~> 2.2) - actionpack (8.0.2) - actionview (= 8.0.2) - activesupport (= 8.0.2) - nokogiri (>= 1.8.5) - rack (>= 2.2.4) - rack-session (>= 1.0.1) - rack-test (>= 0.6.3) - rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) - useragent (~> 0.16) - actiontext (8.0.2) - actionpack (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) - globalid (>= 0.6.0) - nokogiri (>= 1.8.5) - actionview (8.0.2) - activesupport (= 8.0.2) - builder (~> 3.1) - erubi (~> 1.11) - rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) - activejob (8.0.2) - activesupport (= 8.0.2) - globalid (>= 0.3.6) - activemodel (8.0.2) - activesupport (= 8.0.2) - activerecord (8.0.2) - activemodel (= 8.0.2) - activesupport (= 8.0.2) - timeout (>= 0.4.0) - activestorage (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activesupport (= 8.0.2) - marcel (~> 1.0) - activesupport (8.0.2) - base64 - benchmark (>= 0.3) - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - logger (>= 1.4.2) - minitest (>= 5.1) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) - uri (>= 0.13.1) - ansi (1.5.0) - ast (2.4.3) - base64 (0.2.0) - benchmark (0.4.0) - bigdecimal (3.1.9) - bootsnap (1.18.6) - msgpack (~> 1.2) - builder (3.3.0) - concurrent-ruby (1.3.5) - connection_pool (2.5.3) - crass (1.0.6) - cssbundling-rails (1.4.3) - railties (>= 6.0.0) - date (3.4.1) - debug (1.10.0) - irb (~> 1.10) - reline (>= 0.3.8) - diff-lcs (1.6.2) - dockerfile-rails (1.7.9) - rails (>= 3.0.0) - drb (2.2.3) - erb (5.0.1) - erubi (1.13.1) - factory_bot (6.5.1) - activesupport (>= 6.1.0) - factory_bot_rails (6.4.4) - factory_bot (~> 6.5) - railties (>= 5.0.0) - faraday (1.10.4) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) - faraday-multipart (1.1.0) - multipart-post (~> 2.0) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.3) - faraday_middleware (1.2.1) - faraday (~> 1.0) - figaro (1.2.0) - thor (>= 0.14.0, < 2) - globalid (1.2.1) - activesupport (>= 6.1) - honeybadger (5.28.0) - logger - ostruct - i18n (1.14.7) - concurrent-ruby (~> 1.0) - importmap-rails (2.1.0) - actionpack (>= 6.0.0) - activesupport (>= 6.0.0) - railties (>= 6.0.0) - io-console (0.8.0) - irb (1.15.2) - pp (>= 0.6.0) - rdoc (>= 4.0.0) - reline (>= 0.4.2) - json (2.12.1) - language_server-protocol (3.17.0.5) - lint_roller (1.1.0) - logger (1.7.0) - loofah (2.24.1) - crass (~> 1.0.2) - nokogiri (>= 1.12.0) - mail (2.8.1) - mini_mime (>= 0.1.1) - net-imap - net-pop - net-smtp - marcel (1.0.4) - mini_mime (1.1.5) - mini_portile2 (2.8.9) - minitest (5.25.5) - msgpack (1.8.0) - multipart-post (2.4.1) - net-imap (0.5.8) - date - net-protocol - net-pop (0.1.2) - net-protocol - net-protocol (0.2.2) - timeout - net-smtp (0.5.1) - net-protocol - nio4r (2.7.4) - nokogiri (1.18.8) - mini_portile2 (~> 2.8.2) - racc (~> 1.4) - nokogiri (1.18.8-aarch64-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.8-arm64-darwin) - racc (~> 1.4) - nokogiri (1.18.8-x86_64-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.8-x86_64-linux-musl) - racc (~> 1.4) - ostruct (0.6.1) - parallel (1.27.0) - parser (3.3.8.0) - ast (~> 2.4.1) - racc - particlerb (2.1.0) - faraday (>= 0.9.0) - faraday_middleware (>= 0.9.0) - pg (1.5.9) - pp (0.6.2) - prettyprint - prettyprint (0.2.0) - prism (1.4.0) - propshaft (1.1.0) - actionpack (>= 7.0.0) - activesupport (>= 7.0.0) - rack - railties (>= 7.0.0) - psych (5.2.6) - date - stringio - puma (6.6.0) - nio4r (~> 2.0) - racc (1.8.1) - rack (3.1.15) - rack-session (2.1.1) - base64 (>= 0.1.0) - rack (>= 3.0.0) - rack-test (2.2.0) - rack (>= 1.3) - rackup (2.2.1) - rack (>= 3) - rails (8.0.2) - actioncable (= 8.0.2) - actionmailbox (= 8.0.2) - actionmailer (= 8.0.2) - actionpack (= 8.0.2) - actiontext (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activemodel (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) - bundler (>= 1.15.0) - railties (= 8.0.2) - rails-dom-testing (2.3.0) - activesupport (>= 5.0.0) - minitest - nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) - nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) - irb (~> 1.13) - rackup (>= 1.0.0) - rake (>= 12.2) - thor (~> 1.0, >= 1.2.2) - zeitwerk (~> 2.6) - rainbow (3.1.1) - rake (13.2.1) - rdoc (6.14.0) - erb - psych (>= 4.0.0) - regexp_parser (2.10.0) - reline (0.6.1) - io-console (~> 0.5) - rspec (3.13.0) - rspec-core (~> 3.13.0) - rspec-expectations (~> 3.13.0) - rspec-mocks (~> 3.13.0) - rspec-core (3.13.3) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.4) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.4) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-ontap (0.3.1) - rspec - tapout - rspec-rails (8.0.0) - actionpack (>= 7.2) - activesupport (>= 7.2) - railties (>= 7.2) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) - rspec-support (3.13.3) - rubocop (1.75.7) - json (~> 2.3) - language_server-protocol (~> 3.17.0.2) - lint_roller (~> 1.1.0) - parallel (~> 1.10) - parser (>= 3.3.0.2) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.44.0, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.44.1) - parser (>= 3.3.7.2) - prism (~> 1.4) - rubocop-performance (1.25.0) - lint_roller (~> 1.1) - rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rails (2.31.0) - activesupport (>= 4.2.0) - lint_roller (~> 1.1) - rack (>= 1.1) - rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) - ruby-progressbar (1.13.0) - ruby2_keywords (0.0.5) - securerandom (0.4.1) - standard (1.50.0) - language_server-protocol (~> 3.17.0.2) - lint_roller (~> 1.0) - rubocop (~> 1.75.5) - standard-custom (~> 1.0.0) - standard-performance (~> 1.8) - standard-custom (1.0.2) - lint_roller (~> 1.0) - rubocop (~> 1.50) - standard-performance (1.8.0) - lint_roller (~> 1.1) - rubocop-performance (~> 1.25.0) - standard-rails (1.4.0) - lint_roller (~> 1.0) - rubocop-rails (~> 2.31.0) - stringio (3.1.7) - tapout (0.4.5) - ansi - json - thor (1.3.2) - timeout (0.4.3) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) - uri (1.0.3) - useragent (0.16.11) - websocket-driver (0.7.7) - base64 - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.5) - zeitwerk (2.7.3) - -PLATFORMS - aarch64-linux - arm64-darwin - ruby - x86_64-linux - x86_64-linux-musl - -DEPENDENCIES - bootsnap - cssbundling-rails - debug - dockerfile-rails - factory_bot_rails - figaro - honeybadger - importmap-rails - ostruct - particlerb - pg - propshaft - puma - rails (~> 8.0.1) - rspec-ontap - rspec-rails - standard - standard-performance - standard-rails - -CHECKSUMS - actioncable (8.0.2) sha256=7bcce2df62e91a80143592600e16583c273e98aab50ae40a9f6a2604bb3289a0 - actionmailbox (8.0.2) sha256=3d8fb3453913e6257da4d02004bbfa2b997dfd10672f8d990e95013983e2cedb - actionmailer (8.0.2) sha256=b0c968b38576ec56a3dc2795931818e0aaae6a18cc9801f53f175c12d4b277d0 - actionpack (8.0.2) sha256=93e703064f3815295ccf820f57acbca719aec836749597da9262781c9b2f4b78 - actiontext (8.0.2) sha256=a40db32032ee2fbb479d5d69318c4284344c1cda73836fd73ffcdb917d203abf - actionview (8.0.2) sha256=e038e1405cdfc18f04f17243da4fb8eeda3a4992f63a6d70a7281d255cf7cebb - activejob (8.0.2) sha256=b0228b45e36b1ef3a081c684e81494147e094a6baf729018756ccf125b1853ca - activemodel (8.0.2) sha256=0ae1fb7fa1fae0699ba041a9e97702df42ea3b13f2d39f2d0fde51fca5f0656c - activerecord (8.0.2) sha256=793470b92c44e4198d0262ac60086b7822f0ea585079ad67e32a6e4c86f2d90a - activestorage (8.0.2) sha256=f83d221e0f06ae38f2200e55490bd155c76d0add330f6e300e8646048d672977 - activesupport (8.0.2) sha256=8565cddba31b900cdc17682fd66ecd020441e3eef320a9930285394e8c07a45e - ansi (1.5.0) sha256=5408253274e33d9d27d4a98c46d2998266fd51cba58a7eb9d08f50e57ed23592 - ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 - base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507 - benchmark (0.4.0) sha256=0f12f8c495545e3710c3e4f0480f63f06b4c842cc94cec7f33a956f5180e874a - bigdecimal (3.1.9) sha256=2ffc742031521ad69c2dfc815a98e426a230a3d22aeac1995826a75dabfad8cc - bootsnap (1.18.6) sha256=0ae2393c1e911e38be0f24e9173e7be570c3650128251bf06240046f84a07d00 - builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f - concurrent-ruby (1.3.5) sha256=813b3e37aca6df2a21a3b9f1d497f8cbab24a2b94cab325bffe65ee0f6cbebc6 - connection_pool (2.5.3) sha256=cfd74a82b9b094d1ce30c4f1a346da23ee19dc8a062a16a85f58eab1ced4305b - crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d - cssbundling-rails (1.4.3) sha256=53aecd5a7d24ac9c8fcd92975acd0e830fead4ee4583d3d3d49bb64651946e41 - date (3.4.1) sha256=bf268e14ef7158009bfeaec40b5fa3c7271906e88b196d958a89d4b408abe64f - debug (1.10.0) sha256=11e28ca74875979e612444104f3972bd5ffb9e79179907d7ad46dba44bd2e7a4 - diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 - dockerfile-rails (1.7.9) sha256=a27d2f8c772ca2cd8cf8f3fcaea09a2a00c5bb1eaaca125f92e86cab3f5e35f8 - drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 - erb (5.0.1) sha256=760439803b36cc93eca8a266aab614614e588024a89bc30a62e78d98ff452c23 - erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 - factory_bot (6.5.1) sha256=40581ea7bec0aee05514b8f4f99ed477274bdf1884c1372de5209e60322d6ca9 - factory_bot_rails (6.4.4) sha256=139e17caa2c50f098fddf5e5e1f29e8067352024e91ca1186d018b36589e5c88 - faraday (1.10.4) sha256=a384c541cde688d68bf85055723aecb4100c3fa41b53beb2011b245960ab2f19 - faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 - faraday-em_synchrony (1.0.0) sha256=460dad1c30cc692d6e77d4c391ccadb4eca4854b315632cd7e560f74275cf9ed - faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940 - faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b - faraday-multipart (1.1.0) sha256=856b0f1c7316a4d6c052dd2eef5c42f887d56d93a171fe8880da1af064ca0751 - faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682 - faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335 - faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7 - faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0 - faraday-retry (1.0.3) sha256=add154f4f399243cbe070806ed41b96906942e7f5259bb1fe6daf2ec8f497194 - faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9 - figaro (1.2.0) sha256=5c035fec76e597226e591ce4501aba37e059013d87ab47a1de5ab3cd0649e2fa - globalid (1.2.1) sha256=70bf76711871f843dbba72beb8613229a49429d1866828476f9c9d6ccc327ce9 - honeybadger (5.28.0) sha256=010acb869e49ea18796a2a3293aa99103802fdca3a94181db9a0163f1ead6431 - i18n (1.14.7) sha256=ceba573f8138ff2c0915427f1fc5bdf4aa3ab8ae88c8ce255eb3ecf0a11a5d0f - importmap-rails (2.1.0) sha256=9f10c67d60651a547579f448100d033df311c5d5db578301374aeb774faae741 - io-console (0.8.0) sha256=cd6a9facbc69871d69b2cb8b926fc6ea7ef06f06e505e81a64f14a470fddefa2 - irb (1.15.2) sha256=222f32952e278da34b58ffe45e8634bf4afc2dc7aa9da23fed67e581aa50fdba - json (2.12.1) sha256=fc8fe305c17c09c8ce6bc825f9aa61bcf232d835669b10472dffa862796f203b - language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc - lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 - logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 - loofah (2.24.1) sha256=655a30842b70ec476410b347ab1cd2a5b92da46a19044357bbd9f401b009a337 - mail (2.8.1) sha256=ec3b9fadcf2b3755c78785cb17bc9a0ca9ee9857108a64b6f5cfc9c0b5bfc9ad - marcel (1.0.4) sha256=0d5649feb64b8f19f3d3468b96c680bae9746335d02194270287868a661516a4 - mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef - mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289 - minitest (5.25.5) sha256=391b6c6cb43a4802bfb7c93af1ebe2ac66a210293f4a3fb7db36f2fc7dc2c756 - msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 - multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 - net-imap (0.5.8) sha256=52aa5fdfc1a8a3df1f793b20a327e95b5a9dfe1d733e1f0d53075d2dbcfcf593 - net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 - net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 - net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 - nio4r (2.7.4) sha256=d95dee68e0bb251b8ff90ac3423a511e3b784124e5db7ff5f4813a220ae73ca9 - nokogiri (1.18.8) sha256=8c7464875d9ca7f71080c24c0db7bcaa3940e8be3c6fc4bcebccf8b9a0016365 - nokogiri (1.18.8-aarch64-linux-gnu) sha256=36badd2eb281fca6214a5188e24a34399b15d89730639a068d12931e2adc210e - nokogiri (1.18.8-arm64-darwin) sha256=483b5b9fb33653f6f05cbe00d09ea315f268f0e707cfc809aa39b62993008212 - nokogiri (1.18.8-x86_64-linux-gnu) sha256=4a747875db873d18a2985ee2c320a6070c4a414ad629da625fbc58d1a20e5ecc - nokogiri (1.18.8-x86_64-linux-musl) sha256=ddd735fba49475a395b9ea793bb6474e3a3125b89960339604d08a5397de1165 - ostruct (0.6.1) sha256=09a3fb7ecc1fa4039f25418cc05ae9c82bd520472c5c6a6f515f03e4988cb817 - parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 - parser (3.3.8.0) sha256=2476364142b307fa5a1b1ece44f260728be23858a9c71078e956131a75453c45 - particlerb (2.1.0) sha256=3a644d14863e0c0cccb89bd5cacea60117ca6fdc058a7c01c7ba9bfa3bdd6859 - pg (1.5.9) sha256=761efbdf73b66516f0c26fcbe6515dc7500c3f0aa1a1b853feae245433c64fdc - pp (0.6.2) sha256=947ec3120c6f92195f8ee8aa25a7b2c5297bb106d83b41baa02983686577b6ff - prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 - prism (1.4.0) sha256=dc0e3e00e93160213dc2a65519d9002a4a1e7b962db57d444cf1a71565bb703e - propshaft (1.1.0) sha256=d389361faf66aeb17e8d204828962c1e506edd14a1a17adb3fa475435c070f6b - psych (5.2.6) sha256=814328aa5dcb6d604d32126a20bc1cbcf05521a5b49dbb1a8b30a07e580f316e - puma (6.6.0) sha256=f25c06873eb3d5de5f0a4ebc783acc81a4ccfe580c760cfe323497798018ad87 - racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f - rack (3.1.15) sha256=d12b3e9960d18a26ded961250f2c0e3b375b49ff40dbe6786e9c3b160cbffca4 - rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 - rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 - rackup (2.2.1) sha256=f737191fd5c5b348b7f0a4412a3b86383f88c43e13b8217b63d4c8d90b9e798d - rails (8.0.2) sha256=fdfaa5a83ec0388e02864e88d515959caedc88053b5f701c4deb1652d8f164c6 - rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d - rails-html-sanitizer (1.6.2) sha256=35fce2ca8242da8775c83b6ba9c1bcaad6751d9eb73c1abaa8403475ab89a560 - railties (8.0.2) sha256=0d7c3f40c49ba74980f1bac1d4bb153a9331c5ee8a9631d89c7bf79db82e5cf9 - rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a - rake (13.2.1) sha256=46cb38dae65d7d74b6020a4ac9d48afed8eb8149c040eccf0523bec91907059d - rdoc (6.14.0) sha256=2c46de58d7129b8743fcf6d76e3db971bdc914150e15ac06b386549bd82ed7db - regexp_parser (2.10.0) sha256=cb6f0ddde88772cd64bff1dbbf68df66d376043fe2e66a9ef77fcb1b0c548c61 - reline (0.6.1) sha256=1afcc9d7cb1029cdbe780d72f2f09251ce46d3780050f3ec39c3ccc6b60675fb - rspec (3.13.0) sha256=d490914ac1d5a5a64a0e1400c1d54ddd2a501324d703b8cfe83f458337bab993 - rspec-core (3.13.3) sha256=25136507f4f9cf2e8977a2851e64e438b4331646054e345998714108745cdfe4 - rspec-expectations (3.13.4) sha256=4e43459765dfee900b25aa1361e106ab0799895ede65fc57872069feb559ecd8 - rspec-mocks (3.13.4) sha256=6bb158a0719c53d522104ed34c0777b884b2c9dc775ce64eaa10207df02ab993 - rspec-ontap (0.3.1) sha256=4560ff3b87e10b42a95447efc50a33a1098dba4d70756a7ecebf102c64b77b48 - rspec-rails (8.0.0) sha256=977a508cd94d152db2068c6585470db5d0cd47eef56d5410b9531034fb9d97bf - rspec-support (3.13.3) sha256=2a61e393f6e18b7228726e0c6869c5d5a1419d37206116c4d917d145276b3f43 - rubocop (1.75.7) sha256=23566ebb25263f26020687f8abb8aec049f3e29b6a00bdf0aa9d1db16b558be9 - rubocop-ast (1.44.1) sha256=e3cc04203b2ef04f6d6cf5f85fe6d643f442b18cc3b23e3ada0ce5b6521b8e92 - rubocop-performance (1.25.0) sha256=6f7d03568a770054117a78d0a8e191cefeffb703b382871ca7743831b1a52ec1 - rubocop-rails (2.31.0) sha256=79476e1075299c3e60fc50549c7c32614f9ebaae719b899ed75785c6786c52bd - ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 - ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef - securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - standard (1.50.0) sha256=b6c67f61fd6cedeec90ee338c6e913d9ccc4c467660ad1575da8aa6ba10f4aec - standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b - standard-performance (1.8.0) sha256=ed17b7d0e061b2a19a91dd434bef629439e2f32310f22f26acb451addc92b788 - standard-rails (1.4.0) sha256=444d6cb8a096a9249e617ad693b02a714927a6bebe79320dc839b840c857f4c9 - stringio (3.1.7) sha256=5b78b7cb242a315fb4fca61a8255d62ec438f58da2b90be66048546ade4507fa - tapout (0.4.5) sha256=abae2ca6a24c9834f0e610128269d2d3627d5308705830efb77afa5569a6d148 - thor (1.3.2) sha256=eef0293b9e24158ccad7ab383ae83534b7ad4ed99c09f96f1a6b036550abbeda - timeout (0.4.3) sha256=9509f079b2b55fe4236d79633bd75e34c1c1e7e3fb4b56cb5fda61f80a0fe30e - tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b - unicode-display_width (3.1.4) sha256=8caf2af1c0f2f07ec89ef9e18c7d88c2790e217c482bfc78aaa65eadd5415ac1 - unicode-emoji (4.0.4) sha256=2c2c4ef7f353e5809497126285a50b23056cc6e61b64433764a35eff6c36532a - uri (1.0.3) sha256=e9f2244608eea2f7bc357d954c65c910ce0399ca5e18a7a29207ac22d8767011 - useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 - websocket-driver (0.7.7) sha256=056d99f2cd545712cfb1291650fde7478e4f2661dc1db6a0fa3b966231a146b4 - websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 - zeitwerk (2.7.3) sha256=b2e86b4a9b57d26ba68a15230dcc7fe6f040f06831ce64417b0621ad96ba3e85 - -RUBY VERSION - ruby 3.4.4p34 - -BUNDLED WITH - 2.6.9 diff --git a/Procfile b/Procfile deleted file mode 100644 index 5691041..0000000 --- a/Procfile +++ /dev/null @@ -1,2 +0,0 @@ -web: bundle exec puma -t 1:32 -b tcp://0.0.0.0:$PORT -release: rake db:migrate diff --git a/Procfile.dev b/Procfile.dev deleted file mode 100644 index c086f6a..0000000 --- a/Procfile.dev +++ /dev/null @@ -1,2 +0,0 @@ -web: bin/rails server -p 3000 -css: npm run build:css -- --watch diff --git a/README.md b/README.md index 8eb3b6a..6fdaf43 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,148 @@ Go to your project settings in Circle CI and add a new Webhook with `https://bui ## Viewing Status -The [main website](https://buildlight.collectiveidea.com/) shows the basic status for all projects. Adding a user/organization name to the url shows just those projects, for example: [https://buildlight.collectiveidea.com/collectiveidea](https://buildlight.collectiveidea.com/collectiveidea). +The [main website](https://buildlight.collectiveidea.com/) shows the basic status for all projects. Adding a user/organization name to the url shows just those projects, for example: [https://buildlight.collectiveidea.com/collectiveidea](https://buildlight.collectiveidea.com/collectiveidea). Devices (editable only manually for now) can aggregate multiple organizations & projects, and have their own URL. For example, our office's physical light (see gif above) aggregates [@collectiveidea](https://github.com/collectiveidea), [@deadmanssnitch](https://github.com/deadmanssnitch), and client projects. Its URL is: https://buildlight.collectiveidea.com/devices/collectiveidea-office +## Development + +### Requirements + +- [Zig 0.15](https://ziglang.org) (install via [mise](https://mise.jdx.dev): `mise install`) +- PostgreSQL + +### Quick Start + +```bash +mise install # Install Zig 0.15 +createdb buildlight_development +zig build run # Migrations run automatically +``` + +The server starts on http://localhost:8080. + +In debug mode, templates and static files are read from disk — edit HTML, CSS, or +JS and refresh without recompiling. + +### Configuration + +All configuration is via environment variables: + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `8080` | HTTP listen port | +| `DATABASE_URL` | `postgres://localhost/buildlight_development?sslmode=disable` | PostgreSQL URL | +| `HOST` | `localhost` | Hostname for webhook trigger headers | +| `PARTICLE_ACCESS_TOKEN` | _(none)_ | Particle.io API token for device triggers | +| `DEBUG` | _(unset)_ | Store raw webhook payloads when set | + +### Testing + +```bash +createdb buildlight_test +zig build test + +# Or with an explicit database URL: +TEST_DATABASE_URL="postgresql://user:pass@localhost/buildlight_test" zig build test +``` + +### Database Migrations + +Migrations run automatically on startup. To run them explicitly (e.g. during deploy): + +```bash +buildlight migrate +``` + +Migration files are SQL in `src/migrations/` and are embedded in the binary at +compile time. To add a new migration: + +1. Create `src/migrations/NNN_description.sql` +2. Register it in `src/db.zig`: + ```zig + const migrations = .{ + .{ .version = "001", .sql = @embedFile("migrations/001_initial_schema.sql") }, + .{ .version = "002", .sql = @embedFile("migrations/002_your_migration.sql") }, + }; + ``` +3. Rebuild — the new migration is embedded in the binary + +Migrations are tracked in `schema_migrations` and are idempotent. + +### Project Structure + +``` +src/ + main.zig Entry point, server setup, route registration + handlers.zig HTTP request handlers + models.zig Database queries, types (Colors, Status, Device) + parsers.zig Webhook payload parsing (GitHub, Travis, CircleCI) + websocket.zig WebSocket hub and client (pub/sub broadcasting) + templates.zig HTML template rendering + triggers.zig External notifications (webhooks, Particle.io) + db.zig Database pool and migrations + migrations/ SQL migration files +templates/ HTML templates (embedded in release, disk in debug) +public/ Static assets (CSS, JS, favicons) +build.zig Build configuration +build.zig.zon Package dependencies +``` + +### Debug vs Release + +**Debug** (`zig build`): Templates and static files read from disk. Edit and +refresh without recompiling. ~50MB binary. + +**Release** (`zig build -Doptimize=ReleaseSafe`): Everything embedded via +`@embedFile`. Single static binary, no runtime file dependencies. + +## Deployment (Fly.io) + +```bash +flyctl deploy +``` + +The `Dockerfile` builds a two-stage image: +1. Zig compiles with `ReleaseSafe` +2. Runtime is `debian:stable-slim` + `ca-certificates` + +`fly.toml` runs `buildlight migrate` as the release command. Static files under +`/public` are served by Fly's CDN via `[[statics]]`. + +## API + +### Routes + +| Method | Path | Description | +|---|---|---| +| `GET` | `/` | Dashboard HTML or JSON (`Accept: application/json`) | +| `GET` | `/:id` | Colors for username(s), comma-separated | +| `GET` | `/:id.ryg` | Streaming RYG (chunked, 1s updates) | +| `GET` | `/:id.json` | Colors JSON for username(s) | +| `POST` | `/` | Webhook endpoint (GitHub/Travis/CircleCI) | +| `GET` | `/devices/:id` | Device page (by slug or UUID) | +| `GET` | `/api/devices/:id` | Device colors JSON (by UUID) | +| `POST` | `/api/device/trigger` | Trigger device notifications (`coreid=ID`) | +| `GET` | `/api/device/:id/red` | Failing projects for device (by identifier) | +| `GET` | `/ws` | WebSocket | +| `GET` | `/up` | Health check | + +### WebSocket Protocol + +Connect to `/ws` and send JSON to subscribe: + +```json +{"subscribe": "colors:*"} +{"subscribe": "colors:collectiveidea"} +{"subscribe": "device:my-slug"} +``` + +Server broadcasts: +```json +{"channel": "colors:*", "data": {"colors": {"red": false, "yellow": true, "green": true}}} +``` + ## License This software is © Copyright [Collective Idea](http://collectiveidea.com) and released under the MIT License. diff --git a/app.json b/app.json deleted file mode 100644 index 9b37423..0000000 --- a/app.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "buildpacks": [ - { - "url": "https://github.com/gaffneyc/heroku-buildpack-jemalloc.git" - }, - { - "url": "heroku/metrics" - }, - { - "url": "heroku/nodejs" - }, - { - "url": "heroku/ruby" - } - ], - - "scripts": { - "test-setup": "bundle install --with test && bin/setup && npm install yarn && yarn install", - "test": "RAILS_ENV=test bundle exec rake standard css:build && bundle exec rspec -f RSpec::TapY | tapout tap" - }, - "addons":[ - "heroku-postgresql:hobby-dev" - ] -} \ No newline at end of file diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js deleted file mode 100644 index 57b0e8e..0000000 --- a/app/assets/config/manifest.js +++ /dev/null @@ -1,3 +0,0 @@ -//= link_tree ../builds -//= link_tree ../../javascript .js -//= link_tree ../../../vendor/javascript .js diff --git a/app/assets/stylesheets/application.sass.scss b/app/assets/stylesheets/application.sass.scss deleted file mode 100644 index 90bde63..0000000 --- a/app/assets/stylesheets/application.sass.scss +++ /dev/null @@ -1,7 +0,0 @@ -// Entry point for your Sass build -@charset "utf-8"; - -@import "base/base"; -@import "components/bulb"; -@import "components/light"; -@import "components/message"; diff --git a/app/assets/stylesheets/base/_base.scss b/app/assets/stylesheets/base/_base.scss deleted file mode 100644 index 13eec6e..0000000 --- a/app/assets/stylesheets/base/_base.scss +++ /dev/null @@ -1,4 +0,0 @@ -@import "variables"; -@import "reset"; -@import "elements"; -@import "keyframes"; diff --git a/app/assets/stylesheets/base/_elements.scss b/app/assets/stylesheets/base/_elements.scss deleted file mode 100644 index 95e179b..0000000 --- a/app/assets/stylesheets/base/_elements.scss +++ /dev/null @@ -1,28 +0,0 @@ -// Elements -// ----------------------------------------------------------------------------- - -html { - font-family: $base-font-family; - font-weight: 300; - line-height: 1.4; -} - -body { - background-color: $base-background-color; - color: $base-text-color; - font-size: 1em; -} - -ol, -ul { - padding-left: 1rem; -} - -a { - color: currentColor; - - &:focus, - &:hover { - color: $strong-text-color; - } -} diff --git a/app/assets/stylesheets/base/_keyframes.scss b/app/assets/stylesheets/base/_keyframes.scss deleted file mode 100644 index dd6c0fc..0000000 --- a/app/assets/stylesheets/base/_keyframes.scss +++ /dev/null @@ -1,3 +0,0 @@ -@keyframes pulse { - 50% { transform: scale(.8); } -} diff --git a/app/assets/stylesheets/base/_reset.scss b/app/assets/stylesheets/base/_reset.scss deleted file mode 100644 index 2af04dd..0000000 --- a/app/assets/stylesheets/base/_reset.scss +++ /dev/null @@ -1,28 +0,0 @@ -// Reset (the bits of Normalize.css that are used in this site) -// ----------------------------------------------------------------------------- - -// Prevent adjustments of font size after orientation changes in -// IE on Windows Phone and in iOS. -html { - -ms-text-size-adjust: 100%; - -webkit-text-size-adjust: 100%; -} - -// Remove the margin in all browsers (opinionated). -body { - margin: 0; -} - -// 1. Remove the gray background on active links in IE 10. -// 2. Remove gaps in links underline in iOS 8+ and Safari 8+. -a { - background-color: transparent; // 1 - -webkit-text-decoration-skip: objects; // 2 -} - -// Remove the outline on focused links when they are also active or hovered -// in all browsers (opinionated). -a:active, -a:hover { - outline-width: 0; -} diff --git a/app/assets/stylesheets/base/_variables.scss b/app/assets/stylesheets/base/_variables.scss deleted file mode 100644 index fec9162..0000000 --- a/app/assets/stylesheets/base/_variables.scss +++ /dev/null @@ -1,33 +0,0 @@ -// Variables -// ----------------------------------------------------------------------------- - -// Colors -$black: #000; -$white: #fff; -$red: #cf4c29; -$yellow: #ecc561; -$green: #28b56e; - -$base-background-color: mix($black, $white, 75%); - -$base-text-color: mix($black, $white, 10%); -$strong-text-color: $white; -$muted-text-color: mix($black, $white, 50%); - -// Typography -$base-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; - -// Transitions -$base-transition-speed: 500ms; -$fast-transition-speed: $base-transition-speed * 0.5; -$slow-transition-speed: $base-transition-speed * 2; -$slug-transition-speed: $base-transition-speed * 8; - -// Traffic light -$bulb-size: 50vmin; -$bulb-shadow-size: 150vmax; -$bulb-gutter: 6vmin; -$light-height: ($bulb-size * 3) + ($bulb-gutter * 2); - -// Animations -$animation-pulse-duration: 5s; diff --git a/app/assets/stylesheets/components/_bulb.scss b/app/assets/stylesheets/components/_bulb.scss deleted file mode 100644 index 458ea1c..0000000 --- a/app/assets/stylesheets/components/_bulb.scss +++ /dev/null @@ -1,91 +0,0 @@ -// Lightbulbs - the circles that sit within the traffic light -// ----------------------------------------------------------------------------- - -@use "sass:math"; - -.bulb { - height: $bulb-size; - position: relative; - width: $bulb-size; - - &__glow { - animation: pulse $animation-pulse-duration infinite; - display: block; - height: $bulb-shadow-size; - left: 50%; - margin-left: -($bulb-shadow-size * 0.5); - margin-top: -($bulb-shadow-size * 0.5); - opacity: 0; - position: absolute; - top: 50%; - transform: scale(.5); - transition: transform $slow-transition-speed, opacity $slow-transition-speed; - transition-delay: $fast-transition-speed; - width: $bulb-shadow-size; - z-index: 2; - - // 1 - Make sure the building glow displays underneath the - // passing/failing glows; they should take precedence - .bulb--yellow & { z-index: 1; } // 1 - } - - &__disc { - background-color: rgba($black, .2); - border-radius: 50%; - bottom: 0; - left: 0; - position: absolute; - right: 0; - top: 0; - transition: background-color $fast-transition-speed; - z-index: 3; - } - - &__text { - font-size: math.div($bulb-size, 7); - font-weight: 800; - left: $bulb-size * 0.5; - line-height: 1; - margin-left: -($bulb-size * 0.5); - margin-top: -(math.div($bulb-size, 7) * 0.5); - opacity: 0; - position: absolute; - text-align: center; - text-transform: lowercase; - top: $bulb-size * 0.5; - transition: opacity $base-transition-speed; - width: $bulb-size; - z-index: 4; - } - - @mixin bulb($color, $state) { - [data-#{ $state }] & { - .bulb__glow { - background-image: radial-gradient( - ellipse at center, - rgba($color, .9) 10%, - rgba($color, .6) 20%, - rgba($color, .3) 40%, - rgba($color, 0) 70% - ); - opacity: 1; - transform: scale(1); - } - - .bulb__disc { background-color: $color; } - .bulb__text { opacity: 1; } - } - } - - &--red { @include bulb($red, "failing"); } - - &--yellow { - @include bulb($yellow, "building"); - - // 1 - offset glow animation so that the building light - // balances well with the failing/passing ones - .bulb__glow { animation-delay: -($animation-pulse-duration * 0.5); } // 1 - } - - &--green { @include bulb($green, "passing"); } -} diff --git a/app/assets/stylesheets/components/_light.scss b/app/assets/stylesheets/components/_light.scss deleted file mode 100644 index 64131ef..0000000 --- a/app/assets/stylesheets/components/_light.scss +++ /dev/null @@ -1,44 +0,0 @@ -// Traffic light - the box that holds the light bulbs -// ----------------------------------------------------------------------------- - -.light { - bottom: 0; - left: 0; - overflow: hidden; - position: absolute; - right: 0; - top: 0; - - &__box { - display: flex; - flex-direction: column; - height: $light-height; - justify-content: space-between; - left: calc(50vw - (#{ $bulb-size } / 2)); - position: absolute; - top: calc(50vh - (#{ $light-height } / 2)); - transition: margin-top $base-transition-speed; - width: $bulb-size; - - [data-failing] & { - margin-top: $bulb-size + $bulb-gutter; - } - - [data-failing][data-building] & { - margin-top: ($bulb-size + $bulb-gutter) * 0.5; - } - - [data-passing] & { - margin-top: -($bulb-size + $bulb-gutter); - } - - [data-passing][data-building] & { - margin-top: -($bulb-size + $bulb-gutter) * 0.5; - } - - [data-failing][data-passing] & { - margin-top: 0; - transform: scale(.75); - } - } -} diff --git a/app/assets/stylesheets/components/_message.scss b/app/assets/stylesheets/components/_message.scss deleted file mode 100644 index 7010d9d..0000000 --- a/app/assets/stylesheets/components/_message.scss +++ /dev/null @@ -1,46 +0,0 @@ -// Messages -// ----------------------------------------------------------------------------- - -.messages { - left: 5vmin; - position: absolute; - top: 5vmin; - z-index: 10; - - &__footer { - font-size: .8em; - opacity: .5; - - :first-child { margin-top: 0; } - } -} - -.message { - font-size: 0; - overflow: hidden; - padding-bottom: .5em; - transition: font-size $base-transition-speed, - opacity $base-transition-speed; - - .emoji { - height: 1em; - width: auto; - } - - > * { - margin-top: 0; - } - - > :first-child { - font-size: 1.5em; - line-height: 1.2; - margin-bottom: .25em; - } - - [data-failing] &--failure, - [data-building] &--building, - [data-passing] &--passing { - font-size: 1em; - opacity: 1; - } -} diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb deleted file mode 100644 index d672697..0000000 --- a/app/channels/application_cable/channel.rb +++ /dev/null @@ -1,4 +0,0 @@ -module ApplicationCable - class Channel < ActionCable::Channel::Base - end -end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb deleted file mode 100644 index 0ff5442..0000000 --- a/app/channels/application_cable/connection.rb +++ /dev/null @@ -1,4 +0,0 @@ -module ApplicationCable - class Connection < ActionCable::Connection::Base - end -end diff --git a/app/channels/colors_channel.rb b/app/channels/colors_channel.rb deleted file mode 100644 index 285a734..0000000 --- a/app/channels/colors_channel.rb +++ /dev/null @@ -1,9 +0,0 @@ -class ColorsChannel < ApplicationCable::Channel - def subscribed - stream_from "colors:#{params[:id]}" - end - - def unsubscribed - # Any cleanup needed when channel is unsubscribed - end -end diff --git a/app/channels/device_channel.rb b/app/channels/device_channel.rb deleted file mode 100644 index 95b4b86..0000000 --- a/app/channels/device_channel.rb +++ /dev/null @@ -1,28 +0,0 @@ -class DeviceChannel < ApplicationCable::Channel - after_subscribe :initial_broadcast - - def subscribed - @slug = params[:id] - - # Uses friend device slug rather than id. - # device:my-slug - stream_from "device:#{@slug}" - end - - def unsubscribed - # Any cleanup needed when channel is unsubscribed - end - - def initial_broadcast - # Trigger an initial broadcast. - Thread.new do - device = Device.find_by(slug: @slug) - if device - 5.times do - device.broadcast - sleep 30 - end - end - end - end -end diff --git a/app/controllers/api/application_controller.rb b/app/controllers/api/application_controller.rb deleted file mode 100644 index 000da4b..0000000 --- a/app/controllers/api/application_controller.rb +++ /dev/null @@ -1,2 +0,0 @@ -class API::ApplicationController < ActionController::API -end diff --git a/app/controllers/api/devices_controller.rb b/app/controllers/api/devices_controller.rb deleted file mode 100644 index 2b704b8..0000000 --- a/app/controllers/api/devices_controller.rb +++ /dev/null @@ -1,12 +0,0 @@ -class API::DevicesController < API::ApplicationController - def show - device = Device.find(params[:id]) - render json: {colors: device.colors_as_booleans, ryg: device.ryg} - end - - def trigger - device = Device.find_by(identifier: params[:coreid]) - device&.trigger - head :ok - end -end diff --git a/app/controllers/api/red_controller.rb b/app/controllers/api/red_controller.rb deleted file mode 100644 index 8fedd3f..0000000 --- a/app/controllers/api/red_controller.rb +++ /dev/null @@ -1,10 +0,0 @@ -class API::RedController < ApplicationController - def show - device = Device.find_sole_by(identifier: params[:id]) - @red_projects = device.statuses.where(red: true) - respond_to do |format| - format.html - format.json { render json: @red_projects.to_json(only: [:username, :project_name]) } - end - end -end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb deleted file mode 100644 index 09705d1..0000000 --- a/app/controllers/application_controller.rb +++ /dev/null @@ -1,2 +0,0 @@ -class ApplicationController < ActionController::Base -end diff --git a/app/controllers/colors_controller.rb b/app/controllers/colors_controller.rb deleted file mode 100644 index eb0a769..0000000 --- a/app/controllers/colors_controller.rb +++ /dev/null @@ -1,33 +0,0 @@ -class ColorsController < ApplicationController - include ActionController::Live - - def index - respond_to do |format| - format.html do - @colors = Status.colors(@ids) - render "index" - end - format.ryg do - response.headers["Content-Type"] = "text/ryg" - - ActiveRecord::Base.connection_pool.release_connection - - loop do - ActiveRecord::Base.connection_pool.with_connection do - response.stream.write(Status.uncached { Status.ryg(@ids) }) - end - - sleep 1 - end - rescue IOError - response.stream.close - end - format.json { render json: Status.colors(@ids) } - end - end - - def show - @ids = params[:id].split(",") - index - end -end diff --git a/app/controllers/devices_controller.rb b/app/controllers/devices_controller.rb deleted file mode 100644 index 602ed99..0000000 --- a/app/controllers/devices_controller.rb +++ /dev/null @@ -1,13 +0,0 @@ -class DevicesController < ApplicationController - def show - device = Device.where(slug: params[:id]).or(Device.where(id: params[:id])).sole - - respond_to do |format| - format.html do - @colors = device.colors - render "colors/index" - end - format.json { render json: device.colors } - end - end -end diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb deleted file mode 100644 index 47897c7..0000000 --- a/app/controllers/webhooks_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -require "digest" -class WebhooksController < API::ApplicationController - def create - if params[:payload].is_a?(String) - ParseTravis.call(params[:payload]) - head :ok - elsif params[:payload].blank? && params[:repository]&.include?("/") - ParseGithub.call(params) - head :ok - elsif request.headers.env["HTTP_CIRCLECI_EVENT_TYPE"].present? - ParseCircle.call(params) - head :ok - else - head :bad_request - end - end -end diff --git a/app/helpers/color_helper.rb b/app/helpers/color_helper.rb deleted file mode 100644 index 19b6666..0000000 --- a/app/helpers/color_helper.rb +++ /dev/null @@ -1,34 +0,0 @@ -module ColorHelper - def color_attrs_for_body(colors) - html = +"" - - if colors - html << if colors[:red] - " data-failing" - else - " data-passing" - end - - html << " data-building" if colors[:yellow] - end - - html - end - - def color_favicon_link_tag(colors) - filename = +"/favicon" - - if colors - filename << if colors[:red] - "-failing" - else - "-passing" - end - - filename << "-building" if colors[:yellow] - end - - filename << ".ico" - favicon_link_tag filename, id: "favicon" - end -end diff --git a/app/interactors/parse_circle.rb b/app/interactors/parse_circle.rb deleted file mode 100644 index 3779e78..0000000 --- a/app/interactors/parse_circle.rb +++ /dev/null @@ -1,20 +0,0 @@ -class ParseCircle - def self.call(payload) - return unless payload["type"] == "workflow-completed" - # Ignore pull requests. We can't really determine a PR build, so we ignore any branches other than main/master. - return unless payload.dig("pipeline", "vcs", "branch").in? ["main", "master"] - - status = Status.find_or_initialize_by(service: "circle", username: payload["organization"]["name"], project_name: payload["project"]["name"]) - status.payload = payload if Rails.configuration.x.debug - set_colors(status, payload["workflow"]["status"]) - status.save! - end - - # Potential Statuses - # See: https://circleci.com/docs/webhooks/#event-specifications - # "success", "failed", "error", "canceled", "unauthorized" - def self.set_colors(status, code) - status.yellow = false # currently no way to set yellow on Circle - status.red = code != "success" - end -end diff --git a/app/interactors/parse_github.rb b/app/interactors/parse_github.rb deleted file mode 100644 index 7032950..0000000 --- a/app/interactors/parse_github.rb +++ /dev/null @@ -1,26 +0,0 @@ -class ParseGithub - def self.call(payload) - username, project_name = payload["repository"].split("/") - workflow = payload["workflow"] - status = Status.find_or_initialize_by(service: "github", username: username, project_name: project_name, workflow: workflow) - status.payload = payload if Rails.configuration.x.debug - set_colors(status, payload["status"]) - status.save! - end - - # Options - # "success", "failure", "" - def self.set_colors(status, code) - status.yellow = false - case code - when "" - status.yellow = true - when "success" - status.red = false - when "failure" - status.red = true - else - raise "Unknown status: #{code}" - end - end -end diff --git a/app/interactors/parse_travis.rb b/app/interactors/parse_travis.rb deleted file mode 100644 index ebc6558..0000000 --- a/app/interactors/parse_travis.rb +++ /dev/null @@ -1,26 +0,0 @@ -class ParseTravis - def self.call(payload) - json = JSON.parse(payload) - return if json["type"] == "pull_request" - - status = Status.find_or_initialize_by(service: "travis", project_id: json["repository"]["id"].to_s) - status.payload = payload if Rails.configuration.x.debug - status.username = json["repository"]["owner_name"] - status.project_name = json["repository"]["name"] - set_colors(status, json["status_message"]) - status.save! - end - - # Set colors based on travis-ci's status code - def self.set_colors(status, code) - status.yellow = false - case code - when "Pending" - status.yellow = true - when "Passed", "Fixed" - status.red = false - else - status.red = true - end - end -end diff --git a/app/interactors/trigger_particle.rb b/app/interactors/trigger_particle.rb deleted file mode 100644 index dcf5105..0000000 --- a/app/interactors/trigger_particle.rb +++ /dev/null @@ -1,5 +0,0 @@ -class TriggerParticle - def self.call(device) - Particle.publish(name: "build_state", data: device.status, ttl: 3600, private: false) - end -end diff --git a/app/interactors/trigger_webhook.rb b/app/interactors/trigger_webhook.rb deleted file mode 100644 index 6f43c9a..0000000 --- a/app/interactors/trigger_webhook.rb +++ /dev/null @@ -1,11 +0,0 @@ -class TriggerWebhook - def self.call(device) - Faraday.post(device.webhook_url, - {colors: device.colors_as_booleans}.to_json, - { - "Content-Type": "application/json", - "x-ryg": device.ryg, - "x-device-url": Rails.application.routes.url_helpers.api_device_url(device, host: Rails.configuration.x.host) - }) - end -end diff --git a/app/javascript/application.js b/app/javascript/application.js deleted file mode 100644 index c5b71a3..0000000 --- a/app/javascript/application.js +++ /dev/null @@ -1,3 +0,0 @@ -// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails - -import "channels" diff --git a/app/javascript/channels/colors_channel.js b/app/javascript/channels/colors_channel.js deleted file mode 100644 index d3ef3fb..0000000 --- a/app/javascript/channels/colors_channel.js +++ /dev/null @@ -1,55 +0,0 @@ -import consumer from "channels/consumer" - -let ids, channel; - -if (document.location.pathname.match(/^\/devices\//)) { - ids = document.location.pathname.match(/^\/devices\/([^\/\?]*)/)[1].split(","); - channel = "DeviceChannel"; -} else { - ids = document.location.pathname.match(/^\/([^\/\?]*)/)[1].split(","); - channel = "ColorsChannel"; -} - -ids.forEach(function (id) { - if (id === "") { - id = "*"; - } - - consumer.subscriptions.create({ channel: channel, id: id }, { - connected() { - // Called when the subscription is ready for use on the server - }, - - disconnected() { - // Called when the subscription has been terminated by the server - }, - - received(data) { - // Called when there's incoming data on the websocket for this channel - var count, favicon, message, redCount; - redCount = +data.colors.red; - favicon = redCount ? "/favicon-failing" : "/favicon-passing"; - if (redCount > 0) { - document.body.setAttribute("data-failing", ""); - document.body.removeAttribute("data-passing"); - count = document.getElementById("failing-count"); - if (redCount === 1) { - message = "" + redCount + " project is"; - } else { - message = "" + redCount + " projects are"; - } - count.innerHTML = message; - } else { - document.body.removeAttribute("data-failing"); - document.body.setAttribute("data-passing", ""); - } - if (data.colors.yellow) { - document.body.setAttribute("data-building", ""); - favicon += "-building"; - } else { - document.body.removeAttribute("data-building"); - } - return document.getElementById("favicon").setAttribute("href", favicon + ".ico"); - } - }); -}); diff --git a/app/javascript/channels/consumer.js b/app/javascript/channels/consumer.js deleted file mode 100644 index 8ec3aad..0000000 --- a/app/javascript/channels/consumer.js +++ /dev/null @@ -1,6 +0,0 @@ -// Action Cable provides the framework to deal with WebSockets in Rails. -// You can generate new channels where WebSocket features live using the `bin/rails generate channel` command. - -import { createConsumer } from "@rails/actioncable" - -export default createConsumer() diff --git a/app/javascript/channels/index.js b/app/javascript/channels/index.js deleted file mode 100644 index 0c7cd18..0000000 --- a/app/javascript/channels/index.js +++ /dev/null @@ -1,2 +0,0 @@ -// Import all the channels to be used by Action Cable -import "channels/colors_channel" diff --git a/app/mailers/.gitkeep b/app/mailers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/models/.gitkeep b/app/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/models/application_record.rb b/app/models/application_record.rb deleted file mode 100644 index 10a4cba..0000000 --- a/app/models/application_record.rb +++ /dev/null @@ -1,3 +0,0 @@ -class ApplicationRecord < ActiveRecord::Base - self.abstract_class = true -end diff --git a/app/models/device.rb b/app/models/device.rb deleted file mode 100644 index eba3a0f..0000000 --- a/app/models/device.rb +++ /dev/null @@ -1,46 +0,0 @@ -class Device < ApplicationRecord - validates :name, presence: true - validates :identifier, uniqueness: {allow_blank: true} - validates :slug, uniqueness: {allow_blank: true} - - # Ensure the status is updated if we change anything - after_commit :update_status, on: [:create, :update] - - def statuses - Status.where(username: usernames) - .or(Status.where("(username || '/' || project_name) IN (?)", projects)) - end - - def colors - statuses.colors - end - - def colors_as_booleans - statuses.colors_as_booleans - end - - def ryg - statuses.ryg - end - - def update_status - self.status = statuses.current_status - broadcast - - if status_changed? - self.status_changed_at = Time.current - save! - # Currenly only triggering on status change to reduce webhooks - trigger - end - end - - def broadcast - DeviceChannel.broadcast_to(slug, colors: colors) if slug - end - - def trigger - TriggerWebhook.call(self) if webhook_url - TriggerParticle.call(self) if identifier - end -end diff --git a/app/models/status.rb b/app/models/status.rb deleted file mode 100644 index a0a5124..0000000 --- a/app/models/status.rb +++ /dev/null @@ -1,47 +0,0 @@ -class Status < ApplicationRecord - after_commit :update_devices - - scope :red, -> { where(red: true) } - - def name - "#{username}/#{project_name}" - end - - # Devices that are "watching" this Status - def devices - Device.where("usernames @> ARRAY[?]::varchar[]", [username]) - .or(Device.where("projects @> ARRAY[?]::varchar[]", [name])) - end - - # Colors for all statuses - # Red may be a count not a boolean. Use colors_as_booleans for boolean values. - def self.colors(username = nil) - user_scope = username.present? ? where(username: username) : all - red = user_scope.where(red: true).count - red = false if red.zero? - yellow = user_scope.where(yellow: true).any? - - {red: red, yellow: yellow, green: !red} - end - - def self.colors_as_booleans(username = nil) - colors(username).transform_values { |v| !!v } - end - - def self.ryg(username = nil) - colors(username).map { |k, v| v ? k[0].upcase : k[0].downcase }.join - end - - def self.current_status - red = "failing" if where(red: true).any? - yellow = "building" if where(yellow: true).any? - green = "passing" unless red - [green, red, yellow].compact.join("-") # combines status to send "passing|failing-building" - end - - def update_devices - ColorsChannel.broadcast_to("*", colors: Status.colors) - ColorsChannel.broadcast_to(username, colors: Status.colors(username)) - devices.each(&:update_status) - end -end diff --git a/app/views/api/red/show.html.erb b/app/views/api/red/show.html.erb deleted file mode 100644 index 473d3cb..0000000 --- a/app/views/api/red/show.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -
- <% if @red_projects.any? %> -

The following projects are failing

- - <% else %> -
- <%= "🎉" * 5 %> -
-

You have no failing projects.

-
- <%= "🎉" * 5 %> -
- <% end %> -
\ No newline at end of file diff --git a/app/views/colors/index.html.erb b/app/views/colors/index.html.erb deleted file mode 100644 index f75b6f7..0000000 --- a/app/views/colors/index.html.erb +++ /dev/null @@ -1,39 +0,0 @@ -
-
-

- Hooray! All projects are passing. 🎉 -

-
-
-

Rats. <%= pluralize @colors[:red], "project is", plural: "projects are" %> failing.

-
- - -
- - diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb deleted file mode 100644 index 7a7b47d..0000000 --- a/app/views/layouts/application.html.erb +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - [i] Buildlight - <%= color_favicon_link_tag(@colors) %> - <%= csrf_meta_tags %> - <%= action_cable_meta_tag %> - <%= stylesheet_link_tag "application", :media => "all" %> - <%= javascript_importmap_tags %> - - - > - <%= yield %> - - diff --git a/bin/bundle b/bin/bundle deleted file mode 100755 index f19acf5..0000000 --- a/bin/bundle +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env ruby -ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) -load Gem.bin_path('bundler', 'bundle') diff --git a/bin/dev b/bin/dev deleted file mode 100755 index f709c1f..0000000 --- a/bin/dev +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -foreman start -f Procfile.dev diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint deleted file mode 100755 index c12e7ae..0000000 --- a/bin/docker-entrypoint +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -e - -# Enable jemalloc for reduced memory usage and latency. -if [ -z "${LD_PRELOAD+x}" ]; then - LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit) - export LD_PRELOAD -fi - -# Add any container initialization steps here - -exec "${@}" diff --git a/bin/importmap b/bin/importmap deleted file mode 100755 index 36502ab..0000000 --- a/bin/importmap +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env ruby - -require_relative "../config/application" -require "importmap/commands" diff --git a/bin/rails b/bin/rails deleted file mode 100755 index efc0377..0000000 --- a/bin/rails +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env ruby -APP_PATH = File.expand_path("../config/application", __dir__) -require_relative "../config/boot" -require "rails/commands" diff --git a/bin/rake b/bin/rake deleted file mode 100755 index 4fbf10b..0000000 --- a/bin/rake +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env ruby -require_relative "../config/boot" -require "rake" -Rake.application.run diff --git a/bin/rubocop b/bin/rubocop deleted file mode 100755 index 40330c0..0000000 --- a/bin/rubocop +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env ruby -require "rubygems" -require "bundler/setup" - -# explicit rubocop config increases performance slightly while avoiding config confusion. -ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) - -load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup deleted file mode 100755 index 26ad1cb..0000000 --- a/bin/setup +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env ruby -require "fileutils" - -APP_ROOT = File.expand_path("..", __dir__) -APP_NAME = "buildlight" - -def system!(*args) - system(*args, exception: true) -end - -FileUtils.chdir APP_ROOT do - # This script is a way to set up or update your development environment automatically. - # This script is idempotent, so that you can run it at any time and get an expectable outcome. - # Add necessary setup steps to this file. - - puts "== Installing dependencies ==" - system! "gem install bundler --conservative" - system("bundle check") || system!("bundle install") - - puts "\n== Copying sample files ==" - unless File.exist?("config/application.yml") - FileUtils.cp "config/application.example.yml", "config/application.yml" - end - - puts "\n== Preparing database ==" - system! "bin/rails db:prepare" - - puts "\n== Removing old logs and tempfiles ==" - system! "bin/rails log:clear tmp:clear" - - puts "\n== Restarting application server ==" - system! "bin/rails restart" - - # puts "\n== Configuring puma-dev ==" - # system "ln -nfs #{APP_ROOT} ~/.puma-dev/#{APP_NAME}" - # system "curl -Is https://#{APP_NAME}.test/up | head -n 1" -end diff --git a/bin/thrust b/bin/thrust deleted file mode 100755 index 36bde2d..0000000 --- a/bin/thrust +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env ruby -require "rubygems" -require "bundler/setup" - -load Gem.bin_path("thruster", "thrust") diff --git a/bin/update b/bin/update deleted file mode 100755 index 58bfaed..0000000 --- a/bin/update +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env ruby -require 'fileutils' -include FileUtils - -# path to your application root. -APP_ROOT = File.expand_path('..', __dir__) - -def system!(*args) - system(*args) || abort("\n== Command #{args} failed ==") -end - -chdir APP_ROOT do - # This script is a way to update your development environment automatically. - # Add necessary update steps to this file. - - puts '== Installing dependencies ==' - system! 'gem install bundler --conservative' - system('bundle check') || system!('bundle install') - - # Install JavaScript dependencies if using Yarn - # system('bin/yarn') - - puts "\n== Updating database ==" - system! 'bin/rails db:migrate' - - puts "\n== Removing old logs and tempfiles ==" - system! 'bin/rails log:clear tmp:clear' - - puts "\n== Restarting application server ==" - system! 'bin/rails restart' -end diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..361fb60 --- /dev/null +++ b/build.zig @@ -0,0 +1,59 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const dep_opts = .{ .target = target, .optimize = optimize }; + const httpz_module = b.dependency("httpz", dep_opts).module("httpz"); + const pg_module = b.dependency("pg", dep_opts).module("pg"); + + const imports: []const std.Build.Module.Import = &.{ + .{ .name = "httpz", .module = httpz_module }, + .{ .name = "pg", .module = pg_module }, + }; + + // Main executable (embed assets in release mode) + const options = b.addOptions(); + options.addOption(bool, "embed_assets", optimize != .Debug); + + const exe = b.addExecutable(.{ + .name = "buildlight", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = imports, + }), + }); + exe.root_module.addOptions("build_options", options); + exe.linkLibC(); + b.installArtifact(exe); + + // Run step + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + b.step("run", "Run the server").dependOn(&run_cmd.step); + + // Test step (never embed assets in tests) + const test_options = b.addOptions(); + test_options.addOption(bool, "embed_assets", false); + + const tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = imports, + }), + }); + tests.root_module.addOptions("build_options", test_options); + tests.linkLibC(); + + const run_tests = b.addRunArtifact(tests); + run_tests.has_side_effects = true; + b.step("test", "Run tests").dependOn(&run_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..f371284 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,16 @@ +.{ + .name = .buildlight, + .version = "0.0.0", + .fingerprint = 0x314341060ffad66e, + .paths = .{""}, + .dependencies = .{ + .httpz = .{ + .url = "git+https://github.com/karlseguin/http.zig?ref=master#844f8016e6616f00b05d4cc3c713307b0fe586c7", + .hash = "httpz-0.0.0-PNVzrBtMBwAPcQx3mNEgat3Xbsynw-eIC9SmOX5M9XtP", + }, + .pg = .{ + .url = "git+https://github.com/karlseguin/pg.zig?ref=master#e58b318b7867ef065b3135983f829219c5eef891", + .hash = "pg-0.0.0-Wp_7gXFoBgD0fQ72WICKa-bxLga03AXXQ3BbIsjjohQ3", + }, + }, +} diff --git a/config/application.example.yml b/config/application.example.yml deleted file mode 100644 index de3ef42..0000000 --- a/config/application.example.yml +++ /dev/null @@ -1,5 +0,0 @@ -# Add application configuration variables here, as shown below. -# -SECRET_TOKEN: something-random-and-at-least-30-characters -PARTICLE_ACCESS_TOKEN: "abcd5c82f7a646d8337cb575c45d6f9bf2bf12e69" -HOST: locahost:3000 diff --git a/config/application.rb b/config/application.rb deleted file mode 100644 index 744502c..0000000 --- a/config/application.rb +++ /dev/null @@ -1,44 +0,0 @@ -require_relative "boot" - -require "rails" -# Pick the frameworks you want: -require "active_model/railtie" -require "active_job/railtie" -require "active_record/railtie" -# require "active_storage/engine" -require "action_controller/railtie" -require "action_mailer/railtie" -# require "action_mailbox/engine" -# require "action_text/engine" -require "action_view/railtie" -require "action_cable/engine" -# require "rails/test_unit/railtie" - -# Require the gems listed in Gemfile, including any gems -# you've limited to :test, :development, or :production. -Bundler.require(*Rails.groups) - -module Buildlight - class Application < Rails::Application - # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 7.2 - - # Please, add to the `ignore` list any other `lib` subdirectories that do - # not contain `.rb` files, or that should not be reloaded or eager loaded. - # Common ones are `templates`, `generators`, or `middleware`, for example. - config.autoload_lib(ignore: %w[assets tasks]) - - # Configuration for the application, engines, and railties goes here. - # - # These settings can be overridden in specific environments using the files - # in config/environments, which are processed later. - # - # config.time_zone = "Central Time (US & Canada)" - # config.eager_load_paths << Rails.root.join("extras") - - config.x.debug = ENV["DEBUG"].present? - config.x.host = ENV["HOST"] - # Don't generate system test files. - config.generators.system_tests = nil - end -end diff --git a/config/boot.rb b/config/boot.rb deleted file mode 100644 index 988a5dd..0000000 --- a/config/boot.rb +++ /dev/null @@ -1,4 +0,0 @@ -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) - -require "bundler/setup" # Set up gems listed in the Gemfile. -require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/cable.yml b/config/cable.yml deleted file mode 100644 index dee099f..0000000 --- a/config/cable.yml +++ /dev/null @@ -1,6 +0,0 @@ -development: - adapter: postgresql -test: - adapter: postgresql -production: - adapter: postgresql diff --git a/config/database.yml b/config/database.yml deleted file mode 100644 index e83a93c..0000000 --- a/config/database.yml +++ /dev/null @@ -1,16 +0,0 @@ -default: &default - adapter: postgresql - encoding: unicode - # For details on connection pooling, see rails configuration guide - # http://guides.rubyonrails.org/configuring.html#database-pooling - pool: 15 - -development: - <<: *default - database: buildlight_development - # min_messages: DEBUG5 - -test: - <<: *default - database: buildlight_test - diff --git a/config/dockerfile.yml b/config/dockerfile.yml deleted file mode 100644 index 1928ca5..0000000 --- a/config/dockerfile.yml +++ /dev/null @@ -1,16 +0,0 @@ -# generated by dockerfile-rails - ---- -options: - alpine: true - bin-cd: true - gemfile-updates: false - label: - fly_launch_runtime: rails - parallel: true - prepare: false - packages: - build: - - git - deploy: - - gzip diff --git a/config/environment.rb b/config/environment.rb deleted file mode 100644 index cac5315..0000000 --- a/config/environment.rb +++ /dev/null @@ -1,5 +0,0 @@ -# Load the Rails application. -require_relative "application" - -# Initialize the Rails application. -Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb deleted file mode 100644 index a4c2a23..0000000 --- a/config/environments/development.rb +++ /dev/null @@ -1,69 +0,0 @@ -require "active_support/core_ext/integer/time" - -Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. - - # Make code changes take effect immediately without server restart. - config.enable_reloading = true - - # Do not eager load code on boot. - config.eager_load = false - - # Show full error reports. - config.consider_all_requests_local = true - - # Enable server timing. - config.server_timing = true - - # Enable/disable Action Controller caching. By default Action Controller caching is disabled. - # Run rails dev:cache to toggle Action Controller caching. - if Rails.root.join("tmp/caching-dev.txt").exist? - config.action_controller.perform_caching = true - config.action_controller.enable_fragment_cache_logging = true - config.public_file_server.headers = {"cache-control" => "public, max-age=#{2.days.to_i}"} - else - config.action_controller.perform_caching = false - end - - # Change to :null_store to avoid any caching. - config.cache_store = :memory_store - - # Don't care if the mailer can't send. - config.action_mailer.raise_delivery_errors = false - - # Make template changes take effect immediately. - config.action_mailer.perform_caching = false - - # Set localhost to be used by links generated in mailer templates. - config.action_mailer.default_url_options = {host: "localhost", port: 3000} - - # Print deprecation notices to the Rails logger. - config.active_support.deprecation = :log - - # Raise an error on page load if there are pending migrations. - config.active_record.migration_error = :page_load - - # Highlight code that triggered database queries in logs. - config.active_record.verbose_query_logs = true - - # Append comments with runtime information tags to SQL queries in logs. - config.active_record.query_log_tags_enabled = true - - # Highlight code that enqueued background job in logs. - config.active_job.verbose_enqueue_logs = true - - # Raises error for missing translations. - # config.i18n.raise_on_missing_translations = true - - # Annotate rendered view with file names. - config.action_view.annotate_rendered_view_with_filenames = true - - # Uncomment if you wish to allow Action Cable access from any origin. - # config.action_cable.disable_request_forgery_protection = true - - # Raise error when a before_action's only/except options reference missing actions. - config.action_controller.raise_on_missing_callback_actions = true - - # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. - # config.generators.apply_rubocop_autocorrect_after_generate! -end diff --git a/config/environments/production.rb b/config/environments/production.rb deleted file mode 100644 index ebca867..0000000 --- a/config/environments/production.rb +++ /dev/null @@ -1,89 +0,0 @@ -require "active_support/core_ext/integer/time" - -Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. - - # Code is not reloaded between requests. - config.enable_reloading = false - - # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). - config.eager_load = true - - # Full error reports are disabled. - config.consider_all_requests_local = false - - # Turn on fragment caching in view templates. - config.action_controller.perform_caching = true - - # Cache assets for far-future expiry since they are all digest stamped. - config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"} - - # Enable serving of images, stylesheets, and JavaScripts from an asset server. - # config.asset_host = "http://assets.example.com" - - # Assume all access to the app is happening through a SSL-terminating reverse proxy. - config.assume_ssl = true - - # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - config.force_ssl = true - - # Skip http-to-https redirect for the default health check endpoint. - # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } - - # Log to STDOUT with the current request id as a default log tag. - config.log_tags = [:request_id] - config.logger = ActiveSupport::TaggedLogging.logger($stdout) - - # Change to "debug" to log everything (including potentially personally-identifiable information!) - config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") - - # Prevent health checks from clogging up the logs. - config.silence_healthcheck_path = "/up" - - # Don't log any deprecations. - config.active_support.report_deprecations = false - - # Replace the default in-process memory cache store with a durable alternative. - # config.cache_store = :mem_cache_store - - # Replace the default in-process and non-durable queuing backend for Active Job. - # config.active_job.queue_adapter = :resque - - # Ignore bad email addresses and do not raise email delivery errors. - # Set this to true and configure the email server for immediate delivery to raise delivery errors. - # config.action_mailer.raise_delivery_errors = false - - # Set host to be used by links generated in mailer templates. - config.action_mailer.default_url_options = {host: "example.com"} - - # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit. - # config.action_mailer.smtp_settings = { - # user_name: Rails.application.credentials.dig(:smtp, :user_name), - # password: Rails.application.credentials.dig(:smtp, :password), - # address: "smtp.example.com", - # port: 587, - # authentication: :plain - # } - - # Enable locale fallbacks for I18n (makes lookups for any locale fall back to - # the I18n.default_locale when a translation cannot be found). - config.i18n.fallbacks = true - - # Do not dump schema after migrations. - config.active_record.dump_schema_after_migration = false - - # Only use :id for inspections in production. - config.active_record.attributes_for_inspect = [:id] - - # Enable DNS rebinding protection and other `Host` header attacks. - # config.hosts = [ - # "example.com", # Allow requests from example.com - # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` - # ] - # - # Skip DNS rebinding protection for the default health check endpoint. - # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } - - config.action_cable.url = "wss://#{ENV["HOST"]}/cable" - config.action_cable.allowed_request_origins = ["https://#{ENV["HOST"]}", "http://#{ENV["HOST"]}"] -end diff --git a/config/environments/test.rb b/config/environments/test.rb deleted file mode 100644 index 306866b..0000000 --- a/config/environments/test.rb +++ /dev/null @@ -1,50 +0,0 @@ -# The test environment is used exclusively to run your application's -# test suite. You never need to work with it otherwise. Remember that -# your test database is "scratch space" for the test suite and is wiped -# and recreated between test runs. Don't rely on the data there! - -Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. - - # While tests run files are not watched, reloading is not necessary. - config.enable_reloading = false - - # Eager loading loads your entire application. When running a single test locally, - # this is usually not necessary, and can slow down your test suite. However, it's - # recommended that you enable it in continuous integration systems to ensure eager - # loading is working properly before deploying your code. - config.eager_load = ENV["CI"].present? - - # Configure public file server for tests with cache-control for performance. - config.public_file_server.headers = {"cache-control" => "public, max-age=3600"} - - # Show full error reports. - config.consider_all_requests_local = true - config.cache_store = :null_store - - # Render exception templates for rescuable exceptions and raise for other exceptions. - config.action_dispatch.show_exceptions = :rescuable - - # Disable request forgery protection in test environment. - config.action_controller.allow_forgery_protection = false - - # Tell Action Mailer not to deliver emails to the real world. - # The :test delivery method accumulates sent emails in the - # ActionMailer::Base.deliveries array. - config.action_mailer.delivery_method = :test - - # Set host to be used by links generated in mailer templates. - config.action_mailer.default_url_options = {host: "example.com"} - - # Print deprecation notices to the stderr. - config.active_support.deprecation = :stderr - - # Raises error for missing translations. - # config.i18n.raise_on_missing_translations = true - - # Annotate rendered view with file names. - # config.action_view.annotate_rendered_view_with_filenames = true - - # Raise error when a before_action's only/except options reference missing actions. - config.action_controller.raise_on_missing_callback_actions = true -end diff --git a/config/importmap.rb b/config/importmap.rb deleted file mode 100644 index bbf1019..0000000 --- a/config/importmap.rb +++ /dev/null @@ -1,5 +0,0 @@ -# Pin npm packages by running ./bin/importmap - -pin "application" -pin "@rails/actioncable", to: "actioncable.esm.js" -pin_all_from "app/javascript/channels", under: "channels" diff --git a/config/initializers/application_controller_renderer.rb b/config/initializers/application_controller_renderer.rb deleted file mode 100644 index 89d2efa..0000000 --- a/config/initializers/application_controller_renderer.rb +++ /dev/null @@ -1,8 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# ActiveSupport::Reloader.to_prepare do -# ApplicationController.renderer.defaults.merge!( -# http_host: 'example.org', -# https: false -# ) -# end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb deleted file mode 100644 index 4873244..0000000 --- a/config/initializers/assets.rb +++ /dev/null @@ -1,7 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Version of your assets, change this if you want to expire all your assets. -Rails.application.config.assets.version = "1.0" - -# Add additional assets to the asset load path. -# Rails.application.config.assets.paths << Emoji.images_path diff --git a/config/initializers/backtrace_silencers.rb b/config/initializers/backtrace_silencers.rb deleted file mode 100644 index 33699c3..0000000 --- a/config/initializers/backtrace_silencers.rb +++ /dev/null @@ -1,8 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. -# Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } - -# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code -# by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". -Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"] diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb deleted file mode 100644 index b3076b3..0000000 --- a/config/initializers/content_security_policy.rb +++ /dev/null @@ -1,25 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Define an application-wide content security policy. -# See the Securing Rails Applications Guide for more information: -# https://guides.rubyonrails.org/security.html#content-security-policy-header - -# Rails.application.configure do -# config.content_security_policy do |policy| -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https -# policy.style_src :self, :https -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" -# end -# -# # Generate session nonces for permitted importmap, inline scripts, and inline styles. -# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src style-src) -# -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end diff --git a/config/initializers/cookies_serializer.rb b/config/initializers/cookies_serializer.rb deleted file mode 100644 index 5a6a32d..0000000 --- a/config/initializers/cookies_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Specify a serializer for the signed and encrypted cookie jars. -# Valid options are :json, :marshal, and :hybrid. -Rails.application.config.action_dispatch.cookies_serializer = :json diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb deleted file mode 100644 index c0b717f..0000000 --- a/config/initializers/filter_parameter_logging.rb +++ /dev/null @@ -1,8 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. -# Use this to limit dissemination of sensitive information. -# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. -Rails.application.config.filter_parameters += [ - :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc -] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb deleted file mode 100644 index fa9b938..0000000 --- a/config/initializers/inflections.rb +++ /dev/null @@ -1,16 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Add new inflection rules using the following format. Inflections -# are locale specific, and you may define rules for as many different -# locales as you wish. All of these examples are active by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.plural /^(ox)$/i, "\\1en" -# inflect.singular /^(ox)en/i, "\\1" -# inflect.irregular "person", "people" -# inflect.uncountable %w( fish sheep ) -# end - -# These inflection rules are supported but not enabled by default: -ActiveSupport::Inflector.inflections(:en) do |inflect| - inflect.acronym "API" -end diff --git a/config/initializers/locale.rb b/config/initializers/locale.rb deleted file mode 100644 index d89dac7..0000000 --- a/config/initializers/locale.rb +++ /dev/null @@ -1,9 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. -# Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. -# Rails.application.config.time_zone = 'Central Time (US & Canada)' - -# The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. -# Rails.application.config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] -# Rails.application.config.i18n.default_locale = :de diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb deleted file mode 100644 index 1e53b37..0000000 --- a/config/initializers/mime_types.rb +++ /dev/null @@ -1,5 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Add new mime types for use in respond_to blocks: -Mime::Type.register "text/ryg", :ryg -# Mime::Type.register "text/richtext", :rtf diff --git a/config/initializers/new_framework_defaults_5_2.rb b/config/initializers/new_framework_defaults_5_2.rb deleted file mode 100644 index c383d07..0000000 --- a/config/initializers/new_framework_defaults_5_2.rb +++ /dev/null @@ -1,38 +0,0 @@ -# Be sure to restart your server when you modify this file. -# -# This file contains migration options to ease your Rails 5.2 upgrade. -# -# Once upgraded flip defaults one by one to migrate to the new default. -# -# Read the Guide for Upgrading Ruby on Rails for more info on each option. - -# Make Active Record use stable #cache_key alongside new #cache_version method. -# This is needed for recyclable cache keys. -# Rails.application.config.active_record.cache_versioning = true - -# Use AES-256-GCM authenticated encryption for encrypted cookies. -# Also, embed cookie expiry in signed or encrypted cookies for increased security. -# -# This option is not backwards compatible with earlier Rails versions. -# It's best enabled when your entire app is migrated and stable on 5.2. -# -# Existing cookies will be converted on read then written with the new scheme. -# Rails.application.config.action_dispatch.use_authenticated_cookie_encryption = true - -# Use AES-256-GCM authenticated encryption as default cipher for encrypting messages -# instead of AES-256-CBC, when use_authenticated_message_encryption is set to true. -# Rails.application.config.active_support.use_authenticated_message_encryption = true - -# Add default protection from forgery to ActionController::Base instead of in -# ApplicationController. -# Rails.application.config.action_controller.default_protect_from_forgery = true - -# Store boolean values are in sqlite3 databases as 1 and 0 instead of 't' and -# 'f' after migrating old data. -# Rails.application.config.active_record.sqlite3.represent_boolean_as_integer = true - -# Use SHA-1 instead of MD5 to generate non-sensitive digests, such as the ETag header. -# Rails.application.config.active_support.use_sha1_digests = true - -# Make `form_with` generate id attributes for any generated HTML tags. -# Rails.application.config.action_view.form_with_generates_ids = true diff --git a/config/initializers/new_framework_defaults_8_0.rb b/config/initializers/new_framework_defaults_8_0.rb deleted file mode 100644 index 92efa95..0000000 --- a/config/initializers/new_framework_defaults_8_0.rb +++ /dev/null @@ -1,30 +0,0 @@ -# Be sure to restart your server when you modify this file. -# -# This file eases your Rails 8.0 framework defaults upgrade. -# -# Uncomment each configuration one by one to switch to the new default. -# Once your application is ready to run with all new defaults, you can remove -# this file and set the `config.load_defaults` to `8.0`. -# -# Read the Guide for Upgrading Ruby on Rails for more info on each option. -# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html - -### -# Specifies whether `to_time` methods preserve the UTC offset of their receivers or preserves the timezone. -# If set to `:zone`, `to_time` methods will use the timezone of their receivers. -# If set to `:offset`, `to_time` methods will use the UTC offset. -# If `false`, `to_time` methods will convert to the local system UTC offset instead. -#++ -# Rails.application.config.active_support.to_time_preserves_timezone = :zone - -### -# When both `If-Modified-Since` and `If-None-Match` are provided by the client -# only consider `If-None-Match` as specified by RFC 7232 Section 6. -# If set to `false` both conditions need to be satisfied. -#++ -# Rails.application.config.action_dispatch.strict_freshness = true - -### -# Set `Regexp.timeout` to `1`s by default to improve security over Regexp Denial-of-Service attacks. -#++ -# Regexp.timeout = 1 diff --git a/config/initializers/particle.rb b/config/initializers/particle.rb deleted file mode 100644 index 2820074..0000000 --- a/config/initializers/particle.rb +++ /dev/null @@ -1,3 +0,0 @@ -Particle.configure do |c| - c.access_token = ENV["PARTICLE_ACCESS_TOKEN"] -end diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb deleted file mode 100644 index 7db3b95..0000000 --- a/config/initializers/permissions_policy.rb +++ /dev/null @@ -1,13 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Define an application-wide HTTP permissions policy. For further -# information see: https://developers.google.com/web/updates/2018/06/feature-policy - -# Rails.application.config.permissions_policy do |policy| -# policy.camera :none -# policy.gyroscope :none -# policy.microphone :none -# policy.usb :none -# policy.fullscreen :self -# policy.payment :self, "https://secure.example.com" -# end diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb deleted file mode 100644 index 5b04c51..0000000 --- a/config/initializers/session_store.rb +++ /dev/null @@ -1,3 +0,0 @@ -# Be sure to restart your server when you modify this file. - -Rails.application.config.session_store :cookie_store, key: "_buildlight_session" diff --git a/config/initializers/wrap_parameters.rb b/config/initializers/wrap_parameters.rb deleted file mode 100644 index bbfc396..0000000 --- a/config/initializers/wrap_parameters.rb +++ /dev/null @@ -1,14 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# This file contains settings for ActionController::ParamsWrapper which -# is enabled by default. - -# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. -ActiveSupport.on_load(:action_controller) do - wrap_parameters format: [:json] -end - -# To enable root element in JSON for ActiveRecord objects. -# ActiveSupport.on_load(:active_record) do -# self.include_root_in_json = true -# end diff --git a/config/locales/en.yml b/config/locales/en.yml deleted file mode 100644 index decc5a8..0000000 --- a/config/locales/en.yml +++ /dev/null @@ -1,33 +0,0 @@ -# Files in the config/locales directory are used for internationalization -# and are automatically loaded by Rails. If you want to use locales other -# than English, add the necessary files in this directory. -# -# To use the locales, use `I18n.t`: -# -# I18n.t 'hello' -# -# In views, this is aliased to just `t`: -# -# <%= t('hello') %> -# -# To use a different locale, set it with `I18n.locale`: -# -# I18n.locale = :es -# -# This would use the information in config/locales/es.yml. -# -# The following keys must be escaped otherwise they will not be retrieved by -# the default I18n backend: -# -# true, false, on, off, yes, no -# -# Instead, surround them with single quotes. -# -# en: -# 'true': 'foo' -# -# To learn more, please read the Rails Internationalization guide -# available at http://guides.rubyonrails.org/i18n.html. - -en: - hello: "Hello world" diff --git a/config/puma.rb b/config/puma.rb deleted file mode 100644 index a248513..0000000 --- a/config/puma.rb +++ /dev/null @@ -1,41 +0,0 @@ -# This configuration file will be evaluated by Puma. The top-level methods that -# are invoked here are part of Puma's configuration DSL. For more information -# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. -# -# Puma starts a configurable number of processes (workers) and each process -# serves each request in a thread from an internal thread pool. -# -# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You -# should only set this value when you want to run 2 or more workers. The -# default is already 1. -# -# The ideal number of threads per worker depends both on how much time the -# application spends waiting for IO operations and on how much you wish to -# prioritize throughput over latency. -# -# As a rule of thumb, increasing the number of threads will increase how much -# traffic a given process can handle (throughput), but due to CRuby's -# Global VM Lock (GVL) it has diminishing returns and will degrade the -# response time (latency) of the application. -# -# The default is set to 3 threads as it's deemed a decent compromise between -# throughput and latency for the average Rails application. -# -# Any libraries that use a connection pool or another resource pool should -# be configured to provide at least as many connections as the number of -# threads. This includes Active Record's `pool` parameter in `database.yml`. -threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) -threads threads_count, threads_count - -# Specifies the `port` that Puma will listen on to receive requests; default is 3000. -port ENV.fetch("PORT", 3000) - -# Allow puma to be restarted by `bin/rails restart` command. -plugin :tmp_restart - -# Run the Solid Queue supervisor inside of Puma for single-server deployments -plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] - -# Specify the PID file. Defaults to tmp/pids/server.pid in development. -# In other environments, only set the PID file if requested. -pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/routes.rb b/config/routes.rb deleted file mode 100644 index a774ef8..0000000 --- a/config/routes.rb +++ /dev/null @@ -1,19 +0,0 @@ -Rails.application.routes.draw do - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. - get "up" => "rails/health#show", :as => :rails_health_check - - # use a namespace to avoid resources colliding with usernames - namespace :api do - resources :devices, only: :show - resource :device, only: [] do - post :trigger - get ":id/red" => "red#show" - end - end - - resources :devices, only: :show - get ":id(.:format)" => "colors#show" - get "/(.:format)" => "colors#index" - post "/" => "webhooks#create" -end diff --git a/config/secrets.yml b/config/secrets.yml deleted file mode 100644 index 705ebb0..0000000 --- a/config/secrets.yml +++ /dev/null @@ -1,22 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Your secret key is used for verifying the integrity of signed cookies. -# If you change this key, all old signed cookies will become invalid! - -# Make sure the secret is at least 30 characters and all random, -# no regular words or you'll be exposed to dictionary attacks. -# You can use `rake secret` to generate a secure secret key. - -# Make sure the secrets in this file are kept private -# if you're sharing your code publicly. - -development: - secret_key_base: 8d0348e17826916dae219e7319e5e7a0a8f359a12b402c1e4276d8f0acc553905ff499b0c27586fe6c3250dc45328abe99be4ba2648b83a6ab88f4d049497ed2 - -test: - secret_key_base: 5897b9e5d46263db0c851b9fe5248b1757aecab847ff72088a88623f21e15ec74bfb525d90ee9b0d687e3c51130904619b920d8552914b9982fae15955760536 - -# Do not keep production secrets in the repository, -# instead read values from the environment. -production: - secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> diff --git a/config/storage.yml b/config/storage.yml deleted file mode 100644 index d32f76e..0000000 --- a/config/storage.yml +++ /dev/null @@ -1,34 +0,0 @@ -test: - service: Disk - root: <%= Rails.root.join("tmp/storage") %> - -local: - service: Disk - root: <%= Rails.root.join("storage") %> - -# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) -# amazon: -# service: S3 -# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> -# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> -# region: us-east-1 -# bucket: your_own_bucket - -# Remember not to checkin your GCS keyfile to a repository -# google: -# service: GCS -# project: your_project -# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> -# bucket: your_own_bucket - -# Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -# microsoft: -# service: AzureStorage -# storage_account_name: your_account_name -# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> -# container: your_container_name - -# mirror: -# service: Mirror -# primary: local -# mirrors: [ amazon, google, microsoft ] diff --git a/db/migrate/20121123160543_create_statuses.rb b/db/migrate/20121123160543_create_statuses.rb deleted file mode 100644 index 3e43483..0000000 --- a/db/migrate/20121123160543_create_statuses.rb +++ /dev/null @@ -1,17 +0,0 @@ -class CreateStatuses < ActiveRecord::Migration[4.2] - def change - create_table :statuses do |t| - t.string :project_id - t.string :project_name - t.string :status - - t.timestamps - end - - add_index :statuses, :project_id - add_index :statuses, :project_name - add_index :statuses, :status - add_index :statuses, [:project_id, :status] - add_index :statuses, [:project_id, :status, :created_at] - end -end diff --git a/db/migrate/20121123172057_add_payload_to_statuses.rb b/db/migrate/20121123172057_add_payload_to_statuses.rb deleted file mode 100644 index f1d342f..0000000 --- a/db/migrate/20121123172057_add_payload_to_statuses.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddPayloadToStatuses < ActiveRecord::Migration[4.2] - def change - add_column :statuses, :payload, :text - end -end diff --git a/db/migrate/20121123182506_rename_status_to_color_on_statuses.rb b/db/migrate/20121123182506_rename_status_to_color_on_statuses.rb deleted file mode 100644 index ec0c933..0000000 --- a/db/migrate/20121123182506_rename_status_to_color_on_statuses.rb +++ /dev/null @@ -1,13 +0,0 @@ -class RenameStatusToColorOnStatuses < ActiveRecord::Migration[4.2] - def change - remove_index :statuses, :status - remove_index :statuses, [:project_id, :status] - remove_index :statuses, [:project_id, :status, :created_at] - - rename_column :statuses, :status, :color - - add_index :statuses, :color - add_index :statuses, [:project_id, :color] - add_index :statuses, [:project_id, :color, :created_at] - end -end diff --git a/db/migrate/20121123195427_split_colors_on_statuses.rb b/db/migrate/20121123195427_split_colors_on_statuses.rb deleted file mode 100644 index 68bbabd..0000000 --- a/db/migrate/20121123195427_split_colors_on_statuses.rb +++ /dev/null @@ -1,16 +0,0 @@ -class SplitColorsOnStatuses < ActiveRecord::Migration[4.2] - def change - change_table :statuses, bulk: true do |t| - t.remove_index :color - t.remove_index [:project_id, :color] - t.remove_index [:project_id, :color, :created_at] - - t.boolean :red - t.boolean :yellow - t.remove :color, type: :string - - t.index :red - t.index :yellow - end - end -end diff --git a/db/migrate/20121124190606_add_user_to_statuses.rb b/db/migrate/20121124190606_add_user_to_statuses.rb deleted file mode 100644 index 176b9a8..0000000 --- a/db/migrate/20121124190606_add_user_to_statuses.rb +++ /dev/null @@ -1,9 +0,0 @@ -class AddUserToStatuses < ActiveRecord::Migration[4.2] - def change - add_column :statuses, :username, :string - add_index :statuses, :username - add_index :statuses, [:username, :project_name] - add_index :statuses, [:username, :red] - add_index :statuses, [:username, :yellow] - end -end diff --git a/db/migrate/20160510201736_create_devices.rb b/db/migrate/20160510201736_create_devices.rb deleted file mode 100644 index da6c378..0000000 --- a/db/migrate/20160510201736_create_devices.rb +++ /dev/null @@ -1,12 +0,0 @@ -class CreateDevices < ActiveRecord::Migration[4.2] - def change - enable_extension "uuid-ossp" - - create_table :devices, id: :uuid do |t| - t.string :usernames, array: true, default: [], null: false - t.string :projects, array: true, default: [], null: false - - t.timestamps null: false - end - end -end diff --git a/db/migrate/20160510212722_add_identifier_to_devices.rb b/db/migrate/20160510212722_add_identifier_to_devices.rb deleted file mode 100644 index 7962ad3..0000000 --- a/db/migrate/20160510212722_add_identifier_to_devices.rb +++ /dev/null @@ -1,6 +0,0 @@ -class AddIdentifierToDevices < ActiveRecord::Migration[4.2] - def change - add_column :devices, :identifier, :string, null: false - add_index :devices, :identifier, unique: true - end -end diff --git a/db/migrate/20160510213407_add_name_to_devices.rb b/db/migrate/20160510213407_add_name_to_devices.rb deleted file mode 100644 index eebd82d..0000000 --- a/db/migrate/20160510213407_add_name_to_devices.rb +++ /dev/null @@ -1,6 +0,0 @@ -class AddNameToDevices < ActiveRecord::Migration[4.2] - def change - add_column :devices, :name, :string, null: false - add_index :devices, :name - end -end diff --git a/db/migrate/20161012193415_add_service_to_status.rb b/db/migrate/20161012193415_add_service_to_status.rb deleted file mode 100644 index 865c12e..0000000 --- a/db/migrate/20161012193415_add_service_to_status.rb +++ /dev/null @@ -1,14 +0,0 @@ -class AddServiceToStatus < ActiveRecord::Migration[5.0] - class Status < ApplicationRecord - end - - def up - add_column :statuses, :service, :string - Status.update_all(service: "travis") - change_column :statuses, :service, :string, null: false - end - - def down - remove_column :statuses, :service - end -end diff --git a/db/migrate/20230303181951_add_webhook_url_to_devices.rb b/db/migrate/20230303181951_add_webhook_url_to_devices.rb deleted file mode 100644 index 8ce3cca..0000000 --- a/db/migrate/20230303181951_add_webhook_url_to_devices.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddWebhookUrlToDevices < ActiveRecord::Migration[7.0] - def change - add_column :devices, :webhook_url, :string - end -end diff --git a/db/migrate/20230304135152_add_slug_to_devices.rb b/db/migrate/20230304135152_add_slug_to_devices.rb deleted file mode 100644 index 9358722..0000000 --- a/db/migrate/20230304135152_add_slug_to_devices.rb +++ /dev/null @@ -1,7 +0,0 @@ -class AddSlugToDevices < ActiveRecord::Migration[7.0] - def change - enable_extension :citext - add_column :devices, :slug, :citext, null: true - add_index :devices, :slug, unique: true - end -end diff --git a/db/migrate/20230304144230_make_identifier_nullable_on_devices.rb b/db/migrate/20230304144230_make_identifier_nullable_on_devices.rb deleted file mode 100644 index 106f799..0000000 --- a/db/migrate/20230304144230_make_identifier_nullable_on_devices.rb +++ /dev/null @@ -1,5 +0,0 @@ -class MakeIdentifierNullableOnDevices < ActiveRecord::Migration[7.0] - def change - change_column_null :devices, :identifier, true - end -end diff --git a/db/migrate/20230305131208_add_status_to_devices.rb b/db/migrate/20230305131208_add_status_to_devices.rb deleted file mode 100644 index a4fd393..0000000 --- a/db/migrate/20230305131208_add_status_to_devices.rb +++ /dev/null @@ -1,8 +0,0 @@ -class AddStatusToDevices < ActiveRecord::Migration[7.0] - def change - change_table :devices, bulk: true do |t| - t.string :status - t.datetime :status_changed_at - end - end -end diff --git a/db/migrate/20230311115915_add_workflow_to_statues.rb b/db/migrate/20230311115915_add_workflow_to_statues.rb deleted file mode 100644 index 14073ee..0000000 --- a/db/migrate/20230311115915_add_workflow_to_statues.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddWorkflowToStatues < ActiveRecord::Migration[7.0] - def change - add_column :statuses, :workflow, :string - end -end diff --git a/db/schema.rb b/db/schema.rb deleted file mode 100644 index a834327..0000000 --- a/db/schema.rb +++ /dev/null @@ -1,56 +0,0 @@ -# This file is auto-generated from the current state of the database. Instead -# of editing this file, please use the migrations feature of Active Record to -# incrementally modify your database, and then regenerate this schema definition. -# -# This file is the source Rails uses to define your schema when running `bin/rails -# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to -# be faster and is potentially less error prone than running all of your -# migrations from scratch. Old migrations may fail to apply correctly if those -# migrations use external dependencies or application code. -# -# It's strongly recommended that you check this file into your version control system. - -ActiveRecord::Schema[7.0].define(version: 2023_03_11_115915) do - # These are extensions that must be enabled in order to support this database - enable_extension "citext" - enable_extension "plpgsql" - enable_extension "uuid-ossp" - - create_table "devices", id: :uuid, default: -> { "uuid_generate_v4()" }, force: :cascade do |t| - t.string "usernames", default: [], null: false, array: true - t.string "projects", default: [], null: false, array: true - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.string "identifier" - t.string "name", null: false - t.string "webhook_url" - t.citext "slug" - t.string "status" - t.datetime "status_changed_at" - t.index ["identifier"], name: "index_devices_on_identifier", unique: true - t.index ["name"], name: "index_devices_on_name" - t.index ["slug"], name: "index_devices_on_slug", unique: true - end - - create_table "statuses", force: :cascade do |t| - t.string "project_id" - t.string "project_name" - t.datetime "created_at" - t.datetime "updated_at" - t.text "payload" - t.boolean "red" - t.boolean "yellow" - t.string "username" - t.string "service", null: false - t.string "workflow" - t.index ["project_id"], name: "index_statuses_on_project_id" - t.index ["project_name"], name: "index_statuses_on_project_name" - t.index ["red"], name: "index_statuses_on_red" - t.index ["username", "project_name"], name: "index_statuses_on_username_and_project_name" - t.index ["username", "red"], name: "index_statuses_on_username_and_red" - t.index ["username", "yellow"], name: "index_statuses_on_username_and_yellow" - t.index ["username"], name: "index_statuses_on_username" - t.index ["yellow"], name: "index_statuses_on_yellow" - end - -end diff --git a/db/seeds.rb b/db/seeds.rb deleted file mode 100644 index 4edb1e8..0000000 --- a/db/seeds.rb +++ /dev/null @@ -1,7 +0,0 @@ -# This file should contain all the record creation needed to seed the database with its default values. -# The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). -# -# Examples: -# -# cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) -# Mayor.create(name: 'Emanuel', city: cities.first) diff --git a/fly.toml b/fly.toml index 1051617..ba66dae 100644 --- a/fly.toml +++ b/fly.toml @@ -1,42 +1,33 @@ -# fly.toml app configuration file generated for buildlight on 2024-08-01T14:11:40-04:00 -# -# See https://fly.io/docs/reference/configuration/ for information about how to use this file. -# - -app = 'buildlight' -primary_region = 'iad' -console_command = '/rails/bin/rails console' +app = "buildlight" +primary_region = "iad" [build] [deploy] - release_command = "./bin/rails db:prepare" + release_command = "buildlight migrate" [env] - HOST = 'buildlight.collectiveidea.com' - PORT = '8080' - RUBYOPT = '--enable=frozen-string-literal' + HOST = "buildlight.collectiveidea.com" + PORT = "8080" [http_service] internal_port = 8080 force_https = true - auto_stop_machines = 'stop' + auto_stop_machines = "stop" auto_start_machines = true - min_machines_running = 1 - processes = ['app'] + min_machines_running = 0 - [[http_service.checks]] - grace_period = '10s' - interval = '30s' - method = 'GET' - timeout = '2s' - path = '/up' +[[http_service.checks]] + grace_period = "10s" + interval = "30s" + method = "GET" + timeout = "5s" + path = "/up" -[[vm]] - memory = '256mb' - cpu_kind = 'shared' - cpus = 1 [[statics]] - guest_path = "/rails/public" - url_prefix = "/" + guest_path = "/app/public" + url_prefix = "/public" +[[vm]] + size = "shared-cpu-1x" + memory = "256mb" diff --git a/lib/assets/.gitkeep b/lib/assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/tasks/.gitkeep b/lib/tasks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/log/.gitkeep b/log/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 5ce8282..0000000 --- a/package-lock.json +++ /dev/null @@ -1,494 +0,0 @@ -{ - "name": "app", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "app", - "dependencies": { - "sass": "^1.83.4" - }, - "engines": { - "node": "^22.0.0" - } - }, - "node_modules/@parcel/watcher": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", - "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", - "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", - "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", - "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", - "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", - "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", - "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", - "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", - "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", - "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", - "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", - "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "optional": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "license": "Apache-2.0", - "optional": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "optional": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/immutable": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", - "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", - "license": "MIT" - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "optional": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "optional": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT", - "optional": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/readdirp": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.1.tgz", - "integrity": "sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==", - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/sass": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", - "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", - "license": "MIT", - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 676b57b..0000000 --- a/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "app", - "private": "true", - "dependencies": { - "sass": "^1.83.4" - }, - "engines": { - "node": "^22.0.0" - }, - "scripts": { - "build:css": "sass ./app/assets/stylesheets/application.sass.scss ./app/assets/builds/application.css --no-source-map --load-path=node_modules" - } -} diff --git a/public/application.css b/public/application.css new file mode 100644 index 0000000..4e19d47 --- /dev/null +++ b/public/application.css @@ -0,0 +1,256 @@ +/* Reset */ +html { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; +} + +a { + background-color: transparent; + -webkit-text-decoration-skip: objects; +} + +a:active, +a:hover { + outline-width: 0; +} + +/* Elements */ +html { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-weight: 300; + line-height: 1.4; +} + +body { + background-color: #404040; + color: #e6e6e6; + font-size: 1em; +} + +ol, +ul { + padding-left: 1rem; +} + +a { + color: currentColor; +} + +a:focus, +a:hover { + color: #fff; +} + +/* Keyframes */ +@keyframes pulse { + 50% { transform: scale(.8); } +} + +/* Bulb */ +.bulb { + height: 50vmin; + position: relative; + width: 50vmin; +} + +.bulb__glow { + animation: pulse 5s infinite; + display: block; + height: 150vmax; + left: 50%; + margin-left: -75vmax; + margin-top: -75vmax; + opacity: 0; + position: absolute; + top: 50%; + transform: scale(.5); + transition: transform 1000ms, opacity 1000ms; + transition-delay: 250ms; + width: 150vmax; + z-index: 2; +} + +.bulb--yellow .bulb__glow { + z-index: 1; + animation-delay: -2.5s; +} + +.bulb__disc { + background-color: rgba(0, 0, 0, .2); + border-radius: 50%; + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + transition: background-color 250ms; + z-index: 3; +} + +.bulb__text { + font-size: 7.14286vmin; + font-weight: 800; + left: 25vmin; + line-height: 1; + margin-left: -25vmin; + margin-top: -3.57143vmin; + opacity: 0; + position: absolute; + text-align: center; + text-transform: lowercase; + top: 25vmin; + transition: opacity 500ms; + width: 50vmin; + z-index: 4; +} + +/* Red bulb active state */ +[data-failing] .bulb--red .bulb__glow { + background-image: radial-gradient( + ellipse at center, + rgba(207, 76, 41, .9) 10%, + rgba(207, 76, 41, .6) 20%, + rgba(207, 76, 41, .3) 40%, + rgba(207, 76, 41, 0) 70% + ); + opacity: 1; + transform: scale(1); +} + +[data-failing] .bulb--red .bulb__disc { + background-color: #cf4c29; +} + +[data-failing] .bulb--red .bulb__text { + opacity: 1; +} + +/* Yellow bulb active state */ +[data-building] .bulb--yellow .bulb__glow { + background-image: radial-gradient( + ellipse at center, + rgba(236, 197, 97, .9) 10%, + rgba(236, 197, 97, .6) 20%, + rgba(236, 197, 97, .3) 40%, + rgba(236, 197, 97, 0) 70% + ); + opacity: 1; + transform: scale(1); +} + +[data-building] .bulb--yellow .bulb__disc { + background-color: #ecc561; +} + +[data-building] .bulb--yellow .bulb__text { + opacity: 1; +} + +/* Green bulb active state */ +[data-passing] .bulb--green .bulb__glow { + background-image: radial-gradient( + ellipse at center, + rgba(40, 181, 110, .9) 10%, + rgba(40, 181, 110, .6) 20%, + rgba(40, 181, 110, .3) 40%, + rgba(40, 181, 110, 0) 70% + ); + opacity: 1; + transform: scale(1); +} + +[data-passing] .bulb--green .bulb__disc { + background-color: #28b56e; +} + +[data-passing] .bulb--green .bulb__text { + opacity: 1; +} + +/* Light (traffic light container) */ +.light { + bottom: 0; + left: 0; + overflow: hidden; + position: absolute; + right: 0; + top: 0; +} + +.light__box { + display: flex; + flex-direction: column; + height: 162vmin; + justify-content: space-between; + left: calc(50vw - 25vmin); + position: absolute; + top: calc(50vh - 81vmin); + transition: margin-top 500ms; + width: 50vmin; +} + +[data-failing] .light__box { + margin-top: 56vmin; +} + +[data-failing][data-building] .light__box { + margin-top: 28vmin; +} + +[data-passing] .light__box { + margin-top: -56vmin; +} + +[data-passing][data-building] .light__box { + margin-top: -28vmin; +} + +[data-failing][data-passing] .light__box { + margin-top: 0; + transform: scale(.75); +} + +/* Messages */ +.messages { + left: 5vmin; + position: absolute; + top: 5vmin; + z-index: 10; +} + +.messages__footer { + font-size: .8em; + opacity: .5; +} + +.messages__footer :first-child { + margin-top: 0; +} + +.message { + font-size: 0; + overflow: hidden; + padding-bottom: .5em; + transition: font-size 500ms, opacity 500ms; +} + +.message > * { + margin-top: 0; +} + +.message > :first-child { + font-size: 1.5em; + line-height: 1.2; + margin-bottom: .25em; +} + +[data-failing] .message--failure, +[data-building] .message--building, +[data-passing] .message--passing { + font-size: 1em; + opacity: 1; +} diff --git a/public/websocket.js b/public/websocket.js new file mode 100644 index 0000000..96bdc58 --- /dev/null +++ b/public/websocket.js @@ -0,0 +1,76 @@ +// WebSocket client for Buildlight +(function() { + var protocol = location.protocol === "https:" ? "wss:" : "ws:"; + var wsURL = protocol + "//" + location.host + "/ws"; + var ids, channelPrefix; + + if (document.location.pathname.match(/^\/devices\//)) { + ids = document.location.pathname.match(/^\/devices\/([^\/\?]*)/)[1].split(","); + channelPrefix = "device:"; + } else { + ids = document.location.pathname.match(/^\/([^\/\?]*)/)[1].split(","); + channelPrefix = "colors:"; + } + + function connect() { + var ws = new WebSocket(wsURL); + + ws.onopen = function() { + // Subscribe to channels + ids.forEach(function(id) { + if (id === "") id = "*"; + ws.send(JSON.stringify({ subscribe: channelPrefix + id })); + }); + }; + + ws.onmessage = function(event) { + var msg = JSON.parse(event.data); + var data = msg.data; + if (!data || !data.colors) return; + + var redCount = +data.colors.red; + var favicon = redCount ? "/public/favicon-failing" : "/public/favicon-passing"; + + if (redCount > 0) { + document.body.setAttribute("data-failing", ""); + document.body.removeAttribute("data-passing"); + var count = document.getElementById("failing-count"); + if (count) { + var message; + if (redCount === 1) { + message = "" + redCount + " project is"; + } else { + message = "" + redCount + " projects are"; + } + count.textContent = message; + } + } else { + document.body.removeAttribute("data-failing"); + document.body.setAttribute("data-passing", ""); + } + + if (data.colors.yellow) { + document.body.setAttribute("data-building", ""); + favicon += "-building"; + } else { + document.body.removeAttribute("data-building"); + } + + var faviconEl = document.getElementById("favicon"); + if (faviconEl) { + faviconEl.setAttribute("href", favicon + ".ico"); + } + }; + + ws.onclose = function() { + // Reconnect after 3 seconds + setTimeout(connect, 3000); + }; + + ws.onerror = function() { + ws.close(); + }; + } + + connect(); +})(); diff --git a/spec/controllers/api/devices_controller_spec.rb b/spec/controllers/api/devices_controller_spec.rb deleted file mode 100644 index 809d25d..0000000 --- a/spec/controllers/api/devices_controller_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -require "rails_helper" - -describe API::DevicesController do - describe "GET show" do - it "returns the colors" do - device = FactoryBot.create(:device, usernames: ["test"]) - FactoryBot.create(:status, username: "test", red: false, yellow: true) - - get :show, params: {id: device.id} - expect(response.status).to eq(200) - expect(response.body).to eq({colors: {red: false, yellow: true, green: true}, ryg: "rYG"}.to_json) - end - end - - describe "POST trigger" do - before do - FactoryBot.create(:device, identifier: "abc123") - allow(Particle).to receive(:publish) - end - - it "notifies Particle" do - expect(Particle).to receive(:publish).with(name: "build_state", data: "passing", ttl: 3600, private: false) - post :trigger, params: {name: "ready", data: "true", coreid: "abc123", published_at: "2016-06-14T22:06:10.976Z"} - end - - it "does not notify if there is no device" do - expect(Particle).not_to receive(:publish) - post :trigger, params: {name: "ready", data: "true", coreid: "FAKE", published_at: "2016-06-14T22:06:10.976Z"} - end - end -end diff --git a/spec/controllers/api/red_controller_spec.rb b/spec/controllers/api/red_controller_spec.rb deleted file mode 100644 index b1254de..0000000 --- a/spec/controllers/api/red_controller_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -require "rails_helper" - -describe API::RedController do - let!(:red1) { FactoryBot.create :status, red: true, username: "user1" } - let!(:red2) { FactoryBot.create :status, red: true, username: "user2" } - let!(:green1) { FactoryBot.create :status, username: "user1" } - let!(:green2) { FactoryBot.create :status, username: "user2" } - - describe "#show" do - render_views - - let!(:device) { FactoryBot.create(:device, identifier: "abc123", usernames: ["user1"]) } - - it "responds with the list of red project names" do - get :show, params: {id: "abc123"} - - expect(response.body).to match(/#{red1.project_name}/) - expect(response.body).not_to match(/#{red2.project_name}/) - expect(response.body).not_to match(/#{green1.project_name}/) - expect(response.body).not_to match(/#{green2.project_name}/) - end - - it "responds with the list of red projects serialized as json" do - get :show, params: {id: "abc123", format: :json} - - response_json = JSON.parse(response.body) - expect(response_json).to match_array([{"project_name" => red1.project_name, "username" => red1.username}]) - end - end -end diff --git a/spec/controllers/colors_controller_spec.rb b/spec/controllers/colors_controller_spec.rb deleted file mode 100644 index 32b6d5b..0000000 --- a/spec/controllers/colors_controller_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -require "rails_helper" - -describe ColorsController do - describe "index" do - it "shows the red light on if the last status is red" do - FactoryBot.create :status, red: true - get :index, params: {format: :json} - json = JSON.parse(response.body) - expect(json["red"]).to be_truthy - end - - it "shows the red and yellow lights on if the last status is yellow, but previous non-yellow was red" do - FactoryBot.create :status, red: true - 2.times { FactoryBot.create :status, red: false, yellow: true } - get :index, params: {format: :json} - json = JSON.parse(response.body) - expect(json["yellow"]).to be(true) - expect(json["red"]).to be_truthy - end - - it "shows the red light on if the last status is green, but another project is red" do - FactoryBot.create :status, red: true - FactoryBot.create :status, red: false - get :index, params: {format: :json} - json = JSON.parse(response.body) - expect(json["red"]).to be_truthy - end - end - - describe "show" do - it "shows the status for a single user" do - FactoryBot.create :status, username: "collectiveidea", red: false - FactoryBot.create :status, username: "danielmorrison", red: true - get :show, params: {id: "collectiveidea", format: :json} - json = JSON.parse(response.body) - expect(json["red"]).to be(false) - end - - it "shows the status for all users separated by a comma" do - FactoryBot.create :status, username: "collectiveidea", red: false, yellow: true - FactoryBot.create :status, username: "danielmorrison", red: true, yellow: false - get :show, params: {id: "collectiveidea,danielmorrison", format: :json} - json = JSON.parse(response.body) - expect(json["red"]).to be_truthy - expect(json["yellow"]).to be(true) - end - end -end diff --git a/spec/controllers/devices_controller_spec.rb b/spec/controllers/devices_controller_spec.rb deleted file mode 100644 index 362857b..0000000 --- a/spec/controllers/devices_controller_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -require "rails_helper" - -describe DevicesController do - describe "show" do - before do - FactoryBot.create :status, username: "collectiveidea", red: false - FactoryBot.create :status, username: "danielmorrison", red: true - @device = FactoryBot.create :device, usernames: ["collectiveidea"] - end - - it "shows the status for a single device by id" do - get :show, params: {id: @device.id, format: :json} - json = JSON.parse(response.body) - expect(json["red"]).to be(false) - end - - it "shows the status for a single device by slug" do - get :show, params: {id: @device.slug, format: :json} - json = JSON.parse(response.body) - expect(json["red"]).to be(false) - end - end -end diff --git a/spec/controllers/webhooks_controller_spec.rb b/spec/controllers/webhooks_controller_spec.rb deleted file mode 100644 index c40ac7d..0000000 --- a/spec/controllers/webhooks_controller_spec.rb +++ /dev/null @@ -1,73 +0,0 @@ -require "rails_helper" - -describe WebhooksController do - describe "POST create" do - before do - allow(Particle).to receive(:publish) - end - - describe "unknown data" do - it "ignores non-useful data" do - expect(Status.count).to eq(0) - data = {foo: "bar"} - post :create, params: data - expect(response.code).to eq("400") - expect(Status.count).to eq(0) - end - end - - describe "from Travis CI" do - it "recieves a json payload" do - post :create, params: {payload: json_fixture("travis.json")} - expect(response).to be_successful - end - - it "saves useful data" do - post :create, params: {payload: json_fixture("travis.json")} - status = Status.order("created_at DESC").first - expect(status.red).to be(false) - expect(status.service).to eq("travis") - expect(status.project_id).to eq("347744") - expect(status.project_name).to eq("buildlight") - expect(status.username).to eq("collectiveidea") - end - - it "ignores pull requests" do - expect(Status.count).to eq(0) - post :create, params: {payload: json_fixture("travis.json").sub(%("type":"push"), %("type":"pull_request"))} - expect(Status.count).to eq(0) - end - - it "notifies Particle" do - FactoryBot.create(:device, :with_identifier, usernames: ["collectiveidea"]) - allow(Particle).to receive(:publish) - post :create, params: {payload: json_fixture("travis.json")} - expect(Particle).to have_received(:publish).with(name: "build_state", data: "passing", ttl: 3600, private: false) - end - end - - describe "from GitHub Actions" do - it "recieves a json payload" do - post :create, params: JSON.parse(json_fixture("github.json")) - expect(response).to be_successful - end - - it "saves useful data" do - post :create, params: JSON.parse(json_fixture("github.json")) - status = Status.order("created_at DESC").first - expect(status.red).to be(false) - expect(status.service).to eq("github") - expect(status.project_id).to be_nil - expect(status.project_name).to eq("buildlight") - expect(status.username).to eq("collectiveidea") - end - - it "notifies Particle" do - FactoryBot.create(:device, :with_identifier, usernames: ["collectiveidea"]) - allow(Particle).to receive(:publish) - post :create, params: JSON.parse(json_fixture("github.json")) - expect(Particle).to have_received(:publish).with(name: "build_state", data: "passing", ttl: 3600, private: false) - end - end - end -end diff --git a/spec/factories.rb b/spec/factories.rb deleted file mode 100644 index 7716da4..0000000 --- a/spec/factories.rb +++ /dev/null @@ -1,20 +0,0 @@ -FactoryBot.define do - factory :device do - usernames { [] } - projects { [] } - sequence(:name) { |i| "Device #{i}" } - sequence(:slug) { |i| "slug-#{i}" } - - trait :with_identifier do - sequence(:identifier) { |i| "device-#{i}" } - end - end - - factory :status do - service { "travis" } - sequence(:project_id, &:to_s) - sequence(:project_name) { |i| "buildlight#{i}" } - red { false } - yellow { false } - end -end diff --git a/spec/fixtures/circle.json b/spec/fixtures/circle.json deleted file mode 100644 index 9bd8452..0000000 --- a/spec/fixtures/circle.json +++ /dev/null @@ -1 +0,0 @@ -{"type":"workflow-completed","id":"5a0111d5-3fa7-3180-926d-63a61a4b81a8","happened_at":"2024-01-09T21:10:40.799826Z","webhook":{"id":"e6b36f33-5e50-4492-9a16-cced97041b64","name":"Webhook Bin"},"workflow":{"id":"a2a8d81e-9de0-4e07-981c-cb14dbbe0a89","name":"workflow","created_at":"2024-01-09T21:09:36.583Z","stopped_at":"2024-01-09T21:10:40.684Z","url":"https://app.circleci.com/pipelines/github/collectiveidea/buildlight/350/workflows/a2a8d81e-9de0-4e07-981c-cb14dbbe0a89","status":"success"},"pipeline":{"id":"2b13912c-88e7-47e3-999b-30c164c573d9","number":350,"created_at":"2024-01-09T20:53:23.960Z","trigger":{"type":"webhook"},"vcs":{"provider_name":"github","origin_repository_url":"https://github.com/collectiveidea/buildlight","target_repository_url":"https://github.com/collectiveidea/buildlight","revision":"3287ff41025001a2aff89d6aaa57387851f9d588","commit":{"subject":"Merge pull request #233 from collectiveidea/upgrade","body":"Upgrade to Rails 7.1","author":{"name":"Daniel Morrison","email":"daniel@collectiveidea.com"},"authored_at":"2024-01-09T20:53:21Z","committer":{"name":"GitHub","email":"noreply@github.com"},"committed_at":"2024-01-09T20:53:21Z"},"branch":"main"}},"project":{"id":"b7548785-2c2f-44e2-9062-35b4f960875c","name":"buildlight","slug":"github/collectiveidea/buildlight"},"organization":{"id":"cfdfbe86-a83b-41cb-a584-916c604f08bc","name":"collectiveidea"}} diff --git a/spec/fixtures/circle_pr.json b/spec/fixtures/circle_pr.json deleted file mode 100644 index fd9b64a..0000000 --- a/spec/fixtures/circle_pr.json +++ /dev/null @@ -1 +0,0 @@ -{"type":"workflow-completed","id":"49ebe776-6037-34dc-bf7f-4a15fe63b028","happened_at":"2024-01-10T15:45:30.693234Z","webhook":{"id":"e6b36f33-5e50-4492-9a16-cced97041b64","name":"Webhook Bin"},"workflow":{"id":"eff726c2-47ce-46ea-9b6d-f9481eca3123","name":"workflow","created_at":"2024-01-10T15:44:28.957Z","stopped_at":"2024-01-10T15:45:30.550Z","url":"https://app.circleci.com/pipelines/github/collectiveidea/buildlight/354/workflows/eff726c2-47ce-46ea-9b6d-f9481eca3123","status":"success"},"pipeline":{"id":"e623a4e6-27fc-4b79-87c6-0eda834d10b8","number":354,"created_at":"2024-01-10T15:44:28.800Z","trigger":{"type":"webhook"},"vcs":{"provider_name":"github","origin_repository_url":"https://github.com/collectiveidea/buildlight","target_repository_url":"https://github.com/collectiveidea/buildlight","revision":"2637f724b1e7a6f1a980f70a26c595e9014f273e","commit":{"subject":"Switch Circle config to new webhook format","body":"","author":{"name":"Daniel Morrison","email":"daniel@collectiveidea.com"},"authored_at":"2024-01-10T15:44:18Z","committer":{"name":"Daniel Morrison","email":"daniel@collectiveidea.com"},"committed_at":"2024-01-10T15:44:18Z"},"branch":"circle-webhook-upgrade"}},"project":{"id":"b7548785-2c2f-44e2-9062-35b4f960875c","name":"buildlight","slug":"github/collectiveidea/buildlight"},"organization":{"id":"cfdfbe86-a83b-41cb-a584-916c604f08bc","name":"collectiveidea"}} diff --git a/spec/fixtures/github.json b/spec/fixtures/github.json deleted file mode 100644 index 52f043a..0000000 --- a/spec/fixtures/github.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "status": "success", - "repository": "collectiveidea/buildlight", - "workflow": "CI" -} diff --git a/spec/fixtures/travis.json b/spec/fixtures/travis.json deleted file mode 100644 index 414d856..0000000 --- a/spec/fixtures/travis.json +++ /dev/null @@ -1 +0,0 @@ -{"id":3329467,"repository":{"id":347744,"name":"buildlight","owner_name":"collectiveidea","url":"https://github.com/collectiveidea/buildlight"},"number":"2","config":{"language":"ruby","rvm":["1.9.3"],"before_script":["rake db:migrate"],"notifications":{"webhooks":{"urls":["https://buildlight.herokuapp.com/"]}},".result":"configured"},"status":0,"result":0,"status_message":"Passed","result_message":"Passed","started_at":"2012-11-23T16:29:59Z","finished_at":"2012-11-23T16:31:17Z","duration":78,"build_url":"https://travis-ci.org/collectiveidea/buildlight/builds/3329467","commit":"05f7a7e32f73fae1dc2448dba54b641e9ead7eb2","branch":"master","message":"map root path properly for posts","compare_url":"https://github.com/collectiveidea/buildlight/compare/233362da1216...05f7a7e32f73","committed_at":"2012-11-23T16:29:41Z","author_name":"Daniel Morrison","author_email":"daniel@collectiveidea.com","committer_name":"Daniel Morrison","committer_email":"daniel@collectiveidea.com","matrix":[{"id":3329468,"repository_id":347744,"parent_id":3329467,"number":"2.1","state":"finished","config":{"language":"ruby","rvm":"1.9.3","before_script":["rake db:migrate"],"notifications":{"webhooks":{"urls":["https://buildlight.herokuapp.com/"]}},".result":"configured"},"status":0,"result":0,"commit":"05f7a7e32f73fae1dc2448dba54b641e9ead7eb2","branch":"master","message":"map root path properly for posts","compare_url":"https://github.com/collectiveidea/buildlight/compare/233362da1216...05f7a7e32f73","committed_at":"2012-11-23T16:29:41Z","author_name":"Daniel Morrison","author_email":"daniel@collectiveidea.com","committer_name":"Daniel Morrison","committer_email":"daniel@collectiveidea.com","finished_at":"2012-11-23T16:31:17Z"}],"type":"push"} \ No newline at end of file diff --git a/spec/interactors/parse_circle_spec.rb b/spec/interactors/parse_circle_spec.rb deleted file mode 100644 index 126bb09..0000000 --- a/spec/interactors/parse_circle_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -require "rails_helper" - -describe ParseCircle do - describe "set_colors" do - before do - @status = Status.new(service: "circle") - end - - it "sets success to green" do - ParseCircle.set_colors(@status, "success") - expect(@status.red).to be(false) - expect(@status.yellow).to be(false) - end - - it "sets failed to red" do - ParseCircle.set_colors(@status, "failed") - expect(@status.red).to be(true) - expect(@status.yellow).to be(false) - end - end -end diff --git a/spec/interactors/parse_github_spec.rb b/spec/interactors/parse_github_spec.rb deleted file mode 100644 index 99ef94e..0000000 --- a/spec/interactors/parse_github_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -require "rails_helper" - -describe ParseGithub do - describe "call" do - it "uses worflow column to differentiate between statuses" do - other_status = FactoryBot.create :status, service: "github", username: "collectiveidea", project_name: "buildlight", workflow: "Other Workflow", red: true - ParseGithub.call(JSON.parse(json_fixture("github.json"))) - expect(other_status.reload.red).to be(true) - expect(Status.where(service: "github", username: "collectiveidea", project_name: "buildlight").count).to eq(2) - end - end - - describe "set_colors" do - before do - @status = Status.new(service: "github") - end - - it "sets 'success' to green" do - ParseGithub.set_colors(@status, "success") - expect(@status.red).to be(false) - expect(@status.yellow).to be(false) - end - - it "sets 'failure' to red" do - ParseGithub.set_colors(@status, "failure") - expect(@status.red).to be(true) - expect(@status.yellow).to be(false) - end - - it "sets '' to yellow" do - ParseGithub.set_colors(@status, "") - expect(@status.yellow).to be(true) - end - - it "keeps the red color if yellow" do - @status.red = true - ParseGithub.set_colors(@status, "") - expect(@status.red).to be(true) - end - end -end diff --git a/spec/interactors/parse_travis_spec.rb b/spec/interactors/parse_travis_spec.rb deleted file mode 100644 index c673126..0000000 --- a/spec/interactors/parse_travis_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -require "rails_helper" - -describe ParseTravis do - describe "set_colors" do - before do - @status = Status.new - end - - it "sets Passed to green" do - ParseTravis.set_colors(@status, "Passed") - expect(@status.red).to be(false) - expect(@status.yellow).to be(false) - end - - it "sets Fixed to green" do - ParseTravis.set_colors(@status, "Fixed") - expect(@status.red).to be(false) - expect(@status.yellow).to be(false) - end - - it "sets Still Failing to red" do - ParseTravis.set_colors(@status, "Still Failing") - expect(@status.red).to be(true) - expect(@status.yellow).to be(false) - end - - it "sets Pending to yellow" do - ParseTravis.set_colors(@status, "Pending") - expect(@status.yellow).to be(true) - end - - it "keeps the red color if yellow" do - @status.red = true - ParseTravis.set_colors(@status, "Pending") - expect(@status.red).to be(true) - end - end -end diff --git a/spec/interactors/trigger_webhook_spec.rb b/spec/interactors/trigger_webhook_spec.rb deleted file mode 100644 index 1231e61..0000000 --- a/spec/interactors/trigger_webhook_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -require "rails_helper" - -describe TriggerWebhook do - describe "triggering a webhook" do - let!(:status) { FactoryBot.create(:status, username: "hooks", project_name: "buildlight") } - let!(:device) { FactoryBot.create(:device, usernames: ["hooks"]) } - - it "it sends a basic webhook" do - # Add webhook without triggering callbacks - device.update_column(:webhook_url, "https://localhost/fake/path") - - allow(Faraday).to receive(:post) - TriggerWebhook.call(device) - expect(Faraday).to have_received(:post).with( - "https://localhost/fake/path", - {colors: {red: false, yellow: false, green: true}}.to_json, - {"Content-Type": "application/json", "x-ryg": "ryG", "x-device-url": "http://locahost:3000/api/devices/#{device.id}"} - ) - end - end -end diff --git a/spec/models/device_spec.rb b/spec/models/device_spec.rb deleted file mode 100644 index ee85328..0000000 --- a/spec/models/device_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -require "rails_helper" - -RSpec.describe Device, type: :model do - describe "#statuses" do - let!(:status1) { FactoryBot.create(:status, username: "collectiveidea", project_name: "foo") } - let!(:status2) { FactoryBot.create(:status, username: "collectiveidea", project_name: "bar") } - let!(:status3) { FactoryBot.create(:status, username: "deadmanssnitch", project_name: "foo") } - let!(:status4) { FactoryBot.create(:status, username: "deadmanssnitch", project_name: "bar") } - let!(:status5) { FactoryBot.create(:status, username: "inchworm", project_name: "foo") } - - it "includes status by project" do - device = FactoryBot.create(:device, usernames: [], projects: ["collectiveidea/bar", "deadmanssnitch/foo"]) - - expect(device.statuses.size).to eq(2) - expect(device.statuses).to include(status2) - expect(device.statuses).to include(status3) - end - - it "includes status by username" do - device = FactoryBot.create(:device, usernames: ["collectiveidea", "inchworm"], projects: []) - - expect(device.statuses.size).to eq(3) - expect(device.statuses).to include(status1) - expect(device.statuses).to include(status2) - expect(device.statuses).to include(status5) - end - - it "includes status by username and project at the same time" do - device = FactoryBot.create(:device, usernames: ["collectiveidea"], projects: ["deadmanssnitch/bar"]) - - expect(device.statuses.size).to eq(3) - expect(device.statuses).to include(status1) - expect(device.statuses).to include(status2) - expect(device.statuses).to include(status4) - end - end - - describe "#status" do - it "returns the status for the device" do - FactoryBot.create(:status, username: "collectiveidea", project_name: "foo", red: false, yellow: false) - FactoryBot.create(:status, username: "collectiveidea", project_name: "bar", red: false, yellow: true) - FactoryBot.create(:status, username: "deadmanssnitch", project_name: "foo", red: false, yellow: false) - FactoryBot.create(:status, username: "deadmanssnitch", project_name: "bar", red: true, yellow: true) - - device = FactoryBot.create(:device, usernames: ["collectiveidea"], projects: ["deadmanssnitch/foo"]) - expect(device.reload.status).to eq("passing-building") - - FactoryBot.create(:status, username: "collectiveidea", project_name: "baz", red: true, yellow: false) - expect(device.reload.status).to eq("failing-building") - end - end - - describe "#trigger" do - context "when the device has a webhook_url" do - it "sends a webhook" do - device = FactoryBot.create(:device) - # Add webhook without triggering callbacks - device.update_column(:webhook_url, "https://localhost/fake/path") - - allow(TriggerWebhook).to receive(:call) - device.trigger - expect(TriggerWebhook).to have_received(:call).with(device) - end - end - - context "when the device has an identifier" do - it "sends a webhook" do - device = FactoryBot.create(:device, identifier: "fake") - allow(TriggerParticle).to receive(:call) - device.trigger - expect(TriggerParticle).to have_received(:call).with(device) - end - end - end -end diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb deleted file mode 100644 index 37801a5..0000000 --- a/spec/models/status_spec.rb +++ /dev/null @@ -1,105 +0,0 @@ -require "rails_helper" - -describe Status do - describe "colors_as_booleans" do - it "shows the red light as a boolean if the last status is red" do - FactoryBot.create :status, red: true - colors = Status.colors_as_booleans - expect(colors[:red]).to eq(true) - end - end - - describe "colors" do - describe "without a username" do - it "shows the red light on if the last status is red" do - FactoryBot.create :status, red: true - colors = Status.colors - expect(colors[:red]).to be_truthy - end - - it "shows the red light as a count if the last status is red" do - FactoryBot.create :status, red: true - colors = Status.colors - expect(colors[:red]).to eq(1) - end - - it "shows the red and yellow lights on if the last status is yellow, but previous non-yellow was red" do - FactoryBot.create :status, red: true - 2.times { FactoryBot.create :status, red: false, yellow: true } - colors = Status.colors - expect(colors[:yellow]).to be(true) - expect(colors[:red]).to be_truthy - end - - it "shows the red light on if the last status is green, but another project is red" do - FactoryBot.create :status, red: true - FactoryBot.create :status, red: false - colors = Status.colors - expect(colors[:red]).to be_truthy - end - end - - describe "with a username" do - before do - FactoryBot.create :status, username: "danielmorrison", red: true, yellow: true - end - - it "shows the red light on if the last status is red" do - FactoryBot.create :status, username: "collectiveidea", red: true - colors = Status.colors("collectiveidea") - expect(colors[:red]).to be_truthy - end - - it "shows the red and yellow lights on if the last status is yellow, but previous non-yellow was red" do - FactoryBot.create :status, username: "collectiveidea", red: true - 2.times { FactoryBot.create :status, username: "collectiveidea", red: false, yellow: true } - colors = Status.colors("collectiveidea") - expect(colors[:yellow]).to be(true) - expect(colors[:red]).to be_truthy - end - - it "shows the red light on if the last status is green, but another project is red" do - FactoryBot.create :status, username: "collectiveidea", red: true - FactoryBot.create :status, username: "collectiveidea", red: false - colors = Status.colors("collectiveidea") - expect(colors[:red]).to be_truthy - end - end - - describe "with multiple usernames" do - it "shows the red light on if the last status is red" do - FactoryBot.create :status, username: "collectiveidea", red: true, yellow: false - FactoryBot.create :status, username: "danielmorrison", red: false, yellow: true - colors = Status.colors(["collectiveidea", "danielmorrison"]) - expect(colors[:red]).to be_truthy - expect(colors[:yellow]).to be(true) - end - end - end - - describe "#name" do - it "returns the full github-style name" do - status = Status.new(username: "collectiveidea", project_name: "foo") - expect(status.name).to eq("collectiveidea/foo") - end - end - - describe "#devices" do - it "returns the list of Device objects that care about this status" do - device1 = FactoryBot.create(:device, usernames: ["collectiveidea"], projects: ["deadmanssnitch/foo"]) - device2 = FactoryBot.create(:device, usernames: ["collectiveidea", "deadmanssnitch"]) - device3 = FactoryBot.create(:device, usernames: ["deadmanssnitch"], projects: ["collectiveidea/foo"]) - device4 = FactoryBot.create(:device, usernames: [], projects: ["collectiveidea/foo"]) - device5 = FactoryBot.create(:device, usernames: ["deadmanssnitch"], projects: []) - - status = FactoryBot.create(:status, username: "collectiveidea", project_name: "foo") - - expect(status.devices).to include(device1) - expect(status.devices).to include(device2) - expect(status.devices).to include(device3) - expect(status.devices).to include(device4) - expect(status.devices).to include(device4) - expect(status.devices).not_to include(device5) - end - end -end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb deleted file mode 100644 index eae2a1f..0000000 --- a/spec/rails_helper.rb +++ /dev/null @@ -1,50 +0,0 @@ -# This file is copied to spec/ when you run 'rails generate rspec:install' -ENV["RAILS_ENV"] ||= "test" -require "spec_helper" -require File.expand_path("../../config/environment", __FILE__) -require "rspec/rails" -# Add additional requires below this line. Rails is not loaded until this point! - -# Requires supporting ruby files with custom matchers and macros, etc, in -# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are -# run as spec files by default. This means that files in spec/support that end -# in _spec.rb will both be required and run as specs, causing the specs to be -# run twice. It is recommended that you do not name files matching this glob to -# end with _spec.rb. You can configure this pattern with the --pattern -# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. -# -# The following line is provided for convenience purposes. It has the downside -# of increasing the boot-up time by auto-requiring all files in the support -# directory. Alternatively, in the individual `*_spec.rb` files, manually -# require only the support files necessary. -# -Rails.root.glob("spec/support/**/*.rb").sort.each { |f| require f } - -# Checks for pending migrations before tests are run. -# If you are not using ActiveRecord, you can remove this line. -ActiveRecord::Migration.maintain_test_schema! - -RSpec.configure do |config| - # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_paths = [Rails.root.join("spec/fixtures")] - - # If you're not using ActiveRecord, or you'd prefer not to run each of your - # examples within a transaction, remove the following line or assign false - # instead of true. - config.use_transactional_fixtures = true - - # RSpec Rails can automatically mix in different behaviours to your tests - # based on their file location, for example enabling you to call `get` and - # `post` in specs under `spec/controllers`. - # - # You can disable this behaviour by removing the line below, and instead - # explicitly tag your specs with their type, e.g.: - # - # RSpec.describe UsersController, :type => :controller do - # # ... - # end - # - # The different available types are documented in the features, such as in - # https://relishapp.com/rspec/rspec-rails/docs - config.infer_spec_type_from_file_location! -end diff --git a/spec/requests/circle_webhooks_spec.rb b/spec/requests/circle_webhooks_spec.rb deleted file mode 100644 index 8bb632a..0000000 --- a/spec/requests/circle_webhooks_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -require "rails_helper" - -describe "Webhooks from Circle CI" do - before do - allow(Particle).to receive(:publish) - end - - it "recieves a json payload" do - post "/", params: json_fixture("circle.json"), headers: {"content-type": "application/json", "Circleci-Event-Type": "workflow-completed"} - expect(response).to be_successful - end - - it "saves useful data" do - post "/", params: json_fixture("circle.json"), headers: {"content-type": "application/json", "Circleci-Event-Type": "workflow-completed"} - status = Status.order("created_at DESC").first - expect(status.red).to be(false) - expect(status.service).to eq("circle") - expect(status.project_id).to be_nil - expect(status.project_name).to eq("buildlight") - expect(status.username).to eq("collectiveidea") - end - - it "ignores pull requests" do - expect(Status.count).to eq(0) - post "/", params: json_fixture("circle_pr.json"), headers: {"content-type": "application/json", "Circleci-Event-Type": "workflow-completed"} - expect(Status.count).to eq(0) - end - - it "notifies Particle" do - FactoryBot.create(:device, :with_identifier, usernames: ["collectiveidea"]) - allow(Particle).to receive(:publish) - post "/", params: json_fixture("circle.json"), headers: {"content-type": "application/json", "Circleci-Event-Type": "workflow-completed"} - expect(Particle).to have_received(:publish).with(name: "build_state", data: "passing", ttl: 3600, private: false) - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100644 index 976d0de..0000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,85 +0,0 @@ -# This file was generated by the `rails generate rspec:install` command. Conventionally, all -# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. -# The generated `.rspec` file contains `--require spec_helper` which will cause -# this file to always be loaded, without a need to explicitly require it in any -# files. -# -# Given that it is always loaded, you are encouraged to keep this file as -# light-weight as possible. Requiring heavyweight dependencies from this file -# will add to the boot time of your test suite on EVERY test run, even for an -# individual file that may not need all of that loaded. Instead, consider making -# a separate helper file that requires the additional dependencies and performs -# the additional setup, and require it from the spec files that actually need -# it. -# -# The `.rspec` file also contains a few flags that are not defaults but that -# users commonly want. -# -# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration -RSpec.configure do |config| - # rspec-expectations config goes here. You can use an alternate - # assertion/expectation library such as wrong or the stdlib/minitest - # assertions if you prefer. - config.expect_with :rspec do |expectations| - # This option will default to `true` in RSpec 4. It makes the `description` - # and `failure_message` of custom matchers include text for helper methods - # defined using `chain`, e.g.: - # be_bigger_than(2).and_smaller_than(4).description - # # => "be bigger than 2 and smaller than 4" - # ...rather than: - # # => "be bigger than 2" - expectations.include_chain_clauses_in_custom_matcher_descriptions = true - end - - # rspec-mocks config goes here. You can use an alternate test double - # library (such as bogus or mocha) by changing the `mock_with` option here. - config.mock_with :rspec do |mocks| - # Prevents you from mocking or stubbing a method that does not exist on - # a real object. This is generally recommended, and will default to - # `true` in RSpec 4. - mocks.verify_partial_doubles = true - end - - # The settings below are suggested to provide a good initial experience - # with RSpec, but feel free to customize to your heart's content. - # # These two settings work together to allow you to limit a spec run - # # to individual examples or groups you care about by tagging them with - # # `:focus` metadata. When nothing is tagged with `:focus`, all examples - # # get run. - # config.filter_run :focus - # config.run_all_when_everything_filtered = true - # - # # Limits the available syntax to the non-monkey patched syntax that is - # # recommended. For more details, see: - # # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax - # # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ - # # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching - # config.disable_monkey_patching! - # - # # Many RSpec users commonly either run the entire suite or an individual - # # file, and it's useful to allow more verbose output when running an - # # individual spec file. - # if config.files_to_run.one? - # # Use the documentation formatter for detailed output, - # # unless a formatter has already been configured - # # (e.g. via a command-line flag). - # config.default_formatter = 'doc' - # end - # - # # Print the 10 slowest examples and example groups at the - # # end of the spec run, to help surface which specs are running - # # particularly slow. - # config.profile_examples = 10 - # - # Run specs in random order to surface order dependencies. If you find an - # order dependency and want to debug it, you can fix the order by providing - # the seed, which is printed after each run. - # --seed 1234 - config.order = :random - - # Seed global randomization in this process using the `--seed` CLI option. - # Setting this allows you to use `--seed` to deterministically reproduce - # test failures related to randomization by passing the same `--seed` value - # as the one that triggered the failure. - Kernel.srand config.seed -end diff --git a/spec/support/json_helpers.rb b/spec/support/json_helpers.rb deleted file mode 100644 index 0c37f2d..0000000 --- a/spec/support/json_helpers.rb +++ /dev/null @@ -1,11 +0,0 @@ -module JSONHelpers - ENV = {"CONTENT_TYPE" => "application/json"}.freeze - - def json_fixture(filename) - Rails.root.join("spec", "fixtures", filename).read - end -end - -RSpec.configure do |config| - config.include JSONHelpers -end diff --git a/src/db.zig b/src/db.zig new file mode 100644 index 0000000..938179f --- /dev/null +++ b/src/db.zig @@ -0,0 +1,50 @@ +const std = @import("std"); +const pg = @import("pg"); + +/// Initialize a connection pool from a DATABASE_URL string. +pub fn initPool(allocator: std.mem.Allocator, url: []const u8) !*pg.Pool { + const uri = std.Uri.parse(url) catch return error.InvalidDatabaseUrl; + return pg.Pool.initUri(allocator, uri, .{ + .size = 5, + }); +} + +/// Run all pending migrations. Each migration is embedded at compile time. +/// To add a new migration: add an entry to the `migrations` tuple below. +pub fn migrate(pool: *pg.Pool) !void { + // Ensure schema_migrations table exists + _ = try pool.exec( + \\CREATE TABLE IF NOT EXISTS schema_migrations ( + \\ version VARCHAR NOT NULL + \\) + , .{}); + _ = try pool.exec( + "CREATE UNIQUE INDEX IF NOT EXISTS unique_schema_migrations ON schema_migrations (version)", + .{}, + ); + + const migrations = .{ + .{ .version = "001", .sql = @embedFile("migrations/001_initial_schema.sql") }, + // Future migrations go here: + // .{ .version = "002", .sql = @embedFile("migrations/002_add_foo.sql") }, + }; + + inline for (migrations) |m| { + // Check if already applied + const result = try pool.query( + "SELECT version FROM schema_migrations WHERE version = $1", + .{m.version}, + ); + const already_applied = (try result.next()) != null; + result.deinit(); + + if (!already_applied) { + std.log.info("Running migration {s}", .{m.version}); + _ = try pool.exec(m.sql, .{}); + _ = try pool.exec( + "INSERT INTO schema_migrations (version) VALUES ($1)", + .{m.version}, + ); + } + } +} diff --git a/src/handlers.zig b/src/handlers.zig new file mode 100644 index 0000000..6712714 --- /dev/null +++ b/src/handlers.zig @@ -0,0 +1,407 @@ +const std = @import("std"); +const httpz = @import("httpz"); +const build_options = @import("build_options"); + +const models = @import("models.zig"); +const parsers = @import("parsers.zig"); +const ws = @import("websocket.zig"); +const templates = @import("templates.zig"); +const pg = @import("pg"); + +pub const Handler = struct { + pool: *pg.Pool, + hub: *ws.Hub, + allocator: std.mem.Allocator, + host: []const u8, + particle_token: ?[]const u8, + debug: bool, + + // Required by httpz for WebSocket support + pub const WebsocketHandler = ws.WsClient; + + // Called when no route matches + pub fn notFound(_: *Handler, _: *httpz.Request, res: *httpz.Response) !void { + res.status = 404; + if (build_options.embed_assets) { + res.body = @embedFile("../public/404.html"); + } else { + res.body = "Not Found"; + } + } + + // Called when a handler returns an error + pub fn uncaughtError(_: *Handler, req: *httpz.Request, res: *httpz.Response, err: anyerror) void { + std.log.err("{s} {s}: {}", .{ @tagName(req.method), req.url.path, err }); + res.status = 500; + if (build_options.embed_assets) { + res.body = @embedFile("../public/500.html"); + } else { + res.body = "Internal Server Error"; + } + } +}; + +pub fn healthCheck(_: *Handler, _: *httpz.Request, res: *httpz.Response) !void { + res.status = 200; + res.body = "OK"; +} + +/// Hand-rolled static file handler. In production on Fly.io, the [[statics]] +/// config serves these from the CDN. This handler covers local development. +pub fn serveStatic(_: *Handler, req: *httpz.Request, res: *httpz.Response) !void { + const path = req.url.path; + + // Strip leading "/public/" to get filename + if (!std.mem.startsWith(u8, path, "/public/")) { + res.status = 404; + return; + } + const filename = path["/public/".len..]; + + // Prevent directory traversal + if (std.mem.indexOf(u8, filename, "..") != null) { + res.status = 403; + return; + } + + if (build_options.embed_assets) { + // In release mode, serve from embedded files + const content = getEmbeddedPublicFile(filename) orelse { + res.status = 404; + return; + }; + res.content_type = contentTypeForExtension(filename); + res.body = content; + } else { + // In dev mode, read from filesystem + const full_path = std.fmt.allocPrint(res.arena, "public/{s}", .{filename}) catch { + res.status = 500; + return; + }; + const file = std.fs.cwd().openFile(full_path, .{}) catch { + res.status = 404; + return; + }; + defer file.close(); + const content = file.readToEndAlloc(res.arena, 1024 * 1024 * 2) catch { + res.status = 500; + return; + }; + res.content_type = contentTypeForExtension(filename); + res.body = content; + } +} + +fn contentTypeForExtension(filename: []const u8) httpz.ContentType { + if (std.mem.endsWith(u8, filename, ".css")) return .CSS; + if (std.mem.endsWith(u8, filename, ".js")) return .JS; + if (std.mem.endsWith(u8, filename, ".html")) return .HTML; + // Fly.io [[statics]] serves these with proper MIME types in production. + return .TEXT; +} + +fn getEmbeddedPublicFile(name: []const u8) ?[]const u8 { + if (std.mem.eql(u8, name, "application.css")) return @embedFile("../public/application.css"); + if (std.mem.eql(u8, name, "websocket.js")) return @embedFile("../public/websocket.js"); + if (std.mem.eql(u8, name, "favicon.ico")) return @embedFile("../public/favicon.ico"); + if (std.mem.eql(u8, name, "favicon-failing.ico")) return @embedFile("../public/favicon-failing.ico"); + if (std.mem.eql(u8, name, "favicon-passing.ico")) return @embedFile("../public/favicon-passing.ico"); + if (std.mem.eql(u8, name, "favicon-failing-building.ico")) return @embedFile("../public/favicon-failing-building.ico"); + if (std.mem.eql(u8, name, "favicon-passing-building.ico")) return @embedFile("../public/favicon-passing-building.ico"); + if (std.mem.eql(u8, name, "404.html")) return @embedFile("../public/404.html"); + if (std.mem.eql(u8, name, "422.html")) return @embedFile("../public/422.html"); + if (std.mem.eql(u8, name, "500.html")) return @embedFile("../public/500.html"); + if (std.mem.eql(u8, name, "robots.txt")) return @embedFile("../public/robots.txt"); + if (std.mem.eql(u8, name, "icon.png")) return @embedFile("../public/icon.png"); + if (std.mem.eql(u8, name, "icon.svg")) return @embedFile("../public/icon.svg"); + if (std.mem.eql(u8, name, "collectiveidea.gif")) return @embedFile("../public/collectiveidea.gif"); + return null; +} + +pub fn wsUpgrade(handler: *Handler, req: *httpz.Request, res: *httpz.Response) !void { + const ctx = ws.WsClient.Context{ .hub = handler.hub }; + if (try httpz.upgradeWebsocket(ws.WsClient, req, res, &ctx) == false) { + res.status = 400; + res.body = "invalid websocket handshake"; + } +} + +pub fn webhookCreate(handler: *Handler, req: *httpz.Request, res: *httpz.Response) !void { + const content_type = req.header("content-type") orelse ""; + + // Travis CI sends form-encoded payload + if (std.mem.startsWith(u8, content_type, "application/x-www-form-urlencoded")) { + const form_data = try req.formData(); + var it = form_data.iterator(); + while (it.next()) |kv| { + if (std.mem.eql(u8, kv.key, "payload")) { + parsers.parseTravis(handler.pool, handler.hub, kv.value, handler.host, handler.particle_token, handler.debug, handler.allocator) catch |err| { + std.log.err("ParseTravis error: {}", .{err}); + }; + res.status = 200; + return; + } + } + } + + // Read JSON body + const body = req.body() orelse { + res.status = 400; + return; + }; + + // Check for CircleCI (has Circleci-Event-Type header) + if (req.header("circleci-event-type") != null) { + parsers.parseCircle(handler.pool, handler.hub, body, handler.host, handler.particle_token, handler.debug, handler.allocator) catch |err| { + std.log.err("ParseCircle error: {}", .{err}); + }; + res.status = 200; + return; + } + + // GitHub Actions sends JSON with "repository" containing "owner/repo" + const parsed = std.json.parseFromSlice(std.json.Value, res.arena, body, .{}) catch { + res.status = 400; + return; + }; + defer parsed.deinit(); + + const is_github = switch (parsed.value) { + .object => |obj| if (obj.get("repository")) |repo| switch (repo) { + .string => |s| std.mem.indexOf(u8, s, "/") != null, + else => false, + } else false, + else => false, + }; + + if (!is_github) { + res.status = 400; + return; + } + + parsers.parseGithub(handler.pool, handler.hub, body, handler.host, handler.particle_token, handler.debug, handler.allocator) catch |err| { + std.log.err("ParseGithub error: {}", .{err}); + }; + res.status = 200; +} + +pub fn colorsIndex(handler: *Handler, req: *httpz.Request, res: *httpz.Response) !void { + try renderColors(handler, req, res, null); +} + +pub fn colorsShow(handler: *Handler, req: *httpz.Request, res: *httpz.Response) !void { + const raw_id = req.param("id") orelse { + res.status = 404; + return; + }; + + if (std.mem.endsWith(u8, raw_id, ".ryg")) { + try streamRyg(handler, res, raw_id[0 .. raw_id.len - 4]); + return; + } + + if (std.mem.endsWith(u8, raw_id, ".json")) { + try renderColorsJson(handler, res, raw_id[0 .. raw_id.len - 5]); + return; + } + + try renderColors(handler, req, res, raw_id); +} + +fn parseUsernames(allocator: std.mem.Allocator, id: []const u8) ![]const []const u8 { + var list: std.ArrayList([]const u8) = .empty; + var iter = std.mem.splitSequence(u8, id, ","); + while (iter.next()) |part| { + if (part.len > 0) { + try list.append(allocator, part); + } + } + return list.toOwnedSlice(allocator); +} + +/// Extract a string field from a JSON Value object, returning null if not found +/// or if the value is not a string. +fn jsonString(value: std.json.Value, key: []const u8) ?[]const u8 { + return switch (value) { + .object => |obj| if (obj.get(key)) |v| switch (v) { + .string => |s| s, + else => null, + } else null, + else => null, + }; +} + +/// Check if the client wants JSON (via Accept header or ?format=json). +fn wantsJson(req: *httpz.Request) !bool { + const accept = req.header("accept") orelse ""; + if (std.mem.indexOf(u8, accept, "application/json") != null) return true; + const query = try req.query(); + const format = query.get("format") orelse return false; + return std.mem.eql(u8, format, "json"); +} + +fn renderColors(handler: *Handler, req: *httpz.Request, res: *httpz.Response, id: ?[]const u8) !void { + if (try wantsJson(req)) { + try renderColorsJson(handler, res, id); + return; + } + + const usernames: ?[]const []const u8 = if (id) |i| try parseUsernames(res.arena, i) else null; + const colors = try models.getColors(handler.pool, usernames); + try renderColorsHtml(res, colors); +} + +fn renderColorsJson(handler: *Handler, res: *httpz.Response, id: ?[]const u8) !void { + const usernames: ?[]const []const u8 = if (id) |i| try parseUsernames(res.arena, i) else null; + const colors = try models.getColors(handler.pool, usernames); + res.content_type = .JSON; + try colors.writeJson(res.writer()); +} + +fn renderColorsHtml(res: *httpz.Response, colors: models.Colors) !void { + res.content_type = .HTML; + const html = try templates.renderLayout(res.arena, colors); + res.body = html; +} + +/// Stream RYG format. Uses res.chunk() for chunked transfer encoding. +/// Each chunk is 3 bytes (e.g., "rYG") followed by a 1-second sleep. +/// WARNING: This blocks a worker thread per connection. Keep the httpz +/// worker thread count high enough to handle a few concurrent .ryg streams. +fn streamRyg(handler: *Handler, res: *httpz.Response, id: []const u8) !void { + res.content_type = .TEXT; + res.header("cache-control", "no-cache"); + res.header("connection", "keep-alive"); + + const usernames = try parseUsernames(res.arena, id); + + // Stream in a loop (max 1 hour to prevent infinite blocking) + var i: usize = 0; + while (i < 3600) : (i += 1) { + const colors = models.getColors(handler.pool, usernames) catch break; + var buf: [3]u8 = undefined; + const ryg_str = colors.ryg(&buf); + res.chunk(ryg_str) catch break; + std.Thread.sleep(1_000_000_000); // 1 second + } +} + +pub fn deviceShow(handler: *Handler, req: *httpz.Request, res: *httpz.Response) !void { + const id = req.param("id") orelse { + res.status = 404; + return; + }; + + const device = try models.findDeviceBySlugOrId(handler.pool, id) orelse { + res.status = 404; + return; + }; + + const colors = try models.getDeviceColors(handler.pool, device); + + if (try wantsJson(req)) { + res.content_type = .JSON; + try colors.writeJson(res.writer()); + return; + } + + try renderColorsHtml(res, colors); +} + +pub fn apiDeviceShow(handler: *Handler, req: *httpz.Request, res: *httpz.Response) !void { + const id = req.param("id") orelse { + res.status = 404; + return; + }; + + const device = try models.findDeviceById(handler.pool, id) orelse { + res.status = 404; + return; + }; + + const colors = try models.getDeviceColors(handler.pool, device); + + res.content_type = .JSON; + const writer = res.writer(); + try writer.writeAll("{\"colors\":"); + try colors.writeJsonBooleans(writer); + try writer.writeAll(",\"ryg\":\""); + var buf: [3]u8 = undefined; + try writer.writeAll(colors.ryg(&buf)); + try writer.writeAll("\"}"); +} + +pub fn apiDeviceTrigger(handler: *Handler, req: *httpz.Request, res: *httpz.Response) !void { + var core_id: ?[]const u8 = null; + + // Try form data first (Particle sends form-encoded) + const form_data = try req.formData(); + var it = form_data.iterator(); + while (it.next()) |kv| { + if (std.mem.eql(u8, kv.key, "coreid")) { + core_id = kv.value; + break; + } + } + + // Fallback: try JSON body for "coreid" field + if (core_id == null) { + if (req.body()) |body| { + const parsed = std.json.parseFromSlice(std.json.Value, res.arena, body, .{}) catch null; + if (parsed) |p| { + defer p.deinit(); + core_id = jsonString(p.value, "coreid"); + } + } + } + + if (core_id) |cid| { + if (try models.findDeviceByIdentifier(handler.pool, cid)) |device| { + const triggers = @import("triggers.zig"); + const colors = try models.getDeviceColors(handler.pool, device); + if (device.webhook_url.len > 0) { + triggers.triggerWebhook(handler.allocator, device, colors, handler.host); + } + if (device.identifier.len > 0) { + triggers.triggerParticle(handler.allocator, colors.statusString(), handler.particle_token); + } + } + } + + res.status = 200; +} + +pub fn apiRedShow(handler: *Handler, req: *httpz.Request, res: *httpz.Response) !void { + const id = req.param("id") orelse { + res.status = 404; + return; + }; + + const device = try models.findDeviceByIdentifier(handler.pool, id) orelse { + res.status = 404; + return; + }; + + var red_statuses = try models.getRedStatuses(res.arena, handler.pool, device); + defer red_statuses.deinit(res.arena); + + if (try wantsJson(req)) { + res.content_type = .JSON; + const writer = res.writer(); + try writer.writeByte('['); + for (red_statuses.items, 0..) |s, i| { + if (i > 0) try writer.writeByte(','); + try writer.writeAll("{\"username\":\""); + try writer.writeAll(s.username); + try writer.writeAll("\",\"project_name\":\""); + try writer.writeAll(s.project_name); + try writer.writeAll("\"}"); + } + try writer.writeByte(']'); + return; + } + + // HTML response + res.content_type = .HTML; + res.body = try templates.renderRed(res.arena, red_statuses.items); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..adb16eb --- /dev/null +++ b/src/main.zig @@ -0,0 +1,134 @@ +const std = @import("std"); +const httpz = @import("httpz"); +const pg = @import("pg"); +const build_options = @import("build_options"); + +const db = @import("db.zig"); +const handlers = @import("handlers.zig"); +const websocket = @import("websocket.zig"); +const templates = @import("templates.zig"); + +pub const Handler = @import("handlers.zig").Handler; + +// Global server reference for signal handler +var server_instance: ?*httpz.Server(*Handler) = null; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + + // Parse config from env + const port_str = std.posix.getenv("PORT") orelse "8080"; + const port = std.fmt.parseInt(u16, port_str, 10) catch 8080; + const database_url = std.posix.getenv("DATABASE_URL") orelse + "postgres://localhost/buildlight_development?sslmode=disable"; + const host = std.posix.getenv("HOST") orelse "localhost"; + const particle_token = std.posix.getenv("PARTICLE_ACCESS_TOKEN"); + const debug = std.posix.getenv("DEBUG") != null; + + // Check for "migrate" subcommand + var args = std.process.args(); + _ = args.next(); // skip program name + if (args.next()) |arg| { + if (std.mem.eql(u8, arg, "migrate")) { + const pool = try db.initPool(allocator, database_url); + defer pool.deinit(); + try db.migrate(pool); + std.log.info("Migrations complete", .{}); + return; + } + } + + // Init database pool + const pool = try db.initPool(allocator, database_url); + defer pool.deinit(); + + // Run migrations on startup + try db.migrate(pool); + + // Create WebSocket hub + var hub = websocket.Hub.init(allocator); + defer hub.deinit(); + + // Create handler + var handler = Handler{ + .pool = pool, + .hub = &hub, + .allocator = allocator, + .host = host, + .particle_token = particle_token, + .debug = debug, + }; + + // Init server -- note: *Handler (pointer type) so handlers receive *Handler + var server = try httpz.Server(*Handler).init(allocator, .{ + .address = .all(port), + .request = .{ + // Required for Travis CI form-encoded payloads and Particle trigger + .max_form_count = 8, + }, + }, &handler); + defer server.deinit(); + + // Register signal handlers for graceful shutdown + std.posix.sigaction(std.posix.SIG.INT, &.{ + .handler = .{ .handler = shutdown }, + .mask = std.posix.sigemptyset(), + .flags = 0, + }, null); + std.posix.sigaction(std.posix.SIG.TERM, &.{ + .handler = .{ .handler = shutdown }, + .mask = std.posix.sigemptyset(), + .flags = 0, + }, null); + + // Register routes + var router = try server.router(.{}); + + // Health check + router.get("/up", handlers.healthCheck, .{}); + + // Static files + router.get("/public/*", handlers.serveStatic, .{}); + + // WebSocket + router.get("/ws", handlers.wsUpgrade, .{}); + + // API routes + router.get("/api/devices/:id", handlers.apiDeviceShow, .{}); + router.post("/api/device/trigger", handlers.apiDeviceTrigger, .{}); + router.get("/api/device/:id/red", handlers.apiRedShow, .{}); + + // Device routes + router.get("/devices/:id", handlers.deviceShow, .{}); + + // Webhook + router.post("/", handlers.webhookCreate, .{}); + + // Colors -- catch-all must be last + router.get("/:id", handlers.colorsShow, .{}); + router.get("/", handlers.colorsIndex, .{}); + + std.log.info("Listening on 0.0.0.0:{d}", .{port}); + + server_instance = &server; + try server.listen(); +} + +fn shutdown(_: c_int) callconv(.c) void { + if (server_instance) |server| { + server_instance = null; + server.stop(); + } +} + +// Re-export all test blocks so `zig build test` discovers them +comptime { + _ = @import("db.zig"); + _ = @import("models.zig"); + _ = @import("handlers.zig"); + _ = @import("parsers.zig"); + _ = @import("websocket.zig"); + _ = @import("triggers.zig"); + _ = @import("templates.zig"); +} diff --git a/src/migrations/001_initial_schema.sql b/src/migrations/001_initial_schema.sql new file mode 100644 index 0000000..ef6584d --- /dev/null +++ b/src/migrations/001_initial_schema.sql @@ -0,0 +1,43 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE statuses ( + id BIGSERIAL PRIMARY KEY, + project_id VARCHAR, + project_name VARCHAR, + created_at TIMESTAMP, + updated_at TIMESTAMP, + payload TEXT, + red BOOLEAN, + yellow BOOLEAN, + username VARCHAR, + service VARCHAR NOT NULL, + workflow VARCHAR +); + +CREATE INDEX index_statuses_on_project_id ON statuses (project_id); +CREATE INDEX index_statuses_on_project_name ON statuses (project_name); +CREATE INDEX index_statuses_on_red ON statuses (red); +CREATE INDEX index_statuses_on_yellow ON statuses (yellow); +CREATE INDEX index_statuses_on_username ON statuses (username); +CREATE INDEX index_statuses_on_username_and_project_name ON statuses (username, project_name); +CREATE INDEX index_statuses_on_username_and_red ON statuses (username, red); +CREATE INDEX index_statuses_on_username_and_yellow ON statuses (username, yellow); + +CREATE TABLE devices ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + usernames VARCHAR[] NOT NULL DEFAULT '{}', + projects VARCHAR[] NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + identifier VARCHAR, + name VARCHAR NOT NULL, + webhook_url VARCHAR, + slug CITEXT, + status VARCHAR, + status_changed_at TIMESTAMP +); + +CREATE UNIQUE INDEX index_devices_on_identifier ON devices (identifier); +CREATE INDEX index_devices_on_name ON devices (name); +CREATE UNIQUE INDEX index_devices_on_slug ON devices (slug); diff --git a/src/models.zig b/src/models.zig new file mode 100644 index 0000000..cef8c3a --- /dev/null +++ b/src/models.zig @@ -0,0 +1,385 @@ +const std = @import("std"); +const pg = @import("pg"); + +pub const Colors = struct { + red: i32, // count of red statuses (0 means not red) + yellow: bool, + green: bool, + + /// Write JSON with the Rails convention: red is false when 0, integer when > 0. + /// Example: {"red": false, "yellow": true, "green": false} + /// Example: {"red": 3, "yellow": false, "green": false} + pub fn writeJson(self: Colors, writer: anytype) !void { + try writer.writeAll("{\"red\":"); + if (self.red > 0) { + try writer.print("{d}", .{self.red}); + } else { + try writer.writeAll("false"); + } + try writer.writeAll(",\"yellow\":"); + try writer.writeAll(if (self.yellow) "true" else "false"); + try writer.writeAll(",\"green\":"); + try writer.writeAll(if (self.green) "true" else "false"); + try writer.writeByte('}'); + } + + /// Write booleans-only JSON: {"red": true, "yellow": false, "green": true} + pub fn writeJsonBooleans(self: Colors, writer: anytype) !void { + try writer.writeAll("{\"red\":"); + try writer.writeAll(if (self.red > 0) "true" else "false"); + try writer.writeAll(",\"yellow\":"); + try writer.writeAll(if (self.yellow) "true" else "false"); + try writer.writeAll(",\"green\":"); + try writer.writeAll(if (self.green) "true" else "false"); + try writer.writeByte('}'); + } + + /// Returns "RYG", "ryG", "Ryg", etc. Uppercase = active. + pub fn ryg(self: Colors, buf: *[3]u8) []const u8 { + buf[0] = if (self.red > 0) 'R' else 'r'; + buf[1] = if (self.yellow) 'Y' else 'y'; + buf[2] = if (self.green) 'G' else 'g'; + return buf[0..3]; + } + + /// Status string: "passing", "failing", "passing-building", "failing-building". + pub fn statusString(self: Colors) []const u8 { + if (self.red > 0) { + return if (self.yellow) "failing-building" else "failing"; + } + return if (self.yellow) "passing-building" else "passing"; + } +}; + +pub const Status = struct { + id: i64, + service: []const u8, + project_id: []const u8, + project_name: []const u8, + username: []const u8, + workflow: []const u8, +}; + +pub const Device = struct { + id: []const u8, + name: []const u8, + identifier: []const u8, + webhook_url: []const u8, + slug: []const u8, + status: []const u8, +}; + +/// Get aggregated colors for given usernames (null or empty = all statuses). +pub fn getColors(pool: *pg.Pool, usernames: ?[]const []const u8) !Colors { + if (usernames) |names| { + if (names.len > 0) return getColorsFiltered(pool, names); + } + return getColorsAll(pool); +} + +fn getColorsAll(pool: *pg.Pool) !Colors { + const red_result = try pool.query("SELECT COUNT(*)::int FROM statuses WHERE red = true", .{}); + defer red_result.deinit(); + const red_count: i32 = if (try red_result.next()) |row| try row.get(i32, 0) else 0; + + const yellow_result = try pool.query("SELECT EXISTS(SELECT 1 FROM statuses WHERE yellow = true)", .{}); + defer yellow_result.deinit(); + const yellow: bool = if (try yellow_result.next()) |row| try row.get(bool, 0) else false; + + return Colors{ .red = red_count, .yellow = yellow, .green = red_count == 0 }; +} + +fn getColorsFiltered(pool: *pg.Pool, usernames: []const []const u8) !Colors { + const red_result = try pool.query( + "SELECT COUNT(*)::int FROM statuses WHERE red = true AND username = ANY($1)", + .{usernames}, + ); + defer red_result.deinit(); + const red_count: i32 = if (try red_result.next()) |row| try row.get(i32, 0) else 0; + + const yellow_result = try pool.query( + "SELECT EXISTS(SELECT 1 FROM statuses WHERE yellow = true AND username = ANY($1))", + .{usernames}, + ); + defer yellow_result.deinit(); + const yellow: bool = if (try yellow_result.next()) |row| try row.get(bool, 0) else false; + + return Colors{ .red = red_count, .yellow = yellow, .green = red_count == 0 }; +} + +/// Get colors for a specific device by querying its watched statuses. +/// Uses a subquery against the devices table so PostgreSQL handles array comparison. +pub fn getDeviceColors(pool: *pg.Pool, device: Device) !Colors { + const result = try pool.query( + \\SELECT s.red, s.yellow FROM statuses s + \\JOIN devices d ON d.id::text = $1 + \\WHERE s.username = ANY(d.usernames) + \\ OR (s.username || '/' || s.project_name) = ANY(d.projects) + , .{device.id}); + defer result.deinit(); + + var red_count: i32 = 0; + var yellow_exists = false; + while (try result.next()) |row| { + const red: ?bool = try row.get(?bool, 0); + const yellow: ?bool = try row.get(?bool, 1); + if (red != null and red.?) red_count += 1; + if (yellow != null and yellow.?) yellow_exists = true; + } + + return Colors{ + .red = red_count, + .yellow = yellow_exists, + .green = red_count == 0, + }; +} + +const device_columns = + \\SELECT id::text, name, COALESCE(identifier,''), + \\ COALESCE(webhook_url,''), COALESCE(slug,''), COALESCE(status,'') + \\FROM devices +; + +fn deviceFromRow(row: anytype) !Device { + return Device{ + .id = try row.get([]const u8, 0), + .name = try row.get([]const u8, 1), + .identifier = try row.get([]const u8, 2), + .webhook_url = try row.get([]const u8, 3), + .slug = try row.get([]const u8, 4), + .status = try row.get([]const u8, 5), + }; +} + +/// Find device by slug (case-insensitive) or UUID. +pub fn findDeviceBySlugOrId(pool: *pg.Pool, id: []const u8) !?Device { + const result = try pool.query( + device_columns ++ " WHERE slug = $1 OR id::text = $1 LIMIT 1", + .{id}, + ); + defer result.deinit(); + return if (try result.next()) |row| try deviceFromRow(row) else null; +} + +/// Find device by UUID only. +pub fn findDeviceById(pool: *pg.Pool, id: []const u8) !?Device { + const result = try pool.query( + device_columns ++ " WHERE id::text = $1", + .{id}, + ); + defer result.deinit(); + return if (try result.next()) |row| try deviceFromRow(row) else null; +} + +/// Find device by Particle identifier. +pub fn findDeviceByIdentifier(pool: *pg.Pool, identifier: []const u8) !?Device { + const result = try pool.query( + device_columns ++ " WHERE identifier = $1", + .{identifier}, + ); + defer result.deinit(); + return if (try result.next()) |row| try deviceFromRow(row) else null; +} + +/// Upsert a status (find existing by service+username+project_name+project_id+workflow, +/// then insert or update). +pub fn upsertStatus( + pool: *pg.Pool, + service: []const u8, + username: []const u8, + project_name: []const u8, + project_id: []const u8, + workflow: []const u8, + red: ?bool, + yellow: ?bool, + payload: ?[]const u8, +) !void { + // Try to find existing + const find_result = try pool.query( + \\SELECT id FROM statuses + \\WHERE service = $1 + \\ AND COALESCE(username,'') = COALESCE($2,'') + \\ AND COALESCE(project_name,'') = COALESCE($3,'') + \\ AND COALESCE(project_id,'') = COALESCE($4,'') + \\ AND COALESCE(workflow,'') = COALESCE($5,'') + , .{ service, username, project_name, project_id, workflow }); + defer find_result.deinit(); + + if (try find_result.next()) |row| { + const id = try row.get(i64, 0); + _ = try pool.exec( + \\UPDATE statuses SET red = COALESCE($1, red), yellow = COALESCE($2, yellow), + \\ payload = $3, username = $4, project_name = $5, updated_at = NOW() + \\WHERE id = $6 + , .{ red, yellow, payload, username, project_name, id }); + } else { + _ = try pool.exec( + \\INSERT INTO statuses (service, username, project_name, project_id, + \\ workflow, red, yellow, payload, created_at, updated_at) + \\VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) + , .{ service, username, project_name, project_id, workflow, red, yellow, payload }); + } +} + +/// Upsert a Travis status, keyed by service + project_id only (matching Rails +/// find_or_initialize_by behavior). This ensures repo renames update in-place. +pub fn upsertTravisStatus( + pool: *pg.Pool, + username: []const u8, + project_name: []const u8, + project_id: []const u8, + red: ?bool, + yellow: ?bool, + payload: ?[]const u8, +) !void { + const find_result = try pool.query( + "SELECT id FROM statuses WHERE service = 'travis' AND project_id = $1", + .{project_id}, + ); + defer find_result.deinit(); + + if (try find_result.next()) |row| { + const id = try row.get(i64, 0); + _ = try pool.exec( + \\UPDATE statuses SET red = COALESCE($1, red), yellow = COALESCE($2, yellow), + \\ payload = $3, username = $4, project_name = $5, updated_at = NOW() + \\WHERE id = $6 + , .{ red, yellow, payload, username, project_name, id }); + } else { + _ = try pool.exec( + \\INSERT INTO statuses (service, username, project_name, project_id, + \\ workflow, red, yellow, payload, created_at, updated_at) + \\VALUES ('travis', $1, $2, $3, '', $4, $5, $6, NOW(), NOW()) + , .{ username, project_name, project_id, red, yellow, payload }); + } +} + +/// Find devices watching a given username/project. +pub fn findDevicesForStatus(allocator: std.mem.Allocator, pool: *pg.Pool, username: []const u8, project_name: []const u8) !std.ArrayList(Device) { + var devices: std.ArrayList(Device) = .empty; + const name = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ username, project_name }); + defer allocator.free(name); + + const result = try pool.query( + device_columns ++ + \\ WHERE usernames @> ARRAY[$1]::varchar[] + \\ OR projects @> ARRAY[$2]::varchar[] + , .{ username, name }); + defer result.deinit(); + + while (try result.next()) |row| { + try devices.append(allocator, try deviceFromRow(row)); + } + return devices; +} + +/// Get red (failing) statuses for a device. +pub fn getRedStatuses(allocator: std.mem.Allocator, pool: *pg.Pool, device: Device) !std.ArrayList(Status) { + var statuses: std.ArrayList(Status) = .empty; + + const result = try pool.query( + \\SELECT s.id, s.service, COALESCE(s.project_id,''), COALESCE(s.project_name,''), + \\ COALESCE(s.username,''), COALESCE(s.workflow,'') + \\FROM statuses s + \\JOIN devices d ON d.id::text = $1 + \\WHERE s.red = true + \\ AND (s.username = ANY(d.usernames) OR (s.username || '/' || s.project_name) = ANY(d.projects)) + , .{device.id}); + defer result.deinit(); + + while (try result.next()) |row| { + try statuses.append(allocator, Status{ + .id = try row.get(i64, 0), + .service = try row.get([]const u8, 1), + .project_id = try row.get([]const u8, 2), + .project_name = try row.get([]const u8, 3), + .username = try row.get([]const u8, 4), + .workflow = try row.get([]const u8, 5), + }); + } + return statuses; +} + +/// Update a device's status, broadcast via WebSocket, and trigger external webhooks. +pub fn updateDeviceStatus( + allocator: std.mem.Allocator, + pool: *pg.Pool, + hub: anytype, + device: Device, + host: []const u8, + particle_token: ?[]const u8, +) !void { + const colors = try getDeviceColors(pool, device); + const new_status = colors.statusString(); + + // Broadcast device colors to WebSocket clients + if (device.slug.len > 0) { + var channel_buf: [256]u8 = undefined; + const channel = std.fmt.bufPrint(&channel_buf, "device:{s}", .{device.slug}) catch return; + hub.broadcastColors(channel, colors); + } + + if (!std.mem.eql(u8, device.status, new_status)) { + _ = try pool.exec( + "UPDATE devices SET status = $1, status_changed_at = NOW(), updated_at = NOW() WHERE id::text = $2", + .{ new_status, device.id }, + ); + + // Trigger external notifications + const triggers = @import("triggers.zig"); + if (device.webhook_url.len > 0) { + triggers.triggerWebhook(allocator, device, colors, host); + } + if (device.identifier.len > 0) { + triggers.triggerParticle(allocator, new_status, particle_token); + } + } +} + +test "Colors.ryg returns correct string" { + var buf: [3]u8 = undefined; + + const passing = Colors{ .red = 0, .yellow = false, .green = true }; + try std.testing.expectEqualStrings("ryG", passing.ryg(&buf)); + + const failing = Colors{ .red = 2, .yellow = false, .green = false }; + try std.testing.expectEqualStrings("Ryg", failing.ryg(&buf)); + + const building = Colors{ .red = 0, .yellow = true, .green = true }; + try std.testing.expectEqualStrings("rYG", building.ryg(&buf)); + + const all = Colors{ .red = 1, .yellow = true, .green = false }; + try std.testing.expectEqualStrings("RYg", all.ryg(&buf)); +} + +test "Colors.writeJson produces Rails-compatible output" { + var buf: [128]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + + const passing = Colors{ .red = 0, .yellow = false, .green = true }; + try passing.writeJson(fbs.writer()); + try std.testing.expectEqualStrings( + "{\"red\":false,\"yellow\":false,\"green\":true}", + fbs.getWritten(), + ); + + fbs.reset(); + const failing = Colors{ .red = 3, .yellow = true, .green = false }; + try failing.writeJson(fbs.writer()); + try std.testing.expectEqualStrings( + "{\"red\":3,\"yellow\":true,\"green\":false}", + fbs.getWritten(), + ); +} + +test "Colors.writeJsonBooleans always uses booleans" { + var buf: [128]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + + const failing = Colors{ .red = 3, .yellow = true, .green = false }; + try failing.writeJsonBooleans(fbs.writer()); + try std.testing.expectEqualStrings( + "{\"red\":true,\"yellow\":true,\"green\":false}", + fbs.getWritten(), + ); +} diff --git a/src/parsers.zig b/src/parsers.zig new file mode 100644 index 0000000..6304aea --- /dev/null +++ b/src/parsers.zig @@ -0,0 +1,331 @@ +const std = @import("std"); +const pg = @import("pg"); +const models = @import("models.zig"); +const ws = @import("websocket.zig"); + +const JsonObject = std.json.ObjectMap; + +/// Extract a required string field from a JSON object map. +fn getString(obj: JsonObject, key: []const u8) ![]const u8 { + const val = obj.get(key) orelse return error.MissingField; + return switch (val) { + .string => |s| s, + else => error.InvalidField, + }; +} + +/// Extract an optional string field from a JSON object map, returning a default. +fn getStringOr(obj: JsonObject, key: []const u8, default: []const u8) []const u8 { + const val = obj.get(key) orelse return default; + return switch (val) { + .string => |s| s, + else => default, + }; +} + +/// Extract a required nested object from a JSON object map. +fn getObject(obj: JsonObject, key: []const u8) !JsonObject { + const val = obj.get(key) orelse return error.MissingField; + return switch (val) { + .object => |o| o, + else => error.InvalidField, + }; +} + +/// Parse a GitHub Actions webhook payload. +/// Expected JSON: {"repository": "owner/repo", "workflow": "...", "status": "success|failure|""} +pub fn parseGithub( + pool: *pg.Pool, + hub: *ws.Hub, + body: []const u8, + host: []const u8, + particle_token: ?[]const u8, + debug: bool, + allocator: std.mem.Allocator, +) !void { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return error.InvalidJson; + defer parsed.deinit(); + const root = parsed.value.object; + + const repo_str = try getString(root, "repository"); + + // Must contain "/" to be a GitHub payload + const slash_idx = std.mem.indexOf(u8, repo_str, "/") orelse return error.InvalidField; + const username = repo_str[0..slash_idx]; + const project_name = repo_str[slash_idx + 1 ..]; + + const workflow = getStringOr(root, "workflow", ""); + const status_str = getStringOr(root, "status", ""); + + var red: ?bool = null; + var yellow: ?bool = false; + + if (status_str.len == 0) { + // No status = building: set yellow but do NOT change red + yellow = true; + } else if (std.mem.eql(u8, status_str, "success")) { + red = false; + } else if (std.mem.eql(u8, status_str, "failure")) { + red = true; + } else { + // Ignore unknown statuses (cancelled, skipped, etc.) + return; + } + + const payload: ?[]const u8 = if (debug) body else null; + + try models.upsertStatus(pool, "github", username, project_name, "", workflow, red, yellow, payload); + + broadcastStatusUpdate(allocator, pool, hub, username, project_name, host, particle_token); +} + +/// Parse a Travis CI webhook payload (already extracted from form data "payload" field). +/// The payload is a JSON string. +pub fn parseTravis( + pool: *pg.Pool, + hub: *ws.Hub, + payload_str: []const u8, + host: []const u8, + particle_token: ?[]const u8, + debug: bool, + allocator: std.mem.Allocator, +) !void { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, payload_str, .{}) catch + return error.InvalidJson; + defer parsed.deinit(); + const root = parsed.value.object; + + // Ignore pull requests + if (std.mem.eql(u8, getStringOr(root, "type", ""), "pull_request")) return; + + const repo = try getObject(root, "repository"); + const owner_name = try getString(repo, "owner_name"); + const repo_name = try getString(repo, "name"); + + // Get repository ID as string (can be integer or string in JSON) + var repo_id_buf: [32]u8 = undefined; + const repo_id: []const u8 = if (repo.get("id")) |v| switch (v) { + .integer => |i| std.fmt.bufPrint(&repo_id_buf, "{d}", .{i}) catch "", + .string => |s| s, + else => "", + } else ""; + + const status_message = getStringOr(root, "status_message", ""); + + var red: ?bool = null; + var yellow: ?bool = false; + + if (std.mem.eql(u8, status_message, "Pending")) { + // Pending sets yellow but does NOT change red + yellow = true; + } else if (std.mem.eql(u8, status_message, "Passed") or + std.mem.eql(u8, status_message, "Fixed")) + { + red = false; + } else { + red = true; + } + + const payload: ?[]const u8 = if (debug) payload_str else null; + + try models.upsertTravisStatus(pool, owner_name, repo_name, repo_id, red, yellow, payload); + broadcastStatusUpdate(allocator, pool, hub, owner_name, repo_name, host, particle_token); +} + +/// Parse a CircleCI webhook payload. +/// Only handles "workflow-completed" events on main/master branches. +pub fn parseCircle( + pool: *pg.Pool, + hub: *ws.Hub, + body: []const u8, + host: []const u8, + particle_token: ?[]const u8, + debug: bool, + allocator: std.mem.Allocator, +) !void { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return error.InvalidJson; + defer parsed.deinit(); + const root = parsed.value.object; + + // Only handle workflow-completed + if (!std.mem.eql(u8, getStringOr(root, "type", ""), "workflow-completed")) return; + + // Only process main/master branches + const pipeline = getObject(root, "pipeline") catch return; + const vcs = getObject(pipeline, "vcs") catch return; + const branch = getString(vcs, "branch") catch return; + if (!std.mem.eql(u8, branch, "main") and !std.mem.eql(u8, branch, "master")) return; + + const org_name = try getString(try getObject(root, "organization"), "name"); + const project_name = try getString(try getObject(root, "project"), "name"); + const workflow_status = try getString(try getObject(root, "workflow"), "status"); + + // CircleCI always sets yellow=false + const red: bool = !std.mem.eql(u8, workflow_status, "success"); + const payload: ?[]const u8 = if (debug) body else null; + + try models.upsertStatus(pool, "circle", org_name, project_name, "", "", red, false, payload); + broadcastStatusUpdate(allocator, pool, hub, org_name, project_name, host, particle_token); +} + +/// After a status upsert, broadcast colors to WebSocket clients and update devices. +fn broadcastStatusUpdate( + allocator: std.mem.Allocator, + pool: *pg.Pool, + hub: *ws.Hub, + username: []const u8, + project_name: []const u8, + host: []const u8, + particle_token: ?[]const u8, +) void { + // Broadcast to global colors channel + const all_colors = models.getColors(pool, null) catch return; + hub.broadcastColors("colors:*", all_colors); + + // Broadcast to per-username channel + const user_colors = models.getColors(pool, &.{username}) catch return; + var channel_buf: [256]u8 = undefined; + const channel = std.fmt.bufPrint(&channel_buf, "colors:{s}", .{username}) catch return; + hub.broadcastColors(channel, user_colors); + + // Update watching devices + var devices = models.findDevicesForStatus(allocator, pool, username, project_name) catch return; + defer devices.deinit(allocator); + for (devices.items) |device| { + models.updateDeviceStatus(allocator, pool, hub, device, host, particle_token) catch {}; + } +} + +const db = @import("db.zig"); + +var test_pool: ?*pg.Pool = null; + +fn getTestPool() !*pg.Pool { + if (test_pool) |p| return p; + const url = std.posix.getenv("TEST_DATABASE_URL") orelse + "postgresql://gaffneyc:buildlight_test@127.0.0.1/buildlight_test"; + // Use page_allocator for the pool since it's a process-lifetime singleton. + // Using testing.allocator would report false "leaks" for pool internals. + test_pool = try db.initPool(std.heap.page_allocator, url); + // Run migrations once + try db.migrate(test_pool.?); + return test_pool.?; +} + +test "parseGithub creates a status for success" { + const pool = try getTestPool(); + var hub = ws.Hub.init(std.testing.allocator); + defer hub.deinit(); + + // Clean up from previous runs + _ = try pool.exec("DELETE FROM statuses WHERE service = 'github'", .{}); + + const payload = + \\{"repository":"collectiveidea/buildlight","workflow":"CI","status":"success"} + ; + + try parseGithub(pool, &hub, payload, "localhost", null, false, std.testing.allocator); + + const result = try pool.query( + "SELECT username, project_name, red, yellow FROM statuses WHERE service = 'github'", + .{}, + ); + defer result.deinit(); + const row = (try result.next()) orelse return error.TestFailure; + try std.testing.expectEqualStrings("collectiveidea", try row.get([]const u8, 0)); + try std.testing.expectEqualStrings("buildlight", try row.get([]const u8, 1)); + try std.testing.expect((try row.get(?bool, 2)) == false); // red = false + try std.testing.expect((try row.get(?bool, 3)) == false); // yellow = false +} + +test "parseGithub sets yellow when status is empty" { + const pool = try getTestPool(); + var hub = ws.Hub.init(std.testing.allocator); + defer hub.deinit(); + + _ = try pool.exec("DELETE FROM statuses WHERE service = 'github'", .{}); + + const payload = + \\{"repository":"org/repo","workflow":"build","status":""} + ; + + try parseGithub(pool, &hub, payload, "localhost", null, false, std.testing.allocator); + + const result = try pool.query( + "SELECT yellow FROM statuses WHERE service = 'github' AND username = 'org'", + .{}, + ); + defer result.deinit(); + const row = (try result.next()) orelse return error.TestFailure; + try std.testing.expect((try row.get(?bool, 0)) == true); +} + +test "parseCircle ignores non-main branches" { + const pool = try getTestPool(); + var hub = ws.Hub.init(std.testing.allocator); + defer hub.deinit(); + + _ = try pool.exec("DELETE FROM statuses WHERE service = 'circle'", .{}); + + const payload = + \\{"type":"workflow-completed","pipeline":{"vcs":{"branch":"feature"}},"organization":{"name":"ci"},"project":{"name":"app"},"workflow":{"status":"success"}} + ; + + try parseCircle(pool, &hub, payload, "localhost", null, false, std.testing.allocator); + + const result2 = try pool.query( + "SELECT COUNT(*)::int FROM statuses WHERE service = 'circle'", + .{}, + ); + defer result2.deinit(); + const row2 = (try result2.next()) orelse return error.TestFailure; + try std.testing.expectEqual(@as(i32, 0), try row2.get(i32, 0)); +} + +test "parseCircle creates status for main branch success" { + const pool = try getTestPool(); + var hub = ws.Hub.init(std.testing.allocator); + defer hub.deinit(); + + _ = try pool.exec("DELETE FROM statuses WHERE service = 'circle'", .{}); + + const payload2 = + \\{"type":"workflow-completed","pipeline":{"vcs":{"branch":"main"}},"organization":{"name":"myorg"},"project":{"name":"myapp"},"workflow":{"status":"success"}} + ; + + try parseCircle(pool, &hub, payload2, "localhost", null, false, std.testing.allocator); + + const result3 = try pool.query( + "SELECT username, project_name, red FROM statuses WHERE service = 'circle'", + .{}, + ); + defer result3.deinit(); + const row3 = (try result3.next()) orelse return error.TestFailure; + try std.testing.expectEqualStrings("myorg", try row3.get([]const u8, 0)); + try std.testing.expectEqualStrings("myapp", try row3.get([]const u8, 1)); + try std.testing.expect((try row3.get(?bool, 2)) == false); // success = not red +} + +test "parseTravis ignores pull requests" { + const pool = try getTestPool(); + var hub = ws.Hub.init(std.testing.allocator); + defer hub.deinit(); + + _ = try pool.exec("DELETE FROM statuses WHERE service = 'travis'", .{}); + + const payload = + \\{"type":"pull_request","repository":{"id":123,"owner_name":"org","name":"repo"},"status_message":"Passed"} + ; + + try parseTravis(pool, &hub, payload, "localhost", null, false, std.testing.allocator); + + const result4 = try pool.query( + "SELECT COUNT(*)::int FROM statuses WHERE service = 'travis'", + .{}, + ); + defer result4.deinit(); + const row4 = (try result4.next()) orelse return error.TestFailure; + try std.testing.expectEqual(@as(i32, 0), try row4.get(i32, 0)); +} diff --git a/src/templates.zig b/src/templates.zig new file mode 100644 index 0000000..bef5852 --- /dev/null +++ b/src/templates.zig @@ -0,0 +1,105 @@ +const std = @import("std"); +const build_options = @import("build_options"); +const models = @import("models.zig"); + +/// Load a template by name. In release mode, returns embedded content. +/// In debug mode, reads from the filesystem for hot-reload. +fn loadTemplate(allocator: std.mem.Allocator, name: []const u8) ![]const u8 { + if (build_options.embed_assets) { + return getEmbedded(name) orelse error.TemplateNotFound; + } + // Dev mode: read from disk + const path = try std.fmt.allocPrint(allocator, "templates/{s}", .{name}); + defer allocator.free(path); + const file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + return file.readToEndAlloc(allocator, 1024 * 1024); +} + +fn getEmbedded(name: []const u8) ?[]const u8 { + if (std.mem.eql(u8, name, "layout.html")) return @embedFile("../templates/layout.html"); + if (std.mem.eql(u8, name, "red.html")) return @embedFile("../templates/red.html"); + return null; +} + +/// Render the main layout with traffic light UI. +pub fn renderLayout(allocator: std.mem.Allocator, colors: models.Colors) ![]const u8 { + const template = try loadTemplate(allocator, "layout.html"); + + const status = colors.statusString(); + + // Build body attributes + var attrs_buf: [64]u8 = undefined; + var attrs_fbs = std.io.fixedBufferStream(&attrs_buf); + const attrs_writer = attrs_fbs.writer(); + try attrs_writer.writeAll(if (colors.red > 0) " data-failing" else " data-passing"); + if (colors.yellow) try attrs_writer.writeAll(" data-building"); + const body_attrs = attrs_fbs.getWritten(); + + // Build favicon path using statusString (passing/failing/passing-building/failing-building) + var fav_buf: [64]u8 = undefined; + const favicon = std.fmt.bufPrint(&fav_buf, "/public/favicon-{s}.ico", .{status}) catch ""; + + // Build failing count message + var count_buf: [64]u8 = undefined; + const count_msg: []const u8 = if (colors.red == 0) + "" + else if (colors.red == 1) + "1 project is" + else + std.fmt.bufPrint(&count_buf, "{d} projects are", .{colors.red}) catch ""; + + // Simple template replacement + var result = try allocator.dupe(u8, template); + result = try replaceAll(allocator, result, "{{.BodyAttrs}}", body_attrs); + result = try replaceAll(allocator, result, "{{.Favicon}}", favicon); + result = try replaceAll(allocator, result, "{{.FailingCount}}", count_msg); + + return result; +} + +/// Render the red/failing projects page. +pub fn renderRed(allocator: std.mem.Allocator, statuses: []const models.Status) ![]const u8 { + const template = try loadTemplate(allocator, "red.html"); + + if (statuses.len == 0) { + const no_failures = + \\
🎉🎉🎉🎉🎉
+ \\

You have no failing projects.

+ \\
🎉🎉🎉🎉🎉
+ ; + return try replaceAll(allocator, try allocator.dupe(u8, template), "{{.RedProjects}}", no_failures); + } + + // Build the project list HTML + var list: std.ArrayList(u8) = .empty; + defer list.deinit(allocator); + try list.appendSlice(allocator, "

The following projects are failing

\n"); + + return try replaceAll(allocator, try allocator.dupe(u8, template), "{{.RedProjects}}", list.items); +} + +/// Simple string replacement (all occurrences). Frees the input haystack and +/// returns a new allocation. +fn replaceAll(allocator: std.mem.Allocator, haystack: []u8, needle: []const u8, replacement: []const u8) ![]u8 { + var result: std.ArrayList(u8) = .empty; + var i: usize = 0; + while (i < haystack.len) { + if (std.mem.indexOfPos(u8, haystack, i, needle)) |pos| { + try result.appendSlice(allocator, haystack[i..pos]); + try result.appendSlice(allocator, replacement); + i = pos + needle.len; + } else { + try result.appendSlice(allocator, haystack[i..]); + break; + } + } + allocator.free(haystack); + return result.toOwnedSlice(allocator); +} diff --git a/src/triggers.zig b/src/triggers.zig new file mode 100644 index 0000000..3e16dc8 --- /dev/null +++ b/src/triggers.zig @@ -0,0 +1,88 @@ +const std = @import("std"); +const models = @import("models.zig"); + +/// Send a POST to the device's webhook URL with colors as JSON body. +/// Fire-and-forget: errors are logged but do not propagate. +pub fn triggerWebhook( + allocator: std.mem.Allocator, + device: models.Device, + colors: models.Colors, + host: []const u8, +) void { + // Build JSON body + var body_buf: [256]u8 = undefined; + var fbs = std.io.fixedBufferStream(&body_buf); + const writer = fbs.writer(); + writer.writeAll("{\"colors\":") catch return; + colors.writeJsonBooleans(writer) catch return; + writer.writeByte('}') catch return; + const body = fbs.getWritten(); + + // Build RYG header value + var ryg_buf: [3]u8 = undefined; + const ryg = colors.ryg(&ryg_buf); + + // Build device URL header value + var url_buf: [256]u8 = undefined; + const device_url = std.fmt.bufPrint(&url_buf, "https://{s}/api/devices/{s}", .{ host, device.id }) catch return; + + // Make HTTP request using fetch + var client: std.http.Client = .{ .allocator = allocator }; + defer client.deinit(); + + const uri = std.Uri.parse(device.webhook_url) catch { + std.log.err("TriggerWebhook: invalid URL {s}", .{device.webhook_url}); + return; + }; + + _ = client.fetch(.{ + .location = .{ .uri = uri }, + .method = .POST, + .payload = body, + .extra_headers = &.{ + .{ .name = "content-type", .value = "application/json" }, + .{ .name = "x-ryg", .value = ryg }, + .{ .name = "x-device-url", .value = device_url }, + }, + }) catch |err| { + std.log.err("TriggerWebhook: fetch error {}", .{err}); + return; + }; +} + +/// Publish a build_state event to Particle.io. +/// Fire-and-forget: errors are logged but do not propagate. +pub fn triggerParticle( + allocator: std.mem.Allocator, + status: []const u8, + token: ?[]const u8, +) void { + const access_token = token orelse return; + if (access_token.len == 0) return; + + // Build JSON body + var body_buf: [256]u8 = undefined; + const body = std.fmt.bufPrint(&body_buf, + \\{{"name":"build_state","data":"{s}","ttl":3600,"private":false}} + , .{status}) catch return; + + // Build auth header value + var auth_buf: [256]u8 = undefined; + const auth = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{access_token}) catch return; + + var client: std.http.Client = .{ .allocator = allocator }; + defer client.deinit(); + + _ = client.fetch(.{ + .location = .{ .url = "https://api.particle.io/v1/devices/events" }, + .method = .POST, + .payload = body, + .extra_headers = &.{ + .{ .name = "content-type", .value = "application/json" }, + .{ .name = "authorization", .value = auth }, + }, + }) catch |err| { + std.log.err("TriggerParticle: fetch error {}", .{err}); + return; + }; +} diff --git a/src/websocket.zig b/src/websocket.zig new file mode 100644 index 0000000..b215d64 --- /dev/null +++ b/src/websocket.zig @@ -0,0 +1,140 @@ +const std = @import("std"); +const httpz = @import("httpz"); +const models = @import("models.zig"); + +const websocket = httpz.websocket; + +pub const Hub = struct { + allocator: std.mem.Allocator, + mu: std.Thread.Mutex = .{}, + clients: std.AutoHashMap(*WsClient, void), + + pub fn init(allocator: std.mem.Allocator) Hub { + return .{ + .allocator = allocator, + .clients = std.AutoHashMap(*WsClient, void).init(allocator), + }; + } + + pub fn deinit(self: *Hub) void { + self.clients.deinit(); + } + + pub fn register(self: *Hub, client: *WsClient) void { + self.mu.lock(); + defer self.mu.unlock(); + self.clients.put(client, {}) catch {}; + } + + pub fn unregister(self: *Hub, client: *WsClient) void { + self.mu.lock(); + defer self.mu.unlock(); + _ = self.clients.remove(client); + } + + /// Broadcast a pre-formatted message to all clients subscribed to the channel. + pub fn broadcast(self: *Hub, channel: []const u8, message: []const u8) void { + self.mu.lock(); + defer self.mu.unlock(); + var it = self.clients.keyIterator(); + while (it.next()) |client_ptr| { + const client = client_ptr.*; + if (client.isSubscribed(channel)) { + client.conn.write(message) catch {}; + } + } + } + + /// Convenience: broadcast colors JSON to a channel. + pub fn broadcastColors(self: *Hub, channel: []const u8, colors: models.Colors) void { + var buf: [512]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const writer = fbs.writer(); + writer.writeAll("{\"channel\":\"") catch return; + writer.writeAll(channel) catch return; + writer.writeAll("\",\"data\":{\"colors\":") catch return; + colors.writeJson(writer) catch return; + writer.writeAll("}}") catch return; + self.broadcast(channel, fbs.getWritten()); + } +}; + +pub const WsClient = struct { + conn: *websocket.Conn, + hub: *Hub, + subscriptions: std.StringHashMap(void), + + pub const Context = struct { + hub: *Hub, + }; + + /// Called by httpz after the WebSocket handshake completes. + /// Signature must be exactly: init(*websocket.Conn, *const Context) !WsClient + pub fn init(conn: *websocket.Conn, ctx: *const Context) !WsClient { + return WsClient{ + .conn = conn, + .hub = ctx.hub, + .subscriptions = std.StringHashMap(void).init(std.heap.page_allocator), + }; + } + + /// Called after init when self is the stable httpz-managed pointer. + /// Register with the hub here (not in init) to avoid dangling stack pointers. + pub fn afterInit(self: *WsClient) !void { + self.hub.register(self); + } + + /// Called when the client sends a message. + /// Protocol: {"subscribe": "colors:*"} or {"unsubscribe": "colors:foo"} + pub fn clientMessage(self: *WsClient, data: []const u8) !void { + const parsed = std.json.parseFromSlice( + std.json.Value, + std.heap.page_allocator, + data, + .{}, + ) catch return; + defer parsed.deinit(); + + const obj = switch (parsed.value) { + .object => |o| o, + else => return, + }; + + if (obj.get("subscribe")) |sub| { + switch (sub) { + .string => |channel| { + // Store a copy of the channel name + const owned = std.heap.page_allocator.dupe(u8, channel) catch return; + self.subscriptions.put(owned, {}) catch {}; + }, + else => {}, + } + } + + if (obj.get("unsubscribe")) |unsub| { + switch (unsub) { + .string => |channel| { + if (self.subscriptions.fetchRemove(channel)) |entry| { + std.heap.page_allocator.free(entry.key); + } + }, + else => {}, + } + } + } + + /// Called when the connection is closed. + pub fn close(self: *WsClient) void { + self.hub.unregister(self); + // Free subscription keys + var it = self.subscriptions.keyIterator(); + while (it.next()) |key_ptr| { + std.heap.page_allocator.free(key_ptr.*); + } + self.subscriptions.deinit(); + } + + fn isSubscribed(self: *WsClient, channel: []const u8) bool { + return self.subscriptions.contains(channel); + } +}; diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..f441524 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,55 @@ + + + + + + + [i] Buildlight + + + + + +
+
+

+ Hooray! All projects are passing. 🎉 +

+
+
+

Rats. {{.FailingCount}} failing.

+
+ + +
+ + + + + + diff --git a/templates/red.html b/templates/red.html new file mode 100644 index 0000000..af4fa05 --- /dev/null +++ b/templates/red.html @@ -0,0 +1,3 @@ +
+ {{.RedProjects}} +
diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep deleted file mode 100644 index e69de29..0000000