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.
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
+
+
+
+
+
+