fix: resolve all lint offenses + add local CI infrastructure (#9)

* fix: resolve all Rubocop and ERBLint offenses

Rubocop (16 offenses):
- Style/IfUnlessModifier in account_logo_controller
- Lint/RedundantSafeNavigation in templates_documents_controller
- Layout/LineLength in templates_documents_controller, account_config
- Rails/WhereMissing in teams_controller
- Rails/WhereExists in send_submitter_reminder_email_job
- Style/StringLiterals in create_teams migration
- Metrics/* (disabled via inline comments for complex case statements)

ERBLint (10 errors):
- Void element self-closing tags (img /> → img >)
- Layout/ArgumentAlignment in reminder_queue
- Style/StringLiterals + Rails/LinkToBlank in navbar_buttons
- Layout/BlockAlignment in custom_content mailer
- Style/WordArray in role_select

* feat: add local CI via Docker and pre-push lint hook

- Add docker-compose.ci.yml: lint, brakeman, rspec services
- Add Dockerfile.ci: test environment with Ruby, Node, Chromium
- Add bin/lint: quick lint-only check
- Add bin/ci: full CI suite (lint + brakeman + rspec)
- Add .githooks/pre-push: auto-runs linters before push
- Update docker-compose.yml: use ghcr.io image instead of local build

Setup: git config core.hooksPath .githooks
Usage: bin/ci or bin/lint

---------

Co-authored-by: Sebastian Noe <sebastian.schneider@boxine.de>
pull/681/head
Sebastian Noe 1 month ago committed by GitHub
parent a6fada7a99
commit 00ae27b206
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,14 @@
#!/bin/sh
# Pre-push hook: runs linting via Docker before pushing to GitHub.
# Ensures Rubocop, ERBLint, and ESLint pass locally.
# Skip with: git push --no-verify
#
# Enable this hook: git config core.hooksPath .githooks
set -e
echo "🔍 Running lint checks before push..."
docker compose -f docker-compose.ci.yml build lint --quiet 2>/dev/null
docker compose -f docker-compose.ci.yml run --rm --no-deps lint
echo "✅ All lint checks passed."

@ -0,0 +1,29 @@
FROM ruby:4.0.1-alpine
ENV RAILS_ENV=test
ENV NODE_ENV=test
WORKDIR /app
RUN apk add --no-cache \
build-base \
git \
libpq-dev \
yaml-dev \
nodejs \
yarn \
vips-dev \
chromium \
chromium-chromedriver \
&& wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" \
&& mkdir -p /pdfium && tar -xzf pdfium-linux.tgz -C /pdfium \
&& cp /pdfium/lib/libpdfium.so /usr/lib/ \
&& rm -rf pdfium-linux.tgz /pdfium
COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs 4 --retry 3
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .

@ -6,9 +6,7 @@ class AccountLogoController < ApplicationController
def create
file = params[:file]
if file.blank?
return redirect_to settings_personalization_path, alert: I18n.t('unable_to_save')
end
return redirect_to settings_personalization_path, alert: I18n.t('unable_to_save') if file.blank?
current_account.logo.attach(file)

@ -47,7 +47,7 @@ module Api
end
def replace_document(doc_params)
position = doc_params[:position]&.to_i || 0
position = doc_params[:position].to_i
file = Api::DecodeDocumentFile.call(doc_params[:file], name: doc_params[:name])
documents, = Templates::CreateAttachments.call(@template, { files: [file] }, extract_fields: true)
@ -59,7 +59,8 @@ module Api
new_schema = { 'attachment_uuid' => document.uuid, 'name' => document.filename.base }
if old_schema
new_doc_has_fields = @template.fields.any? { |f| f['areas']&.any? { |a| a['attachment_uuid'] == document.uuid } }
new_doc_has_fields =
@template.fields.any? { |f| f['areas']&.any? { |a| a['attachment_uuid'] == document.uuid } }
unless new_doc_has_fields
@template.fields.each do |field|

@ -41,7 +41,7 @@ class TeamsController < ApplicationController
@teams = current_account.teams.active
.left_joins(:users)
.where(users: { archived_at: nil })
.or(current_account.teams.active.left_joins(:users).where(users: { id: nil }))
.or(current_account.teams.active.where.missing(:users))
.select('teams.*, COUNT(users.id) AS active_users_count')
.group('teams.id')
.order(:name)

@ -9,7 +9,7 @@ class TemplatesCloneController < ApplicationController
@template = Template.new(name: "#{@base_template.name} (#{I18n.t('clone')})")
end
def create
def create # rubocop:disable Metrics/AbcSize
ActiveRecord::Associations::Preloader.new(
records: [@base_template],
associations: [{ schema_documents: :preview_images_attachments }]

@ -81,7 +81,7 @@ class ProcessSubmitterRemindersJob
result
end
def duration_to_seconds(key)
def duration_to_seconds(key) # rubocop:disable Metrics/CyclomaticComplexity
case key
when 'one_hour' then 1.hour
when 'two_hours' then 2.hours

@ -15,7 +15,7 @@ class SendSubmitterReminderEmailJob
return unless submitter.email.to_s.include?('@')
return unless Accounts.can_send_emails?(submitter.account)
return if submitter.submission_events.where(event_type: 'send_reminder_email')
.where('created_at > ?', 1.minute.ago).exists?
.exists?(['created_at > ?', 1.minute.ago])
mail = SubmitterMailer.reminder_email(submitter)

@ -62,10 +62,22 @@ class AccountConfig < ApplicationRecord
ENABLE_MCP_KEY = 'enable_mcp'
EMAIL_VARIABLES = {
SUBMITTER_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name sender.name sender.first_name sender.email submitter.name submitter.first_name submitter.email].freeze,
SUBMITTER_COMPLETED_EMAIL_KEY => %w[template.name submission.submitters submission.link sender.name sender.first_name sender.email submitter.name submitter.first_name submitter.email].freeze,
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[template.name submitter.link account.name sender.name sender.first_name sender.email submitter.name submitter.first_name submitter.email].freeze,
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[template.name documents.link account.name sender.name sender.first_name sender.email submitter.name submitter.first_name submitter.email].freeze
SUBMITTER_INVITATION_EMAIL_KEY => %w[
template.name submitter.link account.name sender.name
sender.first_name sender.email submitter.name submitter.first_name submitter.email
].freeze,
SUBMITTER_COMPLETED_EMAIL_KEY => %w[
template.name submission.submitters submission.link sender.name
sender.first_name sender.email submitter.name submitter.first_name submitter.email
].freeze,
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[
template.name submitter.link account.name sender.name
sender.first_name sender.email submitter.name submitter.first_name submitter.email
].freeze,
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[
template.name documents.link account.name sender.name
sender.first_name sender.email submitter.name submitter.first_name submitter.email
].freeze
}.freeze
DEFAULT_VALUES = {

@ -42,9 +42,9 @@
</td>
<td>
<%= button_to t('skip'), settings_submitter_reminder_path(entry[:submitter]),
method: :delete,
class: 'btn btn-xs btn-outline',
data: { turbo_frame: "reminder_row_#{entry[:submitter].id}" } %>
method: :delete,
class: 'btn btn-xs btn-outline',
data: { turbo_frame: "reminder_row_#{entry[:submitter].id}" } %>
</td>
</tr>
<% end %>

@ -1,7 +1,7 @@
<% if current_account.logo.attached? %>
<div class="mb-4">
<div class="flex items-center space-x-4">
<img src="<%= url_for(current_account.logo) %>" class="max-h-16 max-w-xs rounded" />
<img src="<%= url_for(current_account.logo) %>" class="max-h-16 max-w-xs rounded">
<%= button_to t('remove'), settings_account_logo_path, method: :delete, class: 'btn btn-sm btn-error btn-outline', data: { turbo_confirm: t('are_you_sure_') } %>
</div>
</div>

@ -1,7 +1,7 @@
<% if signed_in? && current_user != true_user %>
<%= render 'shared/test_alert' %>
<% elsif request.path.starts_with?('/settings') %>
<%= link_to "https://www.docuseal.com", class: 'hidden md:inline-flex btn btn-warning btn-sm', target: '_blank', data: { prefetch: false } do %>
<%= link_to 'https://www.docuseal.com', class: 'hidden md:inline-flex btn btn-warning btn-sm', target: '_blank', rel: 'noopener', data: { prefetch: false } do %>
Go Full Pro
<% end %>
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>

@ -1,6 +1,6 @@
<a href="/" class="flex justify-center items-center">
<% if @template&.account&.logo&.attached? %>
<img src="<%= url_for(@template.account.logo) %>" class="max-h-16 max-w-[250px]" />
<img src="<%= url_for(@template.account.logo) %>" class="max-h-16 max-w-[250px]">
<% else %>
<span class="mr-3">
<%= render 'shared/logo', width: '50px', height: '50px' %>

@ -1,5 +1,5 @@
<% if @submission&.account&.logo&.attached? %>
<img src="<%= url_for(@submission.account.logo) %>" class="max-h-10 max-w-[160px]" />
<img src="<%= url_for(@submission.account.logo) %>" class="max-h-10 max-w-[160px]">
<% else %>
<%= render 'shared/logo', width: 40, height: 40 %>
<% end %>

@ -1,6 +1,6 @@
<a href="<%= root_path %>" class="mx-auto text-2xl md:text-3xl font-bold items-center flex space-x-3">
<% if @submitter&.account&.logo&.attached? %>
<img src="<%= url_for(@submitter.account.logo) %>" class="max-h-12 max-w-[200px]" />
<img src="<%= url_for(@submitter.account.logo) %>" class="max-h-12 max-w-[200px]">
<% else %>
<%= render 'shared/logo', class: 'w-9 h-9 md:w-12 md:h-12' %>
<span><%= Docuseal.product_name %></span>

@ -4,8 +4,8 @@
<% if submitter_url_pattern && rendered_html.include?(submitter_url_pattern) %>
<% button_label = I18n.t(submitter.with_signature_fields? ? :review_and_sign : :review_and_submit) %>
<% rendered_html = rendered_html.gsub(%r{<a href="([^"]*#{Regexp.escape(submitter_url_pattern)}[^"]*)">[^<]*</a>}i) do
url = Regexp.last_match(1)
render(partial: 'shared/email_button', locals: { url: url, label: button_label })
end %>
url = Regexp.last_match(1)
render(partial: 'shared/email_button', locals: { url: url, label: button_label })
end %>
<% end %>
<%= rendered_html.html_safe %>

@ -1,4 +1,4 @@
<div class="form-control">
<%= f.label :role, class: 'label' %>
<%= f.select :role, [['Admin', 'admin'], ['Editor', 'editor']], { selected: f.object.role }, class: 'base-select' %>
<%= f.select :role, [%w[Admin admin], %w[Editor editor]], { selected: f.object.role }, class: 'base-select' %>
</div>

@ -0,0 +1,31 @@
#!/bin/sh
# Run the full CI suite locally via Docker (mirrors GitHub Actions).
# Usage: bin/ci [service]
# bin/ci — run lint + brakeman + rspec
# bin/ci lint — run only linters
# bin/ci rspec — run only tests
# bin/ci brakeman — run only security scanner
set -e
SERVICE="${1:-}"
echo "Building CI image (cached)..."
docker compose -f docker-compose.ci.yml build --quiet
if [ -z "$SERVICE" ]; then
echo "━━━ Lint ━━━"
docker compose -f docker-compose.ci.yml run --rm --no-deps lint
echo ""
echo "━━━ Brakeman ━━━"
docker compose -f docker-compose.ci.yml run --rm --no-deps brakeman
echo ""
echo "━━━ RSpec ━━━"
docker compose -f docker-compose.ci.yml run --rm rspec
else
docker compose -f docker-compose.ci.yml run --rm "$SERVICE"
fi
docker compose -f docker-compose.ci.yml down --volumes --remove-orphans 2>/dev/null
echo ""
echo "✅ CI passed."

@ -0,0 +1,12 @@
#!/bin/sh
# Run all linters via Docker (same as CI pipeline).
# Usage: bin/lint
set -e
echo "Building CI image (cached)..."
docker compose -f docker-compose.ci.yml build lint --quiet
echo "Running Rubocop + ERBLint + ESLint..."
docker compose -f docker-compose.ci.yml run --rm --no-deps lint
echo "✅ All checks passed."

@ -12,6 +12,6 @@ class CreateTeams < ActiveRecord::Migration[8.0]
end
add_index :teams, :uuid, unique: true
add_index :teams, %i[account_id name], unique: true, where: "archived_at IS NULL"
add_index :teams, %i[account_id name], unique: true, where: 'archived_at IS NULL'
end
end

@ -0,0 +1,61 @@
services:
lint:
build:
context: .
dockerfile: Dockerfile.ci
command: sh -c "bundle exec rubocop && bundle exec erb_lint ./app && yarn eslint 'app/javascript/**/*.js'"
volumes:
- .:/app:ro
- bundle_cache:/usr/local/bundle
- node_cache:/app/node_modules
tmpfs:
- /tmp
brakeman:
build:
context: .
dockerfile: Dockerfile.ci
command: bundle exec brakeman -q --exit-on-warn
volumes:
- .:/app:ro
- bundle_cache:/usr/local/bundle
- node_cache:/app/node_modules
tmpfs:
- /tmp
rspec:
build:
context: .
dockerfile: Dockerfile.ci
command: sh -c "bundle exec rake db:create db:migrate && bundle exec rake assets:precompile && bundle exec rspec"
depends_on:
postgres:
condition: service_healthy
environment:
RAILS_ENV: test
NODE_ENV: test
DATABASE_URL: postgres://postgres:postgres@postgres:5432/docuseal_test
volumes:
- .:/app
- bundle_cache:/usr/local/bundle
- node_cache:/app/node_modules
tmpfs:
- /tmp
postgres:
image: postgres:18
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: docuseal_test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
tmpfs:
- /var/lib/postgresql/data
volumes:
bundle_cache:
node_cache:

@ -3,9 +3,7 @@ services:
depends_on:
postgres:
condition: service_healthy
build:
context: .
dockerfile: Dockerfile
image: ghcr.io/s256/docuseal-with-some-pro-features:latest
ports:
- 3000:3000
volumes:

@ -3,6 +3,7 @@
module SubmitterReminders
module_function
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def next_reminder_at(submitter, reminder_config)
return nil unless reminder_config&.value.is_a?(Hash)
return nil if submitter.completed_at? || submitter.declined_at?
@ -35,6 +36,7 @@ module SubmitterReminders
base_time + duration
end
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def parse_durations(value)
return {} unless value.is_a?(Hash)
@ -46,7 +48,7 @@ module SubmitterReminders
result
end
def duration_to_seconds(key)
def duration_to_seconds(key) # rubocop:disable Metrics/CyclomaticComplexity
case key
when 'one_hour' then 1.hour
when 'two_hours' then 2.hours

Loading…
Cancel
Save