diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 325bfc0..0000000 --- a/.dockerignore +++ /dev/null @@ -1,51 +0,0 @@ -# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. - -# Ignore git directory. -/.git/ -/.gitignore - -# Ignore bundler config. -/.bundle - -# Ignore all environment files. -/.env* - -# Ignore all default key files. -/config/master.key -/config/credentials/*.key - -# Ignore all logfiles and tempfiles. -/log/* -/tmp/* -!/log/.keep -!/tmp/.keep - -# Ignore pidfiles, but keep the directory. -/tmp/pids/* -!/tmp/pids/.keep - -# Ignore storage (uploaded files in development and any SQLite databases). -/storage/* -!/storage/.keep -/tmp/storage/* -!/tmp/storage/.keep - -# Ignore assets. -/node_modules/ -/app/assets/builds/* -!/app/assets/builds/.keep -/public/assets - -# Ignore CI service files. -/.github - -# Ignore Kamal files. -/config/deploy*.yml -/.kamal - -# Ignore development files -/.devcontainer - -# Ignore Docker-related files -/.dockerignore -/Dockerfile* diff --git a/.gitignore b/.gitignore index bd63473..5807673 100644 --- a/.gitignore +++ b/.gitignore @@ -4,20 +4,4 @@ # 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 +/buildlight 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..5f42d8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,96 +1,15 @@ -# syntax=docker/dockerfile:1 -# check=error=true +FROM golang:1 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 +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download -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 COPY . . +RUN CGO_ENABLED=0 go build -tags production -o buildlight ./cmd/buildlight -# 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 - -# Copy built artifacts: gems, application -COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" -COPY --from=build /rails /rails - -# 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 +FROM debian:stable-slim -# Entrypoint sets up the container. -ENTRYPOINT ["/rails/bin/docker-entrypoint"] +COPY --from=builder /app/buildlight /usr/local/bin/buildlight -# 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/Rakefile b/Rakefile deleted file mode 100755 index b523e53..0000000 --- a/Rakefile +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env rake -# Add your own tasks in files placed in lib/tasks ending in .rake, -# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. - -require File.expand_path("../config/application", __FILE__) - -Buildlight::Application.load_tasks - -task default: [:standard, "css:build", :spec] 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/assets.go b/assets.go new file mode 100644 index 0000000..1b1bae2 --- /dev/null +++ b/assets.go @@ -0,0 +1,26 @@ +//go:build !production + +package buildlight + +import ( + "io/fs" + "net/http" + "os" + "path/filepath" +) + +// RootDir is the project root directory, used for resolving asset paths in +// development mode. Tests running from subdirectories should set this. +var RootDir = "." + +func StaticHandler() http.Handler { + return http.StripPrefix("/public/", http.FileServer(http.Dir(filepath.Join(RootDir, "public")))) +} + +func TemplateDir() fs.FS { + return os.DirFS(RootDir) +} + +func ReadPublicFile(name string) ([]byte, error) { + return os.ReadFile(filepath.Join(RootDir, "public", name)) +} diff --git a/assets_prod.go b/assets_prod.go new file mode 100644 index 0000000..61a3ff4 --- /dev/null +++ b/assets_prod.go @@ -0,0 +1,27 @@ +//go:build production + +package buildlight + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed public +var publicFS embed.FS + +//go:embed templates +var templateFS embed.FS + +func StaticHandler() http.Handler { + return http.FileServerFS(publicFS) +} + +func TemplateDir() fs.FS { + return templateFS +} + +func ReadPublicFile(name string) ([]byte, error) { + return publicFS.ReadFile("public/" + name) +} 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/cmd/buildlight/main.go b/cmd/buildlight/main.go new file mode 100644 index 0000000..f60ec34 --- /dev/null +++ b/cmd/buildlight/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + + "buildlight" + "buildlight/internal/app" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "migrate" { + ctx := context.Background() + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + databaseURL = "postgres://localhost/buildlight_development?sslmode=disable" + } + db, err := app.NewDB(ctx, databaseURL) + if err != nil { + log.Fatal(err) + } + defer db.Close() + if err := db.Migrate(ctx, buildlight.MigrationsFS); err != nil { + log.Fatal(err) + } + log.Println("Migrations complete") + return + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + host := os.Getenv("HOST") + if host == "" { + host = "localhost:" + port + } + + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + databaseURL = "postgres://localhost/buildlight_development?sslmode=disable" + } + + cfg := app.Config{ + DatabaseURL: databaseURL, + Port: port, + Host: host, + Debug: os.Getenv("DEBUG") != "", + ParticleAccessToken: os.Getenv("PARTICLE_ACCESS_TOKEN"), + } + + if err := app.ListenAndServe(ctx, cfg); err != nil { + log.Fatal(err) + } +} diff --git a/config.ru b/config.ru deleted file mode 100644 index 5e97aa6..0000000 --- a/config.ru +++ /dev/null @@ -1,5 +0,0 @@ -# This file is used by Rack-based servers to start the application. - -require ::File.expand_path("../config/environment", __FILE__) -use Rack::Deflater -run Buildlight::Application 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/doc/README_FOR_APP b/doc/README_FOR_APP deleted file mode 100644 index fe41f5c..0000000 --- a/doc/README_FOR_APP +++ /dev/null @@ -1,2 +0,0 @@ -Use this README file to introduce your application and point to useful places in the API for learning more. -Run "rake doc:app" to generate API documentation for your models, controllers, helpers, and libraries. diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..ab36095 --- /dev/null +++ b/embed.go @@ -0,0 +1,6 @@ +package buildlight + +import "embed" + +//go:embed migrations/*.sql +var MigrationsFS embed.FS diff --git a/fly.toml b/fly.toml index 1051617..ba613ee 100644 --- a/fly.toml +++ b/fly.toml @@ -1,42 +1,28 @@ -# 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' + 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 = "/" - + size = "shared-cpu-1x" + memory = "256mb" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f221945 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module buildlight + +go 1.24.0 + +require ( + github.com/coder/websocket v1.8.12 + github.com/jackc/pgx/v5 v5.7.2 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/testify v1.10.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..287bf15 --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/db.go b/internal/app/db.go new file mode 100644 index 0000000..9372636 --- /dev/null +++ b/internal/app/db.go @@ -0,0 +1,121 @@ +package app + +import ( + "context" + "fmt" + "io/fs" + "log" + "sort" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type DB struct { + pool *pgxpool.Pool +} + +func NewDB(ctx context.Context, databaseURL string) (*DB, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("connecting to database: %w", err) + } + if err := pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("pinging database: %w", err) + } + return &DB{pool: pool}, nil +} + +func (db *DB) Close() { + db.pool.Close() +} + +// Migrate runs pending SQL migrations using Rails' schema_migrations table format. +// Each applied migration is tracked as a version string (the timestamp prefix), +// so Rails and Go share the same migration state. +func (db *DB) Migrate(ctx context.Context, migrationsFS fs.FS) error { + // Ensure schema_migrations table exists (matches Rails' format) + _, err := db.pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR NOT NULL + ) + `) + if err != nil { + return fmt.Errorf("creating schema_migrations table: %w", err) + } + _, err = db.pool.Exec(ctx, ` + CREATE UNIQUE INDEX IF NOT EXISTS unique_schema_migrations ON schema_migrations (version) + `) + if err != nil { + return fmt.Errorf("creating schema_migrations index: %w", err) + } + + // Load applied versions + applied := make(map[string]bool) + rows, err := db.pool.Query(ctx, "SELECT version FROM schema_migrations") + if err != nil { + return fmt.Errorf("querying schema_migrations: %w", err) + } + defer rows.Close() + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + return err + } + applied[v] = true + } + if err := rows.Err(); err != nil { + return err + } + + // Read migration files, sorted by name + entries, err := fs.ReadDir(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("reading migrations dir: %w", err) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".sql") { + continue + } + + // Extract version (timestamp prefix before first underscore) + version := strings.SplitN(strings.TrimSuffix(name, ".sql"), "_", 2)[0] + + if applied[version] { + continue + } + + sql, err := fs.ReadFile(migrationsFS, "migrations/"+name) + if err != nil { + return fmt.Errorf("reading migration %s: %w", name, err) + } + + log.Printf("Running migration %s", name) + + tx, err := db.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning transaction for %s: %w", name, err) + } + + if _, err := tx.Exec(ctx, string(sql)); err != nil { + tx.Rollback(ctx) + return fmt.Errorf("executing migration %s: %w", name, err) + } + + if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil { + tx.Rollback(ctx) + return fmt.Errorf("recording migration %s: %w", name, err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("committing migration %s: %w", name, err) + } + } + + return nil +} diff --git a/internal/app/handlers.go b/internal/app/handlers.go new file mode 100644 index 0000000..f1c3727 --- /dev/null +++ b/internal/app/handlers.go @@ -0,0 +1,333 @@ +package app + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "buildlight" +) + +type Handler struct { + db *DB + hub *Hub +} + +// WebhookCreate handles POST / - receives CI webhooks +func (h *Handler) WebhookCreate(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Try to detect service type + contentType := r.Header.Get("Content-Type") + + // Travis CI sends payload as form-encoded + if contentType == "application/x-www-form-urlencoded" { + r.Body = io.NopCloser(bytes.NewReader(body)) + r.ParseForm() + payloadStr := r.FormValue("payload") + if payloadStr != "" { + if err := ParseTravis(r.Context(), h.db, h.hub, payloadStr); err != nil { + log.Printf("ParseTravis error: %v", err) + } + w.WriteHeader(http.StatusOK) + return + } + } + + // Try JSON parsing + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Check for Circle CI + circleEventType := r.Header.Get("Circleci-Event-Type") + if circleEventType != "" { + if err := ParseCircle(r.Context(), h.db, h.hub, payload); err != nil { + log.Printf("ParseCircle error: %v", err) + } + w.WriteHeader(http.StatusOK) + return + } + + // Check for GitHub Actions (has "repository" as a string like "owner/repo") + if repo, ok := payload["repository"].(string); ok && strings.Contains(repo, "/") { + if err := ParseGithub(r.Context(), h.db, h.hub, payload); err != nil { + log.Printf("ParseGithub error: %v", err) + } + w.WriteHeader(http.StatusOK) + return + } + + // Check for Travis CI JSON payload + if payloadStr, ok := payload["payload"].(string); ok { + if err := ParseTravis(r.Context(), h.db, h.hub, payloadStr); err != nil { + log.Printf("ParseTravis error: %v", err) + } + w.WriteHeader(http.StatusOK) + return + } + + http.Error(w, "Bad request", http.StatusBadRequest) +} + +// ColorsIndex handles GET / - show all colors +func (h *Handler) ColorsIndex(w http.ResponseWriter, r *http.Request) { + h.renderColors(w, r, nil) +} + +// ColorsShow handles GET /{id} - show colors for specific usernames +func (h *Handler) ColorsShow(w http.ResponseWriter, r *http.Request) { + ids := strings.Split(r.PathValue("id"), ",") + h.renderColors(w, r, ids) +} + +func (h *Handler) renderColors(w http.ResponseWriter, r *http.Request, ids []string) { + accept := r.Header.Get("Accept") + + // Check for .ryg extension + path := r.URL.Path + if strings.HasSuffix(path, ".ryg") { + h.streamRYG(w, r, ids) + return + } + + colors, err := GetColors(r.Context(), h.db, ids) + if err != nil { + renderErrorPage(w, http.StatusInternalServerError) + return + } + + // JSON response + if strings.Contains(accept, "application/json") || r.URL.Query().Get("format") == "json" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(colors) + return + } + + // HTML response + h.renderColorsHTML(w, colors) +} + +func (h *Handler) streamRYG(w http.ResponseWriter, r *http.Request, ids []string) { + w.Header().Set("Content-Type", "text/ryg") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming not supported", http.StatusInternalServerError) + return + } + + for { + select { + case <-r.Context().Done(): + return + default: + colors, err := GetColors(r.Context(), h.db, ids) + if err != nil { + return + } + fmt.Fprint(w, colors.RYG()) + flusher.Flush() + time.Sleep(1 * time.Second) + } + } +} + +func (h *Handler) renderColorsHTML(w http.ResponseWriter, colors Colors) { + ReloadTemplates() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + data := struct { + Colors Colors + Favicon string + BodyAttrs string + }{ + Colors: colors, + Favicon: faviconForColors(colors), + BodyAttrs: bodyAttrsForColors(colors), + } + + var buf bytes.Buffer + if err := Templates.ExecuteTemplate(&buf, "layout.html", data); err != nil { + log.Printf("Template error: %v", err) + renderErrorPage(w, http.StatusInternalServerError) + return + } + w.Write(buf.Bytes()) +} + +// DeviceShow handles GET /devices/{id} +func (h *Handler) DeviceShow(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + device, err := FindDeviceBySlugOrID(r.Context(), h.db, id) + if err != nil { + renderErrorPage(w, http.StatusNotFound) + return + } + + colors, err := GetDeviceColors(r.Context(), h.db, device) + if err != nil { + renderErrorPage(w, http.StatusInternalServerError) + return + } + + accept := r.Header.Get("Accept") + if strings.Contains(accept, "application/json") || r.URL.Query().Get("format") == "json" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(colors) + return + } + + h.renderColorsHTML(w, colors) +} + +// APIDeviceShow handles GET /api/devices/{id} +func (h *Handler) APIDeviceShow(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + device, err := FindDeviceByID(r.Context(), h.db, id) + if err != nil { + renderErrorPage(w, http.StatusNotFound) + return + } + + colors, err := GetDeviceColors(r.Context(), h.db, device) + if err != nil { + renderErrorPage(w, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "colors": colors.AsBooleans(), + "ryg": colors.RYG(), + }) +} + +// APIDeviceTrigger handles POST /api/device/trigger +func (h *Handler) APIDeviceTrigger(w http.ResponseWriter, r *http.Request) { + var body struct { + CoreID string `json:"coreid"` + } + + // Try form value first (Particle sends form data) + coreID := r.FormValue("coreid") + if coreID == "" { + json.NewDecoder(r.Body).Decode(&body) + coreID = body.CoreID + } + + if coreID != "" { + device, err := FindDeviceByIdentifier(r.Context(), h.db, coreID) + if err == nil { + TriggerDevice(r.Context(), h.db, device) + } + } + + w.WriteHeader(http.StatusOK) +} + +// APIRedShow handles GET /api/device/{id}/red +func (h *Handler) APIRedShow(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + device, err := FindDeviceByIdentifier(r.Context(), h.db, id) + if err != nil { + renderErrorPage(w, http.StatusNotFound) + return + } + + redStatuses, err := GetRedStatuses(r.Context(), h.db, device) + if err != nil { + renderErrorPage(w, http.StatusInternalServerError) + return + } + + accept := r.Header.Get("Accept") + if strings.Contains(accept, "application/json") || r.URL.Query().Get("format") == "json" { + type redProject struct { + Username string `json:"username"` + ProjectName string `json:"project_name"` + } + var projects []redProject + for _, s := range redStatuses { + projects = append(projects, redProject{ + Username: s.Username, + ProjectName: s.ProjectName, + }) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(projects) + return + } + + // HTML response + ReloadTemplates() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + data := struct { + RedProjects []Status + }{ + RedProjects: redStatuses, + } + if err := Templates.ExecuteTemplate(w, "red.html", data); err != nil { + log.Printf("Template error: %v", err) + } +} + +// HandleWebSocket handles GET /ws +func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) { + HandleWebSocketConnection(h.hub, w, r) +} + +// Helper functions for templates + +func faviconForColors(colors Colors) string { + filename := "/public/favicon" + if colors.Red > 0 { + filename += "-failing" + } else { + filename += "-passing" + } + if colors.Yellow { + filename += "-building" + } + filename += ".ico" + return filename +} + +func bodyAttrsForColors(colors Colors) string { + var attrs string + if colors.Red > 0 { + attrs += " data-failing" + } else { + attrs += " data-passing" + } + if colors.Yellow { + attrs += " data-building" + } + return attrs +} + +// renderErrorPage serves a static error page from public/ (e.g. 404.html, 500.html). +// Falls back to http.Error if the file doesn't exist. +func renderErrorPage(w http.ResponseWriter, code int) { + data, err := buildlight.ReadPublicFile(fmt.Sprintf("%d.html", code)) + if err != nil { + http.Error(w, http.StatusText(code), code) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(code) + w.Write(data) +} diff --git a/internal/app/handlers_test.go b/internal/app/handlers_test.go new file mode 100644 index 0000000..5148f86 --- /dev/null +++ b/internal/app/handlers_test.go @@ -0,0 +1,472 @@ +package app + +import ( + "encoding/json" + "html/template" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "buildlight" +) + +func setupTestServer(t *testing.T) (*httptest.Server, *DB) { + t.Helper() + + db, _ := setupTestDB(t) + hub := NewHub() + go hub.Run() + + // Parse templates for HTML rendering + var err error + Templates, err = template.New("").Funcs(TemplateFuncs).ParseFS(buildlight.TemplateDir(), "templates/*.html") + if err != nil { + t.Fatalf("Failed to parse templates: %v", err) + } + + h := &Handler{db: db, hub: hub} + + mux := http.NewServeMux() + mux.HandleFunc("GET /up", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + mux.Handle("GET /public/", buildlight.StaticHandler()) + mux.HandleFunc("GET /ws", h.HandleWebSocket) + mux.HandleFunc("GET /api/devices/{id}", h.APIDeviceShow) + mux.HandleFunc("POST /api/device/trigger", h.APIDeviceTrigger) + mux.HandleFunc("GET /api/device/{id}/red", h.APIRedShow) + mux.HandleFunc("GET /devices/{id}", h.DeviceShow) + mux.HandleFunc("POST /", h.WebhookCreate) + mux.HandleFunc("GET /{id}", h.ColorsShow) + mux.HandleFunc("GET /{$}", h.ColorsIndex) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + return server, db +} + +func TestColorsIndex(t *testing.T) { + server, db := setupTestServer(t) + _, ctx := setupTestDB(t) + + t.Run("returns HTML by default", func(t *testing.T) { + truncate(t, db, ctx) + resp, err := http.Get(server.URL + "/") + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "text/html") { + t.Errorf("expected text/html, got %s", ct) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "Buildlight") { + t.Error("expected HTML to contain Buildlight") + } + }) + + t.Run("returns JSON when requested", func(t *testing.T) { + truncate(t, db, ctx) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "alice"}) + + req, _ := http.NewRequest("GET", server.URL+"/?format=json", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var colors Colors + json.NewDecoder(resp.Body).Decode(&colors) + if colors.Red != 1 { + t.Errorf("expected red=1, got %d", colors.Red) + } + }) + + t.Run("returns JSON with Accept header", func(t *testing.T) { + truncate(t, db, ctx) + + req, _ := http.NewRequest("GET", server.URL+"/", nil) + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "application/json") { + t.Errorf("expected application/json, got %s", ct) + } + }) +} + +func TestColorsShow(t *testing.T) { + server, db := setupTestServer(t) + _, ctx := setupTestDB(t) + + t.Run("filters by username", func(t *testing.T) { + truncate(t, db, ctx) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "alice"}) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "bob"}) + + req, _ := http.NewRequest("GET", server.URL+"/alice?format=json", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + var colors Colors + json.NewDecoder(resp.Body).Decode(&colors) + if colors.Red != 1 { + t.Errorf("expected red=1, got %d", colors.Red) + } + }) + + t.Run("supports multiple usernames", func(t *testing.T) { + truncate(t, db, ctx) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "alice"}) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "bob"}) + + req, _ := http.NewRequest("GET", server.URL+"/alice,bob?format=json", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + var colors Colors + json.NewDecoder(resp.Body).Decode(&colors) + if colors.Red != 2 { + t.Errorf("expected red=2, got %d", colors.Red) + } + }) +} + +func TestDeviceShow(t *testing.T) { + server, db := setupTestServer(t) + _, ctx := setupTestDB(t) + + t.Run("shows device by slug", func(t *testing.T) { + truncate(t, db, ctx) + createDevice(t, db, ctx, deviceOpts{ + Slug: "my-device", + Usernames: []string{"alice"}, + }) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "alice"}) + + resp, err := http.Get(server.URL + "/devices/my-device") + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + }) + + t.Run("shows device by id", func(t *testing.T) { + truncate(t, db, ctx) + d := createDevice(t, db, ctx, deviceOpts{Usernames: []string{"alice"}}) + + resp, err := http.Get(server.URL + "/devices/" + d.ID) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + }) + + t.Run("returns 404 for unknown device", func(t *testing.T) { + truncate(t, db, ctx) + resp, err := http.Get(server.URL + "/devices/nonexistent") + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 404 { + t.Errorf("expected 404, got %d", resp.StatusCode) + } + }) +} + +func TestAPIDeviceShow(t *testing.T) { + server, db := setupTestServer(t) + _, ctx := setupTestDB(t) + + t.Run("returns colors and ryg", func(t *testing.T) { + truncate(t, db, ctx) + d := createDevice(t, db, ctx, deviceOpts{Usernames: []string{"alice"}}) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "alice"}) + + resp, err := http.Get(server.URL + "/api/devices/" + d.ID) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var result map[string]any + json.NewDecoder(resp.Body).Decode(&result) + + ryg, _ := result["ryg"].(string) + if ryg != "Ryg" { + t.Errorf("expected ryg=Ryg, got %s", ryg) + } + + colorsMap, _ := result["colors"].(map[string]any) + if colorsMap["red"] != true { + t.Errorf("expected colors.red=true, got %v", colorsMap["red"]) + } + }) + + t.Run("returns 404 for unknown device", func(t *testing.T) { + truncate(t, db, ctx) + resp, err := http.Get(server.URL + "/api/devices/00000000-0000-0000-0000-000000000000") + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 404 { + t.Errorf("expected 404, got %d", resp.StatusCode) + } + }) +} + +func TestAPIDeviceTrigger(t *testing.T) { + server, db := setupTestServer(t) + _, ctx := setupTestDB(t) + + t.Run("accepts coreid form value", func(t *testing.T) { + truncate(t, db, ctx) + createDevice(t, db, ctx, deviceOpts{Identifier: "abc123"}) + + resp, err := http.PostForm(server.URL+"/api/device/trigger", map[string][]string{ + "coreid": {"abc123"}, + }) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + }) + + t.Run("accepts coreid json body", func(t *testing.T) { + truncate(t, db, ctx) + createDevice(t, db, ctx, deviceOpts{Identifier: "abc123"}) + + body := strings.NewReader(`{"coreid":"abc123"}`) + resp, err := http.Post(server.URL+"/api/device/trigger", "application/json", body) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + }) +} + +func TestAPIRedShow(t *testing.T) { + server, db := setupTestServer(t) + _, ctx := setupTestDB(t) + + t.Run("returns JSON list of failing projects", func(t *testing.T) { + truncate(t, db, ctx) + d := createDevice(t, db, ctx, deviceOpts{ + Identifier: "device1", + Usernames: []string{"alice"}, + }) + createStatus(t, db, ctx, statusOpts{ + Red: boolPtr(true), + Username: "alice", + ProjectName: "failing-project", + }) + + req, _ := http.NewRequest("GET", server.URL+"/api/device/"+d.Identifier+"/red?format=json", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var projects []map[string]string + json.NewDecoder(resp.Body).Decode(&projects) + if len(projects) != 1 { + t.Fatalf("expected 1 project, got %d", len(projects)) + } + if projects[0]["project_name"] != "failing-project" { + t.Errorf("expected project_name=failing-project, got %s", projects[0]["project_name"]) + } + }) + + t.Run("returns HTML", func(t *testing.T) { + truncate(t, db, ctx) + d := createDevice(t, db, ctx, deviceOpts{ + Identifier: "device2", + Usernames: []string{"alice"}, + }) + createStatus(t, db, ctx, statusOpts{ + Red: boolPtr(true), + Username: "alice", + ProjectName: "broken-project", + }) + + resp, err := http.Get(server.URL + "/api/device/" + d.Identifier + "/red") + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "broken-project") { + t.Error("expected HTML to contain broken-project") + } + }) +} + +func TestWebhookCreate(t *testing.T) { + server, db := setupTestServer(t) + _, ctx := setupTestDB(t) + + t.Run("returns 400 for unknown payload", func(t *testing.T) { + truncate(t, db, ctx) + resp, err := http.Post(server.URL+"/", "application/json", strings.NewReader(`{"unknown":"payload"}`)) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 400 { + t.Errorf("expected 400, got %d", resp.StatusCode) + } + }) + + t.Run("processes Travis form payload", func(t *testing.T) { + truncate(t, db, ctx) + body := "payload=" + loadFixture(t, "travis.json") + resp, err := http.Post(server.URL+"/", "application/x-www-form-urlencoded", strings.NewReader(body)) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + if count := statusCount(t, db, ctx); count != 1 { + t.Errorf("expected 1 status, got %d", count) + } + }) + + t.Run("processes GitHub payload", func(t *testing.T) { + truncate(t, db, ctx) + body := loadFixture(t, "github.json") + resp, err := http.Post(server.URL+"/", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + if count := statusCount(t, db, ctx); count != 1 { + t.Errorf("expected 1 status, got %d", count) + } + }) + + t.Run("processes Circle payload", func(t *testing.T) { + truncate(t, db, ctx) + body := loadFixture(t, "circle.json") + + req, _ := http.NewRequest("POST", server.URL+"/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Circleci-Event-Type", "workflow-completed") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + if count := statusCount(t, db, ctx); count != 1 { + t.Errorf("expected 1 status, got %d", count) + } + }) + + t.Run("Circle skips non-main branch", func(t *testing.T) { + truncate(t, db, ctx) + body := loadFixture(t, "circle_pr.json") + + req, _ := http.NewRequest("POST", server.URL+"/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Circleci-Event-Type", "workflow-completed") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + if count := statusCount(t, db, ctx); count != 0 { + t.Errorf("expected 0 statuses for non-main branch, got %d", count) + } + }) + + t.Run("returns 400 for invalid JSON", func(t *testing.T) { + truncate(t, db, ctx) + resp, err := http.Post(server.URL+"/", "application/json", strings.NewReader("not json")) + if err != nil { + t.Fatalf("Request error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 400 { + t.Errorf("expected 400, got %d", resp.StatusCode) + } + }) +} diff --git a/internal/app/helpers_test.go b/internal/app/helpers_test.go new file mode 100644 index 0000000..10e088d --- /dev/null +++ b/internal/app/helpers_test.go @@ -0,0 +1,221 @@ +package app + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + + "buildlight" +) + +func TestMain(m *testing.M) { + buildlight.RootDir = filepath.Join("..", "..") + os.Exit(m.Run()) +} + +var ( + testDB *DB + testOnce sync.Once + seq atomic.Int64 +) + +func setupTestDB(t *testing.T) (*DB, context.Context) { + t.Helper() + + testOnce.Do(func() { + url := os.Getenv("DATABASE_URL") + if url == "" { + url = "postgres://localhost/buildlight_test?sslmode=disable" + } + + ctx := context.Background() + db, err := NewDB(ctx, url) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to connect to test database: %v\n", err) + fmt.Fprintf(os.Stderr, "Set DATABASE_URL or create a buildlight_test database\n") + os.Exit(1) + } + + if err := db.Migrate(ctx, buildlight.MigrationsFS); err != nil { + fmt.Fprintf(os.Stderr, "Failed to run migrations: %v\n", err) + os.Exit(1) + } + + testDB = db + }) + + ctx := context.Background() + truncate(t, testDB, ctx) + + return testDB, ctx +} + +func truncate(t *testing.T, db *DB, ctx context.Context) { + t.Helper() + _, err := db.pool.Exec(ctx, "TRUNCATE statuses, devices RESTART IDENTITY CASCADE") + if err != nil { + t.Fatalf("Failed to truncate tables: %v", err) + } +} + +// Factory helpers + +type statusOpts struct { + Service string + ProjectID string + ProjectName string + Username string + Workflow string + Red *bool + Yellow *bool +} + +func createStatus(t *testing.T, db *DB, ctx context.Context, opts statusOpts) *Status { + t.Helper() + + n := seq.Add(1) + + if opts.Service == "" { + opts.Service = "travis" + } + if opts.ProjectID == "" { + opts.ProjectID = fmt.Sprintf("%d", n) + } + if opts.ProjectName == "" { + opts.ProjectName = fmt.Sprintf("buildlight%d", n) + } + if opts.Red == nil { + opts.Red = boolPtr(false) + } + if opts.Yellow == nil { + opts.Yellow = boolPtr(false) + } + + s := &Status{ + Service: opts.Service, + ProjectID: opts.ProjectID, + ProjectName: opts.ProjectName, + Username: opts.Username, + Workflow: opts.Workflow, + Red: opts.Red, + Yellow: opts.Yellow, + } + + err := db.pool.QueryRow(ctx, ` + INSERT INTO statuses (service, project_id, project_name, username, workflow, red, yellow, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) + RETURNING id + `, s.Service, s.ProjectID, s.ProjectName, s.Username, s.Workflow, s.Red, s.Yellow).Scan(&s.ID) + if err != nil { + t.Fatalf("Failed to create status: %v", err) + } + + return s +} + +type deviceOpts struct { + Name string + Usernames []string + Projects []string + Identifier string + Slug string + WebhookURL string +} + +func createDevice(t *testing.T, db *DB, ctx context.Context, opts deviceOpts) *Device { + t.Helper() + + n := seq.Add(1) + + if opts.Name == "" { + opts.Name = fmt.Sprintf("Device %d", n) + } + if opts.Usernames == nil { + opts.Usernames = []string{} + } + if opts.Projects == nil { + opts.Projects = []string{} + } + if opts.Slug == "" { + opts.Slug = fmt.Sprintf("slug-%d", n) + } + + var identifier *string + if opts.Identifier != "" { + identifier = &opts.Identifier + } + + var webhookURL *string + if opts.WebhookURL != "" { + webhookURL = &opts.WebhookURL + } + + d := &Device{} + err := db.pool.QueryRow(ctx, ` + INSERT INTO devices (name, usernames, projects, identifier, slug, webhook_url, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + RETURNING id, name, usernames, projects, COALESCE(identifier,''), COALESCE(webhook_url,''), COALESCE(slug,''), COALESCE(status,''), status_changed_at, created_at, updated_at + `, opts.Name, opts.Usernames, opts.Projects, identifier, opts.Slug, webhookURL).Scan( + &d.ID, &d.Name, &d.Usernames, &d.Projects, + &d.Identifier, &d.WebhookURL, &d.Slug, &d.Status, + &d.StatusChangedAt, &d.CreatedAt, &d.UpdatedAt, + ) + if err != nil { + t.Fatalf("Failed to create device: %v", err) + } + + return d +} + +func statusCount(t *testing.T, db *DB, ctx context.Context) int { + t.Helper() + var count int + err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM statuses").Scan(&count) + if err != nil { + t.Fatalf("Failed to count statuses: %v", err) + } + return count +} + +func loadStatus(t *testing.T, db *DB, ctx context.Context, id int) *Status { + t.Helper() + var s Status + err := db.pool.QueryRow(ctx, ` + SELECT id, service, COALESCE(project_id,''), COALESCE(project_name,''), + COALESCE(username,''), COALESCE(workflow,''), red, yellow + FROM statuses WHERE id = $1 + `, id).Scan(&s.ID, &s.Service, &s.ProjectID, &s.ProjectName, + &s.Username, &s.Workflow, &s.Red, &s.Yellow) + if err != nil { + t.Fatalf("Failed to load status %d: %v", id, err) + } + return &s +} + +func loadLatestStatus(t *testing.T, db *DB, ctx context.Context) *Status { + t.Helper() + var s Status + err := db.pool.QueryRow(ctx, ` + SELECT id, service, COALESCE(project_id,''), COALESCE(project_name,''), + COALESCE(username,''), COALESCE(workflow,''), red, yellow + FROM statuses ORDER BY created_at DESC LIMIT 1 + `).Scan(&s.ID, &s.Service, &s.ProjectID, &s.ProjectName, + &s.Username, &s.Workflow, &s.Red, &s.Yellow) + if err != nil { + t.Fatalf("Failed to load latest status: %v", err) + } + return &s +} + +func loadFixture(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile("testdata/" + name) + if err != nil { + t.Fatalf("Failed to read fixture %s: %v", name, err) + } + return string(data) +} diff --git a/internal/app/models.go b/internal/app/models.go new file mode 100644 index 0000000..da4abf5 --- /dev/null +++ b/internal/app/models.go @@ -0,0 +1,420 @@ +package app + +import ( + "context" + "strings" + "time" +) + +// Status represents a CI build status +type Status struct { + ID int + Service string + ProjectID string + ProjectName string + Username string + Workflow string + Red *bool + Yellow *bool + Payload *string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (s *Status) Name() string { + return s.Username + "/" + s.ProjectName +} + +// Colors represents the aggregated color state +type Colors struct { + Red int `json:"red"` + Yellow bool `json:"yellow"` + Green bool `json:"green"` +} + +// ColorsAsBooleans returns colors with red as a boolean +type ColorsAsBooleans struct { + Red bool `json:"red"` + Yellow bool `json:"yellow"` + Green bool `json:"green"` +} + +func (c Colors) AsBooleans() ColorsAsBooleans { + return ColorsAsBooleans{ + Red: c.Red > 0, + Yellow: c.Yellow, + Green: c.Green, + } +} + +func (c Colors) RYG() string { + var b strings.Builder + if c.Red > 0 { + b.WriteByte('R') + } else { + b.WriteByte('r') + } + if c.Yellow { + b.WriteByte('Y') + } else { + b.WriteByte('y') + } + if c.Green { + b.WriteByte('G') + } else { + b.WriteByte('g') + } + return b.String() +} + +// CurrentStatus returns a status string like "passing", "failing-building", etc. +func CurrentStatus(ctx context.Context, db *DB, usernames []string, projects []string) (string, error) { + statuses, err := findStatusesForDevice(ctx, db, usernames, projects) + if err != nil { + return "", err + } + + hasRed := false + hasYellow := false + for _, s := range statuses { + if s.Red != nil && *s.Red { + hasRed = true + } + if s.Yellow != nil && *s.Yellow { + hasYellow = true + } + } + + var parts []string + if !hasRed { + parts = append(parts, "passing") + } + if hasRed { + parts = append(parts, "failing") + } + if hasYellow { + parts = append(parts, "building") + } + return strings.Join(parts, "-"), nil +} + +// GetColors returns aggregated colors for given usernames (nil = all) +func GetColors(ctx context.Context, db *DB, usernames []string) (Colors, error) { + var redCount int + var yellowExists bool + + if len(usernames) == 0 { + err := db.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM statuses WHERE red = true").Scan(&redCount) + if err != nil { + return Colors{}, err + } + err = db.pool.QueryRow(ctx, + "SELECT EXISTS(SELECT 1 FROM statuses WHERE yellow = true)").Scan(&yellowExists) + if err != nil { + return Colors{}, err + } + } else { + err := db.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM statuses WHERE red = true AND username = ANY($1)", usernames).Scan(&redCount) + if err != nil { + return Colors{}, err + } + err = db.pool.QueryRow(ctx, + "SELECT EXISTS(SELECT 1 FROM statuses WHERE yellow = true AND username = ANY($1))", usernames).Scan(&yellowExists) + if err != nil { + return Colors{}, err + } + } + + return Colors{ + Red: redCount, + Yellow: yellowExists, + Green: redCount == 0, + }, nil +} + +// GetDeviceColors returns colors for a specific device's watched statuses +func GetDeviceColors(ctx context.Context, db *DB, d *Device) (Colors, error) { + statuses, err := findStatusesForDevice(ctx, db, d.Usernames, d.Projects) + if err != nil { + return Colors{}, err + } + + redCount := 0 + yellowExists := false + for _, s := range statuses { + if s.Red != nil && *s.Red { + redCount++ + } + if s.Yellow != nil && *s.Yellow { + yellowExists = true + } + } + + return Colors{ + Red: redCount, + Yellow: yellowExists, + Green: redCount == 0, + }, nil +} + +func findStatusesForDevice(ctx context.Context, db *DB, usernames []string, projects []string) ([]Status, error) { + rows, err := db.pool.Query(ctx, ` + SELECT id, service, COALESCE(project_id,''), COALESCE(project_name,''), + COALESCE(username,''), COALESCE(workflow,''), red, yellow + FROM statuses + WHERE username = ANY($1) + OR (username || '/' || project_name) = ANY($2) + `, usernames, projects) + if err != nil { + return nil, err + } + defer rows.Close() + + var statuses []Status + for rows.Next() { + var s Status + if err := rows.Scan(&s.ID, &s.Service, &s.ProjectID, &s.ProjectName, + &s.Username, &s.Workflow, &s.Red, &s.Yellow); err != nil { + return nil, err + } + statuses = append(statuses, s) + } + return statuses, rows.Err() +} + +// FindDevicesForStatus finds devices watching a given status +func FindDevicesForStatus(ctx context.Context, db *DB, username, projectName string) ([]Device, error) { + name := username + "/" + projectName + rows, err := db.pool.Query(ctx, ` + SELECT id, name, usernames, projects, COALESCE(identifier,''), + COALESCE(webhook_url,''), COALESCE(slug,''), COALESCE(status,''), + status_changed_at, created_at, updated_at + FROM devices + WHERE usernames @> ARRAY[$1]::varchar[] + OR projects @> ARRAY[$2]::varchar[] + `, username, name) + if err != nil { + return nil, err + } + defer rows.Close() + + var devices []Device + for rows.Next() { + var d Device + if err := rows.Scan(&d.ID, &d.Name, &d.Usernames, &d.Projects, + &d.Identifier, &d.WebhookURL, &d.Slug, &d.Status, + &d.StatusChangedAt, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, err + } + devices = append(devices, d) + } + return devices, rows.Err() +} + +// UpsertStatus finds or creates a status and saves it +func UpsertStatus(ctx context.Context, db *DB, s *Status) error { + var id int + err := db.pool.QueryRow(ctx, ` + 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,'') + `, s.Service, s.Username, s.ProjectName, s.ProjectID, s.Workflow).Scan(&id) + + if err != nil { + // Not found, insert + err = db.pool.QueryRow(ctx, ` + 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()) + RETURNING id + `, s.Service, s.Username, s.ProjectName, s.ProjectID, s.Workflow, s.Red, s.Yellow, s.Payload).Scan(&s.ID) + } else { + s.ID = id + _, err = db.pool.Exec(ctx, ` + UPDATE statuses SET red = $1, yellow = $2, payload = $3, username = $4, + project_name = $5, updated_at = NOW() + WHERE id = $6 + `, s.Red, s.Yellow, s.Payload, s.Username, s.ProjectName, id) + } + return err +} + +// GetRedStatuses returns failing statuses for a device +func GetRedStatuses(ctx context.Context, db *DB, d *Device) ([]Status, error) { + rows, err := db.pool.Query(ctx, ` + SELECT id, service, COALESCE(project_id,''), COALESCE(project_name,''), + COALESCE(username,''), COALESCE(workflow,''), red, yellow + FROM statuses + WHERE red = true + AND (username = ANY($1) OR (username || '/' || project_name) = ANY($2)) + `, d.Usernames, d.Projects) + if err != nil { + return nil, err + } + defer rows.Close() + + var statuses []Status + for rows.Next() { + var s Status + if err := rows.Scan(&s.ID, &s.Service, &s.ProjectID, &s.ProjectName, + &s.Username, &s.Workflow, &s.Red, &s.Yellow); err != nil { + return nil, err + } + statuses = append(statuses, s) + } + return statuses, rows.Err() +} + +// Device represents a physical or virtual build light device +type Device struct { + ID string + Name string + Usernames []string + Projects []string + Identifier string + WebhookURL string + Slug string + Status string + StatusChangedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// FindDeviceBySlugOrID finds a device by slug or UUID +func FindDeviceBySlugOrID(ctx context.Context, db *DB, id string) (*Device, error) { + var d Device + err := db.pool.QueryRow(ctx, ` + SELECT id, name, usernames, projects, COALESCE(identifier,''), + COALESCE(webhook_url,''), COALESCE(slug,''), COALESCE(status,''), + status_changed_at, created_at, updated_at + FROM devices + WHERE slug = $1 OR id::text = $1 + LIMIT 1 + `, id).Scan(&d.ID, &d.Name, &d.Usernames, &d.Projects, + &d.Identifier, &d.WebhookURL, &d.Slug, &d.Status, + &d.StatusChangedAt, &d.CreatedAt, &d.UpdatedAt) + if err != nil { + return nil, err + } + return &d, nil +} + +// FindDeviceByID finds a device by UUID +func FindDeviceByID(ctx context.Context, db *DB, id string) (*Device, error) { + var d Device + err := db.pool.QueryRow(ctx, ` + SELECT id, name, usernames, projects, COALESCE(identifier,''), + COALESCE(webhook_url,''), COALESCE(slug,''), COALESCE(status,''), + status_changed_at, created_at, updated_at + FROM devices + WHERE id = $1 + `, id).Scan(&d.ID, &d.Name, &d.Usernames, &d.Projects, + &d.Identifier, &d.WebhookURL, &d.Slug, &d.Status, + &d.StatusChangedAt, &d.CreatedAt, &d.UpdatedAt) + if err != nil { + return nil, err + } + return &d, nil +} + +// FindDeviceByIdentifier finds a device by its Particle identifier +func FindDeviceByIdentifier(ctx context.Context, db *DB, identifier string) (*Device, error) { + var d Device + err := db.pool.QueryRow(ctx, ` + SELECT id, name, usernames, projects, COALESCE(identifier,''), + COALESCE(webhook_url,''), COALESCE(slug,''), COALESCE(status,''), + status_changed_at, created_at, updated_at + FROM devices + WHERE identifier = $1 + `, identifier).Scan(&d.ID, &d.Name, &d.Usernames, &d.Projects, + &d.Identifier, &d.WebhookURL, &d.Slug, &d.Status, + &d.StatusChangedAt, &d.CreatedAt, &d.UpdatedAt) + if err != nil { + return nil, err + } + return &d, nil +} + +// UpdateDeviceStatus recalculates and persists device status, broadcasts, and triggers +func UpdateDeviceStatus(ctx context.Context, db *DB, hub *Hub, d *Device) error { + newStatus, err := CurrentStatus(ctx, db, d.Usernames, d.Projects) + if err != nil { + return err + } + + oldStatus := d.Status + d.Status = newStatus + + // Broadcast to WebSocket clients + if d.Slug != "" { + colors, err := GetDeviceColors(ctx, db, d) + if err == nil { + hub.Broadcast("device:"+d.Slug, map[string]interface{}{ + "colors": colors, + }) + } + } + + if oldStatus != newStatus { + now := time.Now() + d.StatusChangedAt = &now + _, err = db.pool.Exec(ctx, + "UPDATE devices SET status = $1, status_changed_at = $2, updated_at = NOW() WHERE id = $3", + d.Status, d.StatusChangedAt, d.ID) + if err != nil { + return err + } + + // Trigger external notifications + TriggerDevice(ctx, db, d) + } + + return nil +} + +// UpdateDevicesForStatus broadcasts and updates all devices watching a status +func UpdateDevicesForStatus(ctx context.Context, db *DB, hub *Hub, username, projectName string) { + // Broadcast to global and per-username colors channels + allColors, err := GetColors(ctx, db, nil) + if err == nil { + hub.Broadcast("colors:*", map[string]interface{}{"colors": allColors}) + } + userColors, err := GetColors(ctx, db, []string{username}) + if err == nil { + hub.Broadcast("colors:"+username, map[string]interface{}{"colors": userColors}) + } + + // Update watching devices + devices, err := FindDevicesForStatus(ctx, db, username, projectName) + if err != nil { + return + } + for i := range devices { + UpdateDeviceStatus(ctx, db, hub, &devices[i]) + } +} + +// TriggerDevice sends webhook and particle notifications +func TriggerDevice(ctx context.Context, db *DB, d *Device) { + if d.WebhookURL != "" { + colors, err := GetDeviceColors(ctx, db, d) + if err == nil { + go TriggerWebhook(d, colors) + } + } + if d.Identifier != "" { + go TriggerParticle(d) + } +} + +func boolPtr(b bool) *bool { + return &b +} + +func strPtr(s string) *string { + return &s +} diff --git a/internal/app/models_test.go b/internal/app/models_test.go new file mode 100644 index 0000000..95e295a --- /dev/null +++ b/internal/app/models_test.go @@ -0,0 +1,336 @@ +package app + +import ( + "testing" +) + +func TestGetColors(t *testing.T) { + db, ctx := setupTestDB(t) + + t.Run("returns green when no statuses", func(t *testing.T) { + truncate(t, db, ctx) + colors, err := GetColors(ctx, db, nil) + if err != nil { + t.Fatalf("GetColors error: %v", err) + } + if colors.Red != 0 { + t.Errorf("expected red=0, got %d", colors.Red) + } + if colors.Yellow != false { + t.Errorf("expected yellow=false, got %v", colors.Yellow) + } + if colors.Green != true { + t.Errorf("expected green=true, got %v", colors.Green) + } + }) + + t.Run("counts red statuses", func(t *testing.T) { + truncate(t, db, ctx) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "alice"}) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "bob"}) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(false), Username: "carol"}) + + colors, err := GetColors(ctx, db, nil) + if err != nil { + t.Fatalf("GetColors error: %v", err) + } + if colors.Red != 2 { + t.Errorf("expected red=2, got %d", colors.Red) + } + if colors.Green != false { + t.Errorf("expected green=false, got %v", colors.Green) + } + }) + + t.Run("detects yellow", func(t *testing.T) { + truncate(t, db, ctx) + createStatus(t, db, ctx, statusOpts{Yellow: boolPtr(true)}) + + colors, err := GetColors(ctx, db, nil) + if err != nil { + t.Fatalf("GetColors error: %v", err) + } + if colors.Yellow != true { + t.Errorf("expected yellow=true, got %v", colors.Yellow) + } + }) + + t.Run("filters by username", func(t *testing.T) { + truncate(t, db, ctx) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "alice"}) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "bob"}) + + colors, err := GetColors(ctx, db, []string{"alice"}) + if err != nil { + t.Fatalf("GetColors error: %v", err) + } + if colors.Red != 1 { + t.Errorf("expected red=1, got %d", colors.Red) + } + }) + + t.Run("returns green when filtered user has no red", func(t *testing.T) { + truncate(t, db, ctx) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "alice"}) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(false), Username: "bob"}) + + colors, err := GetColors(ctx, db, []string{"bob"}) + if err != nil { + t.Fatalf("GetColors error: %v", err) + } + if colors.Red != 0 { + t.Errorf("expected red=0, got %d", colors.Red) + } + if colors.Green != true { + t.Errorf("expected green=true, got %v", colors.Green) + } + }) +} + +func TestColorsAsBooleans(t *testing.T) { + t.Run("converts red count to boolean", func(t *testing.T) { + c := Colors{Red: 3, Yellow: true, Green: false} + b := c.AsBooleans() + if b.Red != true { + t.Errorf("expected red=true, got %v", b.Red) + } + if b.Yellow != true { + t.Errorf("expected yellow=true, got %v", b.Yellow) + } + if b.Green != false { + t.Errorf("expected green=false, got %v", b.Green) + } + }) + + t.Run("zero red converts to false", func(t *testing.T) { + c := Colors{Red: 0, Yellow: false, Green: true} + b := c.AsBooleans() + if b.Red != false { + t.Errorf("expected red=false, got %v", b.Red) + } + if b.Green != true { + t.Errorf("expected green=true, got %v", b.Green) + } + }) +} + +func TestColorsRYG(t *testing.T) { + tests := []struct { + name string + colors Colors + want string + }{ + {"all off", Colors{Red: 0, Yellow: false, Green: false}, "ryg"}, + {"all on", Colors{Red: 1, Yellow: true, Green: true}, "RYG"}, + {"red only", Colors{Red: 2, Yellow: false, Green: false}, "Ryg"}, + {"green only", Colors{Red: 0, Yellow: false, Green: true}, "ryG"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.colors.RYG() + if got != tt.want { + t.Errorf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestStatusName(t *testing.T) { + s := &Status{Username: "collectiveidea", ProjectName: "buildlight"} + if got := s.Name(); got != "collectiveidea/buildlight" { + t.Errorf("expected collectiveidea/buildlight, got %s", got) + } +} + +func TestFindDevicesForStatus(t *testing.T) { + db, ctx := setupTestDB(t) + + t.Run("finds devices watching by username", func(t *testing.T) { + truncate(t, db, ctx) + createDevice(t, db, ctx, deviceOpts{Usernames: []string{"alice"}}) + createDevice(t, db, ctx, deviceOpts{Usernames: []string{"bob"}}) + + devices, err := FindDevicesForStatus(ctx, db, "alice", "repo") + if err != nil { + t.Fatalf("FindDevicesForStatus error: %v", err) + } + if len(devices) != 1 { + t.Errorf("expected 1 device, got %d", len(devices)) + } + }) + + t.Run("finds devices watching by project", func(t *testing.T) { + truncate(t, db, ctx) + createDevice(t, db, ctx, deviceOpts{Projects: []string{"alice/repo"}}) + createDevice(t, db, ctx, deviceOpts{Projects: []string{"bob/other"}}) + + devices, err := FindDevicesForStatus(ctx, db, "alice", "repo") + if err != nil { + t.Fatalf("FindDevicesForStatus error: %v", err) + } + if len(devices) != 1 { + t.Errorf("expected 1 device, got %d", len(devices)) + } + }) + + t.Run("finds devices watching by username or project", func(t *testing.T) { + truncate(t, db, ctx) + createDevice(t, db, ctx, deviceOpts{Usernames: []string{"alice"}}) + createDevice(t, db, ctx, deviceOpts{Projects: []string{"alice/repo"}}) + createDevice(t, db, ctx, deviceOpts{Usernames: []string{"bob"}}) + + devices, err := FindDevicesForStatus(ctx, db, "alice", "repo") + if err != nil { + t.Fatalf("FindDevicesForStatus error: %v", err) + } + if len(devices) != 2 { + t.Errorf("expected 2 devices, got %d", len(devices)) + } + }) +} + +func TestGetDeviceColors(t *testing.T) { + db, ctx := setupTestDB(t) + + t.Run("returns colors for device statuses by username", func(t *testing.T) { + truncate(t, db, ctx) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "alice"}) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(false), Username: "bob"}) + + d := createDevice(t, db, ctx, deviceOpts{Usernames: []string{"alice"}}) + colors, err := GetDeviceColors(ctx, db, d) + if err != nil { + t.Fatalf("GetDeviceColors error: %v", err) + } + if colors.Red != 1 { + t.Errorf("expected red=1, got %d", colors.Red) + } + }) + + t.Run("returns colors for device statuses by project", func(t *testing.T) { + truncate(t, db, ctx) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "alice", ProjectName: "repo1"}) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "alice", ProjectName: "repo2"}) + + d := createDevice(t, db, ctx, deviceOpts{Projects: []string{"alice/repo1"}}) + colors, err := GetDeviceColors(ctx, db, d) + if err != nil { + t.Fatalf("GetDeviceColors error: %v", err) + } + if colors.Red != 1 { + t.Errorf("expected red=1, got %d", colors.Red) + } + }) +} + +func TestCurrentStatus(t *testing.T) { + db, ctx := setupTestDB(t) + + t.Run("returns passing when no failures", func(t *testing.T) { + truncate(t, db, ctx) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(false), Username: "alice"}) + + status, err := CurrentStatus(ctx, db, []string{"alice"}, nil) + if err != nil { + t.Fatalf("CurrentStatus error: %v", err) + } + if status != "passing" { + t.Errorf("expected passing, got %s", status) + } + }) + + t.Run("returns failing when red", func(t *testing.T) { + truncate(t, db, ctx) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Username: "alice"}) + + status, err := CurrentStatus(ctx, db, []string{"alice"}, nil) + if err != nil { + t.Fatalf("CurrentStatus error: %v", err) + } + if status != "failing" { + t.Errorf("expected failing, got %s", status) + } + }) + + t.Run("returns failing-building when red and yellow", func(t *testing.T) { + truncate(t, db, ctx) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(true), Yellow: boolPtr(true), Username: "alice"}) + + status, err := CurrentStatus(ctx, db, []string{"alice"}, nil) + if err != nil { + t.Fatalf("CurrentStatus error: %v", err) + } + if status != "failing-building" { + t.Errorf("expected failing-building, got %s", status) + } + }) + + t.Run("returns passing-building when yellow only", func(t *testing.T) { + truncate(t, db, ctx) + createStatus(t, db, ctx, statusOpts{Red: boolPtr(false), Yellow: boolPtr(true), Username: "alice"}) + + status, err := CurrentStatus(ctx, db, []string{"alice"}, nil) + if err != nil { + t.Fatalf("CurrentStatus error: %v", err) + } + if status != "passing-building" { + t.Errorf("expected passing-building, got %s", status) + } + }) +} + +func TestUpsertStatus(t *testing.T) { + db, ctx := setupTestDB(t) + + t.Run("inserts new status", func(t *testing.T) { + truncate(t, db, ctx) + s := &Status{ + Service: "github", + Username: "alice", + ProjectName: "repo", + Red: boolPtr(false), + Yellow: boolPtr(false), + } + if err := UpsertStatus(ctx, db, s); err != nil { + t.Fatalf("UpsertStatus error: %v", err) + } + if s.ID == 0 { + t.Error("expected status ID to be set") + } + if count := statusCount(t, db, ctx); count != 1 { + t.Errorf("expected 1 status, got %d", count) + } + }) + + t.Run("updates existing status", func(t *testing.T) { + truncate(t, db, ctx) + s := &Status{ + Service: "github", + Username: "alice", + ProjectName: "repo", + Red: boolPtr(true), + Yellow: boolPtr(false), + } + UpsertStatus(ctx, db, s) + + s2 := &Status{ + Service: "github", + Username: "alice", + ProjectName: "repo", + Red: boolPtr(false), + Yellow: boolPtr(false), + } + if err := UpsertStatus(ctx, db, s2); err != nil { + t.Fatalf("UpsertStatus error: %v", err) + } + + if count := statusCount(t, db, ctx); count != 1 { + t.Errorf("expected 1 status after upsert, got %d", count) + } + + loaded := loadStatus(t, db, ctx, s2.ID) + if *loaded.Red != false { + t.Errorf("expected red=false after upsert, got %v", *loaded.Red) + } + }) +} diff --git a/internal/app/parsers.go b/internal/app/parsers.go new file mode 100644 index 0000000..74b21bb --- /dev/null +++ b/internal/app/parsers.go @@ -0,0 +1,153 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// ParseGithub parses a GitHub Actions webhook payload +func ParseGithub(ctx context.Context, db *DB, hub *Hub, payload map[string]interface{}) error { + repo, _ := payload["repository"].(string) + parts := strings.SplitN(repo, "/", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid repository: %s", repo) + } + username := parts[0] + projectName := parts[1] + workflow, _ := payload["workflow"].(string) + statusCode, _ := payload["status"].(string) + + s := &Status{ + Service: "github", + Username: username, + ProjectName: projectName, + Workflow: workflow, + } + + if DebugMode { + raw, _ := json.Marshal(payload) + s.Payload = strPtr(string(raw)) + } + + // Set colors based on status + s.Yellow = boolPtr(false) + switch statusCode { + case "": + s.Yellow = boolPtr(true) + case "success": + s.Red = boolPtr(false) + case "failure": + s.Red = boolPtr(true) + default: + return fmt.Errorf("unknown status: %s", statusCode) + } + + if err := UpsertStatus(ctx, db, s); err != nil { + return err + } + + go UpdateDevicesForStatus(ctx, db, hub, username, projectName) + return nil +} + +// ParseTravis parses a Travis CI webhook payload +func ParseTravis(ctx context.Context, db *DB, hub *Hub, payloadStr string) error { + var payload map[string]interface{} + if err := json.Unmarshal([]byte(payloadStr), &payload); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + + // Ignore pull requests + if typ, _ := payload["type"].(string); typ == "pull_request" { + return nil + } + + repo, _ := payload["repository"].(map[string]interface{}) + if repo == nil { + return fmt.Errorf("missing repository") + } + + repoID := fmt.Sprintf("%v", repo["id"]) + ownerName, _ := repo["owner_name"].(string) + repoName, _ := repo["name"].(string) + statusMessage, _ := payload["status_message"].(string) + + s := &Status{ + Service: "travis", + ProjectID: repoID, + Username: ownerName, + ProjectName: repoName, + } + + if DebugMode { + s.Payload = &payloadStr + } + + // Set colors based on status message + s.Yellow = boolPtr(false) + switch statusMessage { + case "Pending": + s.Yellow = boolPtr(true) + case "Passed", "Fixed": + s.Red = boolPtr(false) + default: + s.Red = boolPtr(true) + } + + if err := UpsertStatus(ctx, db, s); err != nil { + return err + } + + go UpdateDevicesForStatus(ctx, db, hub, ownerName, repoName) + return nil +} + +// ParseCircle parses a Circle CI webhook payload +func ParseCircle(ctx context.Context, db *DB, hub *Hub, payload map[string]interface{}) error { + // Only handle workflow-completed events + typ, _ := payload["type"].(string) + if typ != "workflow-completed" { + return nil + } + + // Only process main/master branches + pipeline, _ := payload["pipeline"].(map[string]interface{}) + vcs, _ := pipeline["vcs"].(map[string]interface{}) + branch, _ := vcs["branch"].(string) + if branch != "main" && branch != "master" { + return nil + } + + org, _ := payload["organization"].(map[string]interface{}) + orgName, _ := org["name"].(string) + + project, _ := payload["project"].(map[string]interface{}) + projectName, _ := project["name"].(string) + + workflow, _ := payload["workflow"].(map[string]interface{}) + workflowStatus, _ := workflow["status"].(string) + + s := &Status{ + Service: "circle", + Username: orgName, + ProjectName: projectName, + } + + if DebugMode { + raw, _ := json.Marshal(payload) + s.Payload = strPtr(string(raw)) + } + + // Set colors - no yellow support for Circle + s.Yellow = boolPtr(false) + s.Red = boolPtr(workflowStatus != "success") + + if err := UpsertStatus(ctx, db, s); err != nil { + return err + } + + go UpdateDevicesForStatus(ctx, db, hub, orgName, projectName) + return nil +} diff --git a/internal/app/parsers_test.go b/internal/app/parsers_test.go new file mode 100644 index 0000000..5a9f0e9 --- /dev/null +++ b/internal/app/parsers_test.go @@ -0,0 +1,308 @@ +package app + +import ( + "encoding/json" + "testing" +) + +func TestParseCircle(t *testing.T) { + db, ctx := setupTestDB(t) + hub := NewHub() + + t.Run("sets colors on success", func(t *testing.T) { + truncate(t, db, ctx) + body := loadFixture(t, "circle.json") + var payload map[string]interface{} + if err := json.Unmarshal([]byte(body), &payload); err != nil { + t.Fatalf("Failed to unmarshal fixture: %v", err) + } + + if err := ParseCircle(ctx, db, hub, payload); err != nil { + t.Fatalf("ParseCircle error: %v", err) + } + + s := loadLatestStatus(t, db, ctx) + if s.Service != "circle" { + t.Errorf("expected service circle, got %s", s.Service) + } + if s.Username != "collectiveidea" { + t.Errorf("expected username collectiveidea, got %s", s.Username) + } + if s.ProjectName != "buildlight" { + t.Errorf("expected project_name buildlight, got %s", s.ProjectName) + } + if *s.Red != false { + t.Errorf("expected red=false, got %v", *s.Red) + } + if *s.Yellow != false { + t.Errorf("expected yellow=false, got %v", *s.Yellow) + } + }) + + t.Run("sets colors on failure", func(t *testing.T) { + truncate(t, db, ctx) + body := loadFixture(t, "circle.json") + var payload map[string]interface{} + json.Unmarshal([]byte(body), &payload) + + // Change workflow status to failed + workflow := payload["workflow"].(map[string]interface{}) + workflow["status"] = "failed" + + if err := ParseCircle(ctx, db, hub, payload); err != nil { + t.Fatalf("ParseCircle error: %v", err) + } + + s := loadLatestStatus(t, db, ctx) + if *s.Red != true { + t.Errorf("expected red=true, got %v", *s.Red) + } + if *s.Yellow != false { + t.Errorf("expected yellow=false, got %v", *s.Yellow) + } + }) + + t.Run("ignores non-main branches", func(t *testing.T) { + truncate(t, db, ctx) + body := loadFixture(t, "circle_pr.json") + var payload map[string]interface{} + json.Unmarshal([]byte(body), &payload) + + if err := ParseCircle(ctx, db, hub, payload); err != nil { + t.Fatalf("ParseCircle error: %v", err) + } + + if count := statusCount(t, db, ctx); count != 0 { + t.Errorf("expected 0 statuses, got %d", count) + } + }) + + t.Run("ignores non-workflow-completed events", func(t *testing.T) { + truncate(t, db, ctx) + payload := map[string]interface{}{ + "type": "job-completed", + } + + if err := ParseCircle(ctx, db, hub, payload); err != nil { + t.Fatalf("ParseCircle error: %v", err) + } + + if count := statusCount(t, db, ctx); count != 0 { + t.Errorf("expected 0 statuses, got %d", count) + } + }) +} + +func TestParseGithub(t *testing.T) { + db, ctx := setupTestDB(t) + hub := NewHub() + + t.Run("creates a status", func(t *testing.T) { + truncate(t, db, ctx) + body := loadFixture(t, "github.json") + var payload map[string]interface{} + json.Unmarshal([]byte(body), &payload) + + if err := ParseGithub(ctx, db, hub, payload); err != nil { + t.Fatalf("ParseGithub error: %v", err) + } + + if count := statusCount(t, db, ctx); count != 1 { + t.Errorf("expected 1 status, got %d", count) + } + + s := loadLatestStatus(t, db, ctx) + if s.Service != "github" { + t.Errorf("expected service github, got %s", s.Service) + } + if s.Username != "collectiveidea" { + t.Errorf("expected username collectiveidea, got %s", s.Username) + } + if s.ProjectName != "buildlight" { + t.Errorf("expected project_name buildlight, got %s", s.ProjectName) + } + if s.Workflow != "CI" { + t.Errorf("expected workflow CI, got %s", s.Workflow) + } + }) + + t.Run("sets green on success", func(t *testing.T) { + truncate(t, db, ctx) + payload := map[string]interface{}{ + "repository": "owner/repo", + "status": "success", + "workflow": "CI", + } + + if err := ParseGithub(ctx, db, hub, payload); err != nil { + t.Fatalf("ParseGithub error: %v", err) + } + + s := loadLatestStatus(t, db, ctx) + if *s.Red != false { + t.Errorf("expected red=false, got %v", *s.Red) + } + if *s.Yellow != false { + t.Errorf("expected yellow=false, got %v", *s.Yellow) + } + }) + + t.Run("sets red on failure", func(t *testing.T) { + truncate(t, db, ctx) + payload := map[string]interface{}{ + "repository": "owner/repo", + "status": "failure", + "workflow": "CI", + } + + if err := ParseGithub(ctx, db, hub, payload); err != nil { + t.Fatalf("ParseGithub error: %v", err) + } + + s := loadLatestStatus(t, db, ctx) + if *s.Red != true { + t.Errorf("expected red=true, got %v", *s.Red) + } + if *s.Yellow != false { + t.Errorf("expected yellow=false, got %v", *s.Yellow) + } + }) + + t.Run("sets yellow on empty status", func(t *testing.T) { + truncate(t, db, ctx) + payload := map[string]interface{}{ + "repository": "owner/repo", + "status": "", + "workflow": "CI", + } + + if err := ParseGithub(ctx, db, hub, payload); err != nil { + t.Fatalf("ParseGithub error: %v", err) + } + + s := loadLatestStatus(t, db, ctx) + if *s.Yellow != true { + t.Errorf("expected yellow=true, got %v", *s.Yellow) + } + }) + + t.Run("upserts existing status", func(t *testing.T) { + truncate(t, db, ctx) + payload := map[string]interface{}{ + "repository": "owner/repo", + "status": "failure", + "workflow": "CI", + } + + ParseGithub(ctx, db, hub, payload) + if count := statusCount(t, db, ctx); count != 1 { + t.Fatalf("expected 1 status, got %d", count) + } + + payload["status"] = "success" + ParseGithub(ctx, db, hub, payload) + + if count := statusCount(t, db, ctx); count != 1 { + t.Errorf("expected 1 status after upsert, got %d", count) + } + + s := loadLatestStatus(t, db, ctx) + if *s.Red != false { + t.Errorf("expected red=false after upsert, got %v", *s.Red) + } + }) +} + +func TestParseTravis(t *testing.T) { + db, ctx := setupTestDB(t) + hub := NewHub() + + t.Run("sets green on Passed", func(t *testing.T) { + truncate(t, db, ctx) + body := loadFixture(t, "travis.json") + if err := ParseTravis(ctx, db, hub, body); err != nil { + t.Fatalf("ParseTravis error: %v", err) + } + + s := loadLatestStatus(t, db, ctx) + if s.Service != "travis" { + t.Errorf("expected service travis, got %s", s.Service) + } + if s.Username != "collectiveidea" { + t.Errorf("expected username collectiveidea, got %s", s.Username) + } + if s.ProjectName != "buildlight" { + t.Errorf("expected project_name buildlight, got %s", s.ProjectName) + } + if *s.Red != false { + t.Errorf("expected red=false, got %v", *s.Red) + } + if *s.Yellow != false { + t.Errorf("expected yellow=false, got %v", *s.Yellow) + } + }) + + t.Run("sets green on Fixed", func(t *testing.T) { + truncate(t, db, ctx) + payload := `{"status_message":"Fixed","repository":{"id":1,"name":"repo","owner_name":"owner"},"type":"push"}` + if err := ParseTravis(ctx, db, hub, payload); err != nil { + t.Fatalf("ParseTravis error: %v", err) + } + + s := loadLatestStatus(t, db, ctx) + if *s.Red != false { + t.Errorf("expected red=false, got %v", *s.Red) + } + }) + + t.Run("sets yellow on Pending", func(t *testing.T) { + truncate(t, db, ctx) + payload := `{"status_message":"Pending","repository":{"id":1,"name":"repo","owner_name":"owner"},"type":"push"}` + if err := ParseTravis(ctx, db, hub, payload); err != nil { + t.Fatalf("ParseTravis error: %v", err) + } + + s := loadLatestStatus(t, db, ctx) + if *s.Yellow != true { + t.Errorf("expected yellow=true, got %v", *s.Yellow) + } + }) + + t.Run("sets red on Failed", func(t *testing.T) { + truncate(t, db, ctx) + payload := `{"status_message":"Failed","repository":{"id":1,"name":"repo","owner_name":"owner"},"type":"push"}` + if err := ParseTravis(ctx, db, hub, payload); err != nil { + t.Fatalf("ParseTravis error: %v", err) + } + + s := loadLatestStatus(t, db, ctx) + if *s.Red != true { + t.Errorf("expected red=true, got %v", *s.Red) + } + }) + + t.Run("sets red on Broken", func(t *testing.T) { + truncate(t, db, ctx) + payload := `{"status_message":"Broken","repository":{"id":1,"name":"repo","owner_name":"owner"},"type":"push"}` + if err := ParseTravis(ctx, db, hub, payload); err != nil { + t.Fatalf("ParseTravis error: %v", err) + } + + s := loadLatestStatus(t, db, ctx) + if *s.Red != true { + t.Errorf("expected red=true, got %v", *s.Red) + } + }) + + t.Run("ignores pull requests", func(t *testing.T) { + truncate(t, db, ctx) + payload := `{"status_message":"Passed","type":"pull_request","repository":{"id":1,"name":"repo","owner_name":"owner"}}` + if err := ParseTravis(ctx, db, hub, payload); err != nil { + t.Fatalf("ParseTravis error: %v", err) + } + + if count := statusCount(t, db, ctx); count != 0 { + t.Errorf("expected 0 statuses for pull_request, got %d", count) + } + }) +} diff --git a/internal/app/reload.go b/internal/app/reload.go new file mode 100644 index 0000000..29ffdc2 --- /dev/null +++ b/internal/app/reload.go @@ -0,0 +1,19 @@ +//go:build !production + +package app + +import ( + "html/template" + "log" + + "buildlight" +) + +func ReloadTemplates() { + t, err := template.New("").Funcs(TemplateFuncs).ParseFS(buildlight.TemplateDir(), "templates/*.html") + if err != nil { + log.Printf("Template reload error: %v", err) + return + } + Templates = t +} diff --git a/internal/app/reload_prod.go b/internal/app/reload_prod.go new file mode 100644 index 0000000..ebbccac --- /dev/null +++ b/internal/app/reload_prod.go @@ -0,0 +1,5 @@ +//go:build production + +package app + +func ReloadTemplates() {} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..bd6a0c9 --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,114 @@ +package app + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "time" + + "buildlight" +) + +var ( + Templates *template.Template + TemplateFuncs = template.FuncMap{ + "pluralize": func(count int, singular, plural string) string { + if count == 1 { + return fmt.Sprintf("%d %s", count, singular) + } + return fmt.Sprintf("%d %s", count, plural) + }, + } + + AppHost string + DebugMode bool + ParticleAccessToken string +) + +type Config struct { + DatabaseURL string + Port string + Host string + Debug bool + ParticleAccessToken string +} + +// ListenAndServe starts the buildlight server. It blocks until the context is +// cancelled, then gracefully shuts down. +func ListenAndServe(ctx context.Context, cfg Config) error { + // Parse templates + var err error + Templates, err = template.New("").Funcs(TemplateFuncs).ParseFS(buildlight.TemplateDir(), "templates/*.html") + if err != nil { + return fmt.Errorf("parsing templates: %w", err) + } + + // Connect to database + db, err := NewDB(ctx, cfg.DatabaseURL) + if err != nil { + return fmt.Errorf("connecting to database: %w", err) + } + defer db.Close() + + // Run migrations + if err := db.Migrate(ctx, buildlight.MigrationsFS); err != nil { + return fmt.Errorf("running migrations: %w", err) + } + + // Set globals + AppHost = cfg.Host + DebugMode = cfg.Debug + ParticleAccessToken = cfg.ParticleAccessToken + + // Create WebSocket hub + hub := NewHub() + go hub.Run() + + // Create handler + h := &Handler{db: db, hub: hub} + + // Set up routes + mux := http.NewServeMux() + + mux.HandleFunc("GET /up", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "OK") + }) + + mux.Handle("GET /public/", buildlight.StaticHandler()) + + mux.HandleFunc("GET /ws", h.HandleWebSocket) + + mux.HandleFunc("GET /api/devices/{id}", h.APIDeviceShow) + mux.HandleFunc("POST /api/device/trigger", h.APIDeviceTrigger) + mux.HandleFunc("GET /api/device/{id}/red", h.APIRedShow) + + mux.HandleFunc("GET /devices/{id}", h.DeviceShow) + + mux.HandleFunc("POST /", h.WebhookCreate) + + // Colors - must be last since it catches /{id} + mux.HandleFunc("GET /{id}", h.ColorsShow) + mux.HandleFunc("GET /{$}", h.ColorsIndex) + + server := &http.Server{ + Addr: ":" + cfg.Port, + Handler: mux, + } + + go func() { + log.Printf("Listening on :%s", cfg.Port) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server error: %v", err) + } + }() + + <-ctx.Done() + log.Println("Shutting down...") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + return server.Shutdown(shutdownCtx) +} diff --git a/spec/fixtures/circle.json b/internal/app/testdata/circle.json similarity index 100% rename from spec/fixtures/circle.json rename to internal/app/testdata/circle.json diff --git a/spec/fixtures/circle_pr.json b/internal/app/testdata/circle_pr.json similarity index 100% rename from spec/fixtures/circle_pr.json rename to internal/app/testdata/circle_pr.json diff --git a/spec/fixtures/github.json b/internal/app/testdata/github.json similarity index 100% rename from spec/fixtures/github.json rename to internal/app/testdata/github.json diff --git a/spec/fixtures/travis.json b/internal/app/testdata/travis.json similarity index 100% rename from spec/fixtures/travis.json rename to internal/app/testdata/travis.json diff --git a/internal/app/triggers.go b/internal/app/triggers.go new file mode 100644 index 0000000..ae41bee --- /dev/null +++ b/internal/app/triggers.go @@ -0,0 +1,67 @@ +package app + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" +) + +// TriggerWebhook sends a POST to the device's webhook URL +func TriggerWebhook(d *Device, colors Colors) { + body, err := json.Marshal(map[string]interface{}{ + "colors": colors.AsBooleans(), + }) + if err != nil { + log.Printf("TriggerWebhook marshal error: %v", err) + return + } + + req, err := http.NewRequest("POST", d.WebhookURL, bytes.NewReader(body)) + if err != nil { + log.Printf("TriggerWebhook request error: %v", err) + return + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-ryg", colors.RYG()) + req.Header.Set("x-device-url", fmt.Sprintf("https://%s/api/devices/%s", AppHost, d.ID)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Printf("TriggerWebhook error for device %s: %v", d.Name, err) + return + } + resp.Body.Close() +} + +// TriggerParticle publishes a build_state event to Particle +func TriggerParticle(d *Device) { + if ParticleAccessToken == "" { + return + } + + body, _ := json.Marshal(map[string]interface{}{ + "name": "build_state", + "data": d.Status, + "ttl": 3600, + "private": false, + }) + + req, err := http.NewRequest("POST", "https://api.particle.io/v1/devices/events", bytes.NewReader(body)) + if err != nil { + log.Printf("TriggerParticle request error: %v", err) + return + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+ParticleAccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Printf("TriggerParticle error: %v", err) + return + } + resp.Body.Close() +} diff --git a/internal/app/triggers_test.go b/internal/app/triggers_test.go new file mode 100644 index 0000000..f44b307 --- /dev/null +++ b/internal/app/triggers_test.go @@ -0,0 +1,64 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestTriggerWebhook(t *testing.T) { + t.Run("sends correct body and headers", func(t *testing.T) { + var gotBody []byte + var gotHeaders http.Header + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + gotHeaders = r.Header + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + AppHost = "buildlight.example.com" + + d := &Device{ + ID: "test-device-id", + Name: "Test Device", + WebhookURL: server.URL, + } + colors := Colors{Red: 1, Yellow: false, Green: false} + + TriggerWebhook(d, colors) + + // Check body + var body map[string]interface{} + if err := json.Unmarshal(gotBody, &body); err != nil { + t.Fatalf("Failed to parse body: %v", err) + } + colorsMap, ok := body["colors"].(map[string]interface{}) + if !ok { + t.Fatal("expected colors in body") + } + if colorsMap["red"] != true { + t.Errorf("expected body colors.red=true, got %v", colorsMap["red"]) + } + if colorsMap["yellow"] != false { + t.Errorf("expected body colors.yellow=false, got %v", colorsMap["yellow"]) + } + if colorsMap["green"] != false { + t.Errorf("expected body colors.green=false, got %v", colorsMap["green"]) + } + + // Check headers + if got := gotHeaders.Get("Content-Type"); got != "application/json" { + t.Errorf("expected Content-Type application/json, got %s", got) + } + if got := gotHeaders.Get("x-ryg"); got != "Ryg" { + t.Errorf("expected x-ryg Ryg, got %s", got) + } + if got := gotHeaders.Get("x-device-url"); got != "https://buildlight.example.com/api/devices/test-device-id" { + t.Errorf("expected x-device-url with device id, got %s", got) + } + }) +} diff --git a/internal/app/websocket.go b/internal/app/websocket.go new file mode 100644 index 0000000..31fa2b1 --- /dev/null +++ b/internal/app/websocket.go @@ -0,0 +1,146 @@ +package app + +import ( + "context" + "encoding/json" + "log" + "net/http" + "sync" + "time" + + "github.com/coder/websocket" +) + +// Hub manages WebSocket subscriptions and broadcasting +type Hub struct { + mu sync.RWMutex + clients map[*Client]struct{} +} + +type Client struct { + hub *Hub + conn *websocket.Conn + subscriptions map[string]bool + mu sync.Mutex + send chan []byte +} + +func NewHub() *Hub { + return &Hub{ + clients: make(map[*Client]struct{}), + } +} + +func (h *Hub) Run() { + // Hub is passive - broadcasting is done directly +} + +func (h *Hub) Register(c *Client) { + h.mu.Lock() + h.clients[c] = struct{}{} + h.mu.Unlock() +} + +func (h *Hub) Unregister(c *Client) { + h.mu.Lock() + delete(h.clients, c) + h.mu.Unlock() +} + +// Broadcast sends a message to all clients subscribed to the given channel +func (h *Hub) Broadcast(channel string, data interface{}) { + msg, err := json.Marshal(map[string]interface{}{ + "channel": channel, + "data": data, + }) + if err != nil { + return + } + + h.mu.RLock() + defer h.mu.RUnlock() + + for c := range h.clients { + c.mu.Lock() + subscribed := c.subscriptions[channel] + c.mu.Unlock() + if subscribed { + select { + case c.send <- msg: + default: + // Client too slow, skip + } + } + } +} + +func HandleWebSocketConnection(hub *Hub, w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, // Allow any origin + }) + if err != nil { + log.Printf("WebSocket accept error: %v", err) + return + } + + client := &Client{ + hub: hub, + conn: conn, + subscriptions: make(map[string]bool), + send: make(chan []byte, 256), + } + + hub.Register(client) + defer hub.Unregister(client) + + // Writer goroutine + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + go func() { + defer cancel() + for { + select { + case msg, ok := <-client.send: + if !ok { + return + } + writeCtx, writeCancel := context.WithTimeout(ctx, 5*time.Second) + err := conn.Write(writeCtx, websocket.MessageText, msg) + writeCancel() + if err != nil { + return + } + case <-ctx.Done(): + return + } + } + }() + + // Reader loop - handle subscribe/unsubscribe messages + for { + _, msg, err := conn.Read(ctx) + if err != nil { + break + } + + var cmd struct { + Subscribe string `json:"subscribe"` + Unsubscribe string `json:"unsubscribe"` + } + if err := json.Unmarshal(msg, &cmd); err != nil { + continue + } + + client.mu.Lock() + if cmd.Subscribe != "" { + client.subscriptions[cmd.Subscribe] = true + } + if cmd.Unsubscribe != "" { + delete(client.subscriptions, cmd.Unsubscribe) + } + client.mu.Unlock() + } + + close(client.send) +} 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/migrations/20121123160543_create_statuses.sql b/migrations/20121123160543_create_statuses.sql new file mode 100644 index 0000000..3f167cb --- /dev/null +++ b/migrations/20121123160543_create_statuses.sql @@ -0,0 +1,14 @@ +CREATE TABLE statuses ( + id SERIAL PRIMARY KEY, + project_id VARCHAR, + project_name VARCHAR, + status VARCHAR, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +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_status ON statuses (status); +CREATE INDEX index_statuses_on_project_id_and_status ON statuses (project_id, status); +CREATE INDEX index_statuses_on_project_id_and_status_and_created_at ON statuses (project_id, status, created_at); diff --git a/migrations/20121123172057_add_payload_to_statuses.sql b/migrations/20121123172057_add_payload_to_statuses.sql new file mode 100644 index 0000000..62a9adf --- /dev/null +++ b/migrations/20121123172057_add_payload_to_statuses.sql @@ -0,0 +1 @@ +ALTER TABLE statuses ADD COLUMN payload TEXT; diff --git a/migrations/20121123182506_rename_status_to_color_on_statuses.sql b/migrations/20121123182506_rename_status_to_color_on_statuses.sql new file mode 100644 index 0000000..92738ee --- /dev/null +++ b/migrations/20121123182506_rename_status_to_color_on_statuses.sql @@ -0,0 +1,9 @@ +DROP INDEX index_statuses_on_status; +DROP INDEX index_statuses_on_project_id_and_status; +DROP INDEX index_statuses_on_project_id_and_status_and_created_at; + +ALTER TABLE statuses RENAME COLUMN status TO color; + +CREATE INDEX index_statuses_on_color ON statuses (color); +CREATE INDEX index_statuses_on_project_id_and_color ON statuses (project_id, color); +CREATE INDEX index_statuses_on_project_id_and_color_and_created_at ON statuses (project_id, color, created_at); diff --git a/migrations/20121123195427_split_colors_on_statuses.sql b/migrations/20121123195427_split_colors_on_statuses.sql new file mode 100644 index 0000000..995c4bc --- /dev/null +++ b/migrations/20121123195427_split_colors_on_statuses.sql @@ -0,0 +1,10 @@ +DROP INDEX index_statuses_on_color; +DROP INDEX index_statuses_on_project_id_and_color; +DROP INDEX index_statuses_on_project_id_and_color_and_created_at; + +ALTER TABLE statuses ADD COLUMN red BOOLEAN; +ALTER TABLE statuses ADD COLUMN yellow BOOLEAN; +ALTER TABLE statuses DROP COLUMN color; + +CREATE INDEX index_statuses_on_red ON statuses (red); +CREATE INDEX index_statuses_on_yellow ON statuses (yellow); diff --git a/migrations/20121124190606_add_user_to_statuses.sql b/migrations/20121124190606_add_user_to_statuses.sql new file mode 100644 index 0000000..7dd8451 --- /dev/null +++ b/migrations/20121124190606_add_user_to_statuses.sql @@ -0,0 +1,6 @@ +ALTER TABLE statuses ADD COLUMN username VARCHAR; + +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); diff --git a/migrations/20160510201736_create_devices.sql b/migrations/20160510201736_create_devices.sql new file mode 100644 index 0000000..2d58d24 --- /dev/null +++ b/migrations/20160510201736_create_devices.sql @@ -0,0 +1,9 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +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 +); diff --git a/migrations/20160510212722_add_identifier_to_devices.sql b/migrations/20160510212722_add_identifier_to_devices.sql new file mode 100644 index 0000000..90c81e6 --- /dev/null +++ b/migrations/20160510212722_add_identifier_to_devices.sql @@ -0,0 +1,2 @@ +ALTER TABLE devices ADD COLUMN identifier VARCHAR NOT NULL; +CREATE UNIQUE INDEX index_devices_on_identifier ON devices (identifier); diff --git a/migrations/20160510213407_add_name_to_devices.sql b/migrations/20160510213407_add_name_to_devices.sql new file mode 100644 index 0000000..661ea2a --- /dev/null +++ b/migrations/20160510213407_add_name_to_devices.sql @@ -0,0 +1,2 @@ +ALTER TABLE devices ADD COLUMN name VARCHAR NOT NULL; +CREATE INDEX index_devices_on_name ON devices (name); diff --git a/migrations/20161012193415_add_service_to_status.sql b/migrations/20161012193415_add_service_to_status.sql new file mode 100644 index 0000000..e9c5f45 --- /dev/null +++ b/migrations/20161012193415_add_service_to_status.sql @@ -0,0 +1,3 @@ +ALTER TABLE statuses ADD COLUMN service VARCHAR; +UPDATE statuses SET service = 'travis' WHERE service IS NULL; +ALTER TABLE statuses ALTER COLUMN service SET NOT NULL; diff --git a/migrations/20230303181951_add_webhook_url_to_devices.sql b/migrations/20230303181951_add_webhook_url_to_devices.sql new file mode 100644 index 0000000..2d4edc8 --- /dev/null +++ b/migrations/20230303181951_add_webhook_url_to_devices.sql @@ -0,0 +1 @@ +ALTER TABLE devices ADD COLUMN webhook_url VARCHAR; diff --git a/migrations/20230304135152_add_slug_to_devices.sql b/migrations/20230304135152_add_slug_to_devices.sql new file mode 100644 index 0000000..b1574a1 --- /dev/null +++ b/migrations/20230304135152_add_slug_to_devices.sql @@ -0,0 +1,3 @@ +CREATE EXTENSION IF NOT EXISTS "citext"; +ALTER TABLE devices ADD COLUMN slug CITEXT; +CREATE UNIQUE INDEX index_devices_on_slug ON devices (slug); diff --git a/migrations/20230304144230_make_identifier_nullable_on_devices.sql b/migrations/20230304144230_make_identifier_nullable_on_devices.sql new file mode 100644 index 0000000..90d5232 --- /dev/null +++ b/migrations/20230304144230_make_identifier_nullable_on_devices.sql @@ -0,0 +1 @@ +ALTER TABLE devices ALTER COLUMN identifier DROP NOT NULL; diff --git a/migrations/20230305131208_add_status_to_devices.sql b/migrations/20230305131208_add_status_to_devices.sql new file mode 100644 index 0000000..06fc7b4 --- /dev/null +++ b/migrations/20230305131208_add_status_to_devices.sql @@ -0,0 +1,2 @@ +ALTER TABLE devices ADD COLUMN status VARCHAR; +ALTER TABLE devices ADD COLUMN status_changed_at TIMESTAMP; diff --git a/migrations/20230311115915_add_workflow_to_statues.sql b/migrations/20230311115915_add_workflow_to_statues.sql new file mode 100644 index 0000000..15c28fe --- /dev/null +++ b/migrations/20230311115915_add_workflow_to_statues.sql @@ -0,0 +1 @@ +ALTER TABLE statuses ADD COLUMN workflow VARCHAR; 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/400.html b/public/400.html deleted file mode 100644 index 282dbc8..0000000 --- a/public/400.html +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - - The server cannot process the request due to a client error (400 Bad Request) - - - - - - - - - - - - - -
-
- -
-
-

The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

-
-
- - - - diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html deleted file mode 100644 index 9532a9c..0000000 --- a/public/406-unsupported-browser.html +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - - Your browser is not supported (406 Not Acceptable) - - - - - - - - - - - - - -
-
- -
-
-

Your browser is not supported.
Please upgrade your browser to continue.

-
-
- - - - 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/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/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..15f67a8 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,55 @@ + + + + + + + [i] Buildlight + + + + + +
+
+

+ Hooray! All projects are passing. 🎉 +

+
+
+

Rats. {{if .Colors.Red}}{{pluralize .Colors.Red "project is" "projects are"}}{{end}} failing.

+
+ + +
+ + + + + + diff --git a/templates/red.html b/templates/red.html new file mode 100644 index 0000000..e8649c6 --- /dev/null +++ b/templates/red.html @@ -0,0 +1,18 @@ +
+ {{if .RedProjects}} +

The following projects are failing

+
    + {{range .RedProjects}} +
  • {{.ProjectName}}
  • + {{end}} +
+ {{else}} +
+ 🎉🎉🎉🎉🎉 +
+

You have no failing projects.

+
+ 🎉🎉🎉🎉🎉 +
+ {{end}} +
diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep deleted file mode 100644 index e69de29..0000000