This file provides context and conventions for AI coding agents (Claude Code, Copilot, Cursor, etc.) contributing to this project. Follow these patterns to produce code that is consistent with the existing codebase.
netdef-ci-github-app is a Ruby/Sinatra webhook server that bridges GitHub pull requests and a Bamboo CI system. It receives GitHub events, triggers CI builds, polls for results, updates GitHub Check Runs, and sends Slack notifications.
Stack:
- Ruby 3.1+ / Sinatra / Rack / Puma
- PostgreSQL via
otr-activerecord(ActiveRecord without Rails) - Delayed Job (background workers — not Sidekiq)
- Octokit (GitHub API)
- RSpec + FactoryBot + WebMock (tests)
- Rubocop 1.56 (linting — enforced in CI)
app/github_app.rb # Sinatra entry point — routes only, no business logic
lib/
bamboo_ci/ # All Bamboo REST API interactions
github/ # GitHub webhook handlers and Check API wrappers
build/ # Submodules for build orchestration
retry/ # Comment-triggered retry logic
re_run/ # Check suite re-run logic
plan_execution/ # Handlers for execution completion
topotest_failures/ # Failure log parsers
parsers/ # PR commit parsers
models/ # ActiveRecord models (12 models)
helpers/ # Cross-cutting concerns: config, logging, metrics, auth
slack/ # Slack API client
slack_bot/ # Notification formatters
workers/ # Delayed Job worker classes
spec/
app/ # Integration tests (Rack::Test)
lib/ # Unit tests — mirror lib/ structure
workers/ # Worker unit tests
factories/ # FactoryBot factories
support/ # RSpec helpers (factory_bot.rb, webmock.rb)
config/
setup.rb # Boot sequence — load order matters here
delayed_job.rb # Worker tuning (max_attempts, sleep_delay, max_run_time)
database.yml # PostgreSQL connection by environment
db/migrate/ # Numbered migrations only — never edit schema.rb directly
Every Ruby file starts with this exact header block (replace filename.rb and the year accordingly):
# SPDX-License-Identifier: BSD-2-Clause
#
# filename.rb
# Part of NetDEF CI System
#
# Copyright (c) <year> by
# Network Device Education Foundation, Inc. ("NetDEF")
#
# frozen_string_literal: trueInclude this header on all new files. The order matters: license identifier first, then filename + project line, then copyright, then frozen string literal.
| Construct | Convention | Example |
|---|---|---|
| Classes / Modules | PascalCase | BambooCi::PlanRun |
| Methods | snake_case | fetch_executions |
| Constants | UPPER_SNAKE_CASE | MAX_RETRY_ATTEMPTS |
| DB tables | plural snake_case | ci_jobs, check_suites |
| AR models | singular PascalCase | CiJob, CheckSuite |
| Files | snake_case matching class | plan_run.rb for PlanRun |
Group classes by domain, not by layer:
lib/bamboo_ci/api.rb → module BambooCi, class Api
lib/github/check.rb → module Github, class Check
lib/slack_bot/stage.rb → module SlackBot, class Stage
Do not put business logic in app/github_app.rb. Routes delegate immediately:
# Good
post '/*' do
Github::BuildPlan.new(payload).create
end
# Bad — inline logic in the route
post '/*' do
pull_request = PullRequest.find_or_create_by(...)
# ... 50 lines of logic
endRubocop enforces 20 lines max. Extract private helpers rather than writing long methods.
Rubocop enforces 200 lines max. Split responsibilities into submodules (see lib/github/build/, lib/github/retry/).
Style/Documentation is disabled. Do not add class-level docblock comments unless there is a non-obvious invariant to explain. One-line comments for why, not what.
Status enums follow this shared pattern across CiJob, Stage, and AuditStatus:
enum status: {
queued: 0,
in_progress: 1,
success: 2,
cancelled: -1,
failure: -2,
skipped: -3
}Use the symbol form everywhere: ci_job.success!, stage.in_progress?, CiJob.where(status: :failure).
Declare associations at the top of the model, before validations and scopes:
class CiJob < ApplicationRecord
belongs_to :check_suite
belongs_to :stage
has_many :topotest_failures, dependent: :destroy
has_many :audit_statuses, as: :auditable, dependent: :destroy
enum status: { ... }
scope :failure_only, -> { where(status: :failure) }
end- Always create a numbered migration:
rails generate migration AddColumnToTableor write by hand followingdb/migrate/naming. - Never edit
db/schema.rbby hand — it is regenerated byrake db:migrate:reset. - Validate that
schema.rbmatches migrations in CI:diff <(rake db:schema:dump) db/schema.rb.
Workers are plain Ruby classes enqueued with .delay:
# Enqueue
CreateExecutionByPlan.new.delay(queue: 2).perform(check_suite_id: suite.id)
# Worker
class CreateExecutionByPlan
def perform(check_suite_id:)
suite = CheckSuite.find(check_suite_id)
# ...
end
endRules:
- Workers live in
workers/, not inlib/. - Workers do not inherit from
ActiveJob::Base— use plain Ruby classes. - Keep workers thin: delegate logic to
lib/classes. - Queue numbers 0–9 are meaningful (see
config.rufor priority tiers). - Max 5 attempts, 5-minute timeout per job (configured in
config/delayed_job.rb).
RSpec with --format=documentation --order=random. Always run with bundle exec rspec.
Mirror the source path:
lib/github/build_plan.rb → spec/lib/github/build_plan_spec.rb
workers/ci_job_status.rb → spec/workers/ci_job_status_spec.rb
app/github_app.rb → spec/app/github_app_spec.rb
# frozen_string_literal: true
describe Github::BuildPlan do
let(:payload) { create(:pull_request_payload) }
describe '#create' do
context 'when the repository is configured' do
before { allow_any_instance_of(Github::Check).to receive(:create).and_return(true) }
it 'creates a check suite' do
expect { described_class.new(payload).create }.to change(CheckSuite, :count).by(1)
end
end
context 'when the repository is not configured' do
it 'returns early without creating records' do
expect { described_class.new(payload).create }.not_to change(CheckSuite, :count)
end
end
end
endFactories live in spec/factories/. Use traits for associations, not nested factories:
FactoryBot.define do
factory :check_suite do
commit_sha_ref { Faker::Alphanumeric.alphanumeric(number: 40) }
author { Faker::Internet.username }
trait :with_ci_jobs do
after(:create) do |suite|
create_list(:ci_job, 3, check_suite: suite)
end
end
end
endAll external HTTP calls must be mocked with WebMock. No real network calls in tests:
before do
stub_request(:post, %r{bamboo/rest/api/latest/queue})
.to_return(status: 200, body: { buildResultKey: 'PROJ-123' }.to_json)
endSimpleCov enforces 90% branch coverage minimum per group. Use :nocov: only for genuinely untestable blocks (e.g., rescue blocks for infrastructure failures):
# :nocov:
rescue StandardError => e
logger.error(e.message)
# :nocov:
endRuntime configuration is loaded from config.yml (not committed — see config_template.yml):
config = GitHubApp::Configuration.instance
config.reload # re-reads YAML from disk
config.all_logins # GitHub app login names
config.bamboo_url # Bamboo base URLNever hardcode URLs, credentials, or repo names. Always read from GitHubApp::Configuration.instance.
Use the project logger — do not use puts or p:
logger = GithubLogger.instance.create('github_app.log', Logger::INFO)
logger.info { "[#{self.class}] Starting plan #{plan.id}" }
logger.error { "[#{self.class}] Failed: #{e.message}" }Log file names map to components (see lib/helpers/github_logger.rb). Use the closest existing log file for your component.
Increment Prometheus counters/histograms for any new external call or user-visible operation:
PrometheusMet.instance.http_requests.increment(labels: { method: 'POST', status: '200' })Do not add new metric names without first checking lib/helpers/prometheus_metrics.rb for an existing one that fits.
The GitHub Actions workflow (.github/workflows/ruby.yml) runs on every push/PR:
- Rubocop — lint check via reviewdog (inline PR comments on violations).
- RSpec — full test suite with a real PostgreSQL 14 service.
Before opening a PR:
bundle exec rubocop # must pass with zero offenses
bundle exec rspec # must pass at ≥90% coverage
rake db:migrate:reset # verify migrations apply cleanly- Do not use
ActiveJob— this project uses Delayed Job with plain Ruby workers. - Do not call external services from within a Sinatra route — enqueue a worker instead.
- Do not modify
db/schema.rbby hand — generate a migration. - Do not add logic to
app/github_app.rb— it should only instantiate and delegate. - Do not stub
Time.noworDate.todayglobally — useTimecopif a test requires time control (not currently a dependency — add it if needed). - Do not create new queue numbers beyond 0–9 without updating
config.ru. - Run
rubocop -Abefore committing — unformatted code fails CI immediately.
- New files include the frozen string literal comment and SPDX header.
- Business logic lives in
lib/, not inapp/,workers/, or models. - New ActiveRecord model includes a migration and status enum (if applicable).
- New Delayed Job worker lives in
workers/and delegates tolib/. - External HTTP calls are wrapped in a class under
lib/bamboo_ci/orlib/github/. - Tests mirror the source path and achieve ≥90% branch coverage.
- WebMock stubs cover all new HTTP calls in tests.
-
rubocoppasses with no new offenses. -
rake db:migrate:reset && bundle exec rspecpasses locally.