Skill

SkillsContent & Creative › Writing & copy

discourse-upcoming-changes-authoring

Use when modifying, debugging, or extending the upcoming changes framework code and system itself.

Freerisk: medium
discourseupcomingchangesgojavascriptgit

The full skill

— name: discourse-upcoming-changes-authoring description: Use when modifying, debugging, or extending the upcoming changes framework code and system itself. — # Upcoming Changes Framework — Authoring Guide This skill is for working on the upcoming changes framework itself — the internal machinery that powers feature flag rollout in Discourse. For *adding a new feature flag* using the framework, see the `discourse-upcoming-changes` skill instead. ## Architecture Overview The upcoming changes system has three layers: a **Ruby core** that manages state and business logic, a **services layer** that orchestrates tracking/notifications/toggling, and an **Ember frontend** that renders the admin UI and applies per-user overrides. ### Ruby Core **`lib/upcoming_changes.rb`** — The central module. All business logic for resolving values, checking user eligibility, caching, and image handling lives here. Key methods to understand: – `resolved_value(setting_name)` — Determines the *effective* value of a setting. This is where auto-promotion logic lives: if a setting's status meets/exceeds `promote_upcoming_changes_on_status`, the resolved value is `true` even if the DB default is `false`. Permanent settings always resolve to `true` (admins can't disable them). – `enabled_for_user?(setting_name, user)` — The primary access check. Considers: resolved value, group restrictions, anonymous users (only get access if no group restrictions). – `stats_for_user(user:, acting_guardian:)` — Returns per-change status for a user including *why* they have/don't have access (the `user_enabled_reasons` enum). – `current_statuses` / `permanent_upcoming_changes` — Cached lookups keyed by git version (one-time cost per deploy). Cleared by `clear_caches!` and automatically when `TrackNotifyStatusChanges` detects changes. **`app/models/upcoming_change_event.rb`** — Audit trail. Every lifecycle event (added, removed, status change, manual toggle, admin notification) is recorded here. Has unique indexes to prevent duplicate events of specific types per change. **`lib/site_setting_extension.rb`** — Where `upcoming_change:` metadata in `site_settings.yml` gets parsed. When a setting is registered with this metadata, it stores the parsed result in `@upcoming_change_metadata` and defines a `{name}_groups_map` method. The `impact` string is split into `impact_type` and `impact_role`. Also handles `upcoming_change_default_override:` metadata — see [Default Overrides](#default-overrides) below. **`lib/site_settings/defaults_provider.rb`** — Manages the default values for all settings, including upcoming change default overrides. Tracks which overrides are active via `@active_upcoming_change_overrides` and applies them when resolving defaults. Provides `upcoming_change_override_metadata` for the frontend to display warnings about changed defaults. **`app/models/site_setting_group.rb`** — Stores group restrictions for settings. Group IDs are pipe-separated strings (`"1|2|3"`). The `setting_group_ids` class method returns a hash used for in-memory caching. ### Services Layer All services use `Service::Base`. They're organized under `app/services/upcoming_changes/`: | Service | Purpose | |———|———| | `List` | Admin-only, fetches all changes with metadata, group data, and images | | `Toggle` | Admin enable/disable — updates SiteSetting, clears groups if `disallow_enabled_for_groups`, logs staff action, fires DiscourseEvent | | `Track` | Orchestrator called by the scheduled job — delegates to three action sub-services | | `TrackNotifyAddedChanges` | Compares current settings against event history, creates `added` events | | `TrackRemovedChanges` | Creates `removed` events for settings no longer present | | `TrackNotifyStatusChanges` | Detects status changes in metadata, creates events, clears caches | | `NotifyPromotions` | Iterates all changes and calls `NotifyPromotion` for each | | `NotifyPromotion` | Handles one promotion — checks policies, merges notifications, fires events | | `NotifyAdminsOfAvailableChange` | Notifies admins when a change reaches one status below promotion threshold | | `NotificationDataMerger` | Consolidates multiple change notifications into one to avoid spam | **`SiteSetting::UpsertGroups`** — Manages group assignments for settings (upserts `SiteSettingGroup`, refreshes caches, notifies clients). ### Scheduled Job **`app/jobs/scheduled/check_upcoming_changes.rb`** — Runs every 20 minutes inside a `DistributedMutex`. Calls `Track` then `NotifyPromotions`. Supports verbose logging via the `upcoming_change_verbose_logging` setting. ### Frontend **Admin page** — `admin/templates/admin-config/upcoming-changes.gjs` renders the page header, `admin/components/admin-config-areas/upcoming-changes.gjs` is the container with filtering, and `admin/components/admin-config-areas/upcoming-change-item.gjs` renders each row. **Key frontend patterns:** – Filtering by status, impact type, impact role, and enabled/disabled state via `AdminFilterControls` – Group selection uses a multi-select dropdown with debounced API saves – Toast notifications for all toggle/group changes – Lightbox integration for preview images **Site settings service** (`app/services/site-settings.js`) — Loads upcoming changes from `PreloadStore`, applies them as overrides to site settings, and stores them in `settings.currentUserUpcomingChanges`. **Body CSS classes** — `app/controllers/application.js` generates `uc-{dasherized-key}` classes on `<body>` for each enabled upcoming change, allowing CSS-based feature gating. **Notifications** — Two notification types (`upcoming-change-available`, `upcoming-change-automatically-promoted`) handle singular/dual/many change descriptions and link to the admin page with filter params. **Sidebar** — Badge notification dot appears on the upcoming changes link when `currentUser.hasNewUpcomingChanges` is true. **MessageBus** — Subscribes to `/client_settings` and updates both `siteSettings` and `currentUserUpcomingChanges` in real time. ### Controller **`admin/config/upcoming_changes_controller.rb`** — Three endpoints: – `GET index` — List changes (with `filter_statuses` param) – `PUT update_groups` — Set group restrictions for a setting – `PUT toggle_change` — Enable/disable a setting ### Problem Check **`app/services/problem_check/upcoming_change_stable_opted_out.rb`** — Warns admins hourly if they've opted out of a stable/permanent change. ### Default Overrides Upcoming changes can override the default value of a *different* site setting when enabled. This allows feature rollouts to change related setting defaults without breaking admin customizations. #### Metadata Format A setting declares a default override with the `upcoming_change_default_override` key in `config/site_settings.yml`: “`yaml # The upcoming change setting (the "trigger") increase_suggested_topics_max_days_old_default: default: false type: bool upcoming_change: status: experimental impact: "site_setting_default,all_members" # The setting whose default changes (the "target") suggested_topics_max_days_old: default: 365 type: integer upcoming_change_default_override: upcoming_change: increase_suggested_topics_max_days_old_default new_default: 1000 “` When `increase_suggested_topics_max_days_old_default` is enabled (either manually by admin or via auto-promotion), the default value of `suggested_topics_max_days_old` changes from `365` to `1000`. The `impact` field on the trigger setting should include `site_setting_default` as its `impact_type`. #### How It Works 1. **Registration** — `lib/site_setting_extension.rb` parses `upcoming_change_default_override` during setting registration and stores it in `upcoming_change_default_overrides` (a hash keyed by setting name). 2. **Activation** — During `SiteSetting.refresh!`, each override is checked: if `UpcomingChanges.enabled?(override[:upcoming_change])` returns true, the override is activated via `defaults.activate_upcoming_change_override`. The setting's current value is updated to `new_default` **only if the admin has not manually modified it**. 3. **Default resolution** — `DefaultsProvider#all` applies active overrides when resolving defaults, so code reading `SiteSetting.defaults[:setting_name]` gets the overridden value. 4. **Frontend display** — `DefaultsProvider#upcoming_change_override_metadata` returns `{ old_default:, new_default:, change_setting_name: }` for active overrides. The site settings UI (`admin/components/site-setting.gjs`) shows a warning linking to the upcoming changes page. #### Key Behaviors – **Non-destructive**: If an admin has manually set a custom value for the target setting, the override does not apply — it only affects the default. – **Reversible**: Disabling the upcoming change deactivates the override and restores the original default. – **Default-locale only**: Overrides currently only apply on the default locale. – **Related setting link**: `UpcomingChanges.find_related_default_override_for_change` finds the target setting for a given upcoming change, used to show cross-links in the UI. ## Key Design Decisions ### Caching Strategy The `current_statuses` and `permanent_upcoming_changes` caches are keyed by git version (`Discourse.git_version`). This means they're naturally invalidated on every deploy — no TTL needed. Within a deploy, `TrackNotifyStatusChanges` calls `clear_caches!` when it detects metadata changes. Always call `clear_caches!` in tests after modifying metadata. ### Auto-Promotion The `resolved_value` method is the single source of truth for whether a setting is "on." Auto-promotion happens implicitly: when a setting's status meets the threshold, `resolved_value` returns `true` regardless of the DB value. The DB value only changes when an admin explicitly toggles. This separation means promotion is reversible by the admin without losing the original opt-in/opt-out state. ### Notification Merging When multiple changes need notifications, `NotificationDataMerger` consolidates them into a single notification per admin. It finds existing unread notifications and merges the change names array. The frontend notification types handle singular ("Feature X"), dual ("Feature X and Feature Y"), and many ("Feature X and 2 others") display. ### New Site Notification Suppression Notifications for `added` and `promoted` changes are skipped on new sites (determined by `Migration::Helpers.new_site?` in `lib/migration/helpers.rb` — a site is "new" if its first schema migration was less than 1 hour ago). This prevents freshly provisioned sites from being flooded with notifications for every existing upcoming change on their first run. The tracking/detection steps still execute — only the notification delivery is suppressed. ### Group-Based Access Group restrictions use a separate `SiteSettingGroup` model rather than storing groups on the setting itself. This allows the caching layer (`site_setting_group_ids`) to work independently. When `disallow_enabled_for_groups` is set in metadata, the UI only shows Everyone/No One options. Group IDs are pipe-separated in the DB for efficient single-row storage. ### Event Idempotency `UpcomingChangeEvent` has unique indexes on specific event type + change name combinations. This prevents duplicate `added`, `removed`, or notification events even if the job runs multiple times. Always check for existing events before creating new ones in service code. ## Common Modification Scenarios ### Adding a New Status 1. Add the status and its numeric value to `UpcomingChanges.statuses` in `lib/upcoming_changes.rb` 2. The numeric ordering determines hierarchy — `meets_or_exceeds_status?` uses these values 3. Update `previous_status` mapping if the new status fits in the progression 4. Add status badge styling in `app/assets/stylesheets/admin/upcoming-changes.scss` (`.upcoming-change__badge.–status-{name}`) 5. Add translations for the status label ### Adding a New Event Type 1. Add the enum value to `UpcomingChangeEvent` (`app/models/upcoming_change_event.rb`) 2. If the event should be unique per change, add a unique index in a migration 3. Create or update the relevant service to emit the event ### Modifying the Admin UI The three main components to know: – **Container** (`upcoming-changes.gjs`) — Filtering logic and data management – **Item** (`upcoming-change-item.gjs`) — Individual change row rendering and interactions – **User view** (`admin-user-upcoming-changes.gjs`) — Read-only per-user view State is managed via `trackedObject` for reactivity. API calls go through `ajax()` directly in the item component. ### Adding a Default Override To make an upcoming change control the default of another setting: 1. Add `upcoming_change_default_override` metadata to the **target** setting in `config/site_settings.yml`: “`yaml target_setting: default: original_value upcoming_change_default_override: upcoming_change: trigger_setting_name new_default: new_value “` 2. Ensure the **trigger** setting has `impact: "site_setting_default,…"` in its `upcoming_change:` metadata 3. The override activates automatically when the trigger is enabled — no additional code needed 4. Test with `mock_upcoming_change_default_overrides` — see [Mocking Default Overrides](#mocking-default-overrides) ### Changing Resolution Logic All value resolution goes through `resolved_value` in `lib/upcoming_changes.rb`. If you need to change how settings are evaluated (e.g., adding a new override condition), this is the single place to modify. The method checks in order: permanent status, admin manual override, auto-promotion threshold. ## Testing Patterns ### Mocking Metadata Use the test helper to mock upcoming change metadata — never modify `site_settings.yml` in tests: “`ruby mock_upcoming_change_metadata( { enable_some_feature: { impact: "feature,all_members", status: :experimental, impact_type: "feature", impact_role: "all_members", }, }, ) “` Always clean up with `clear_mocked_upcoming_change_metadata` in an `after` block (or the helper handles it automatically depending on context). The helper is defined in `spec/support/helpers.rb`. ### Mocking Default Overrides Use `mock_upcoming_change_default_overrides` to set up override metadata in tests — never modify `site_settings.yml`: “`ruby mock_upcoming_change_default_overrides( { suggested_topics_max_days_old: { upcoming_change: :increase_suggested_topics_max_days_old_default, new_default: 1000, }, }, ) # Enable the trigger setting and refresh to activate the override SiteSetting.increase_suggested_topics_max_days_old_default = true SiteSetting.refresh! # Now SiteSetting.suggested_topics_max_days_old returns 1000 (the overridden default) “` To test that the override does NOT apply when the admin has customized the target setting: “`ruby # Admin sets a custom value before the override activates SiteSetting.suggested_topics_max_days_old = 730 SiteSetting.increase_suggested_topics_max_days_old_default = true SiteSetting.refresh! # Override is not applied — admin's explicit choice is preserved expect(SiteSetting.suggested_topics_max_days_old).to eq(730) “` ### Cache Clearing in Tests After modifying metadata or settings, call `UpcomingChanges.clear_caches!` to ensure tests see fresh data. The caches are keyed by git version, so they persist across test examples unless explicitly cleared. ### Testing Services Services follow standard `Service::Base` test patterns — see the `discourse-service-authoring` skill. Use `run_successfully`, `fail_a_policy`, etc. ### System Tests Page objects live at: – `spec/system/page_objects/pages/admin_upcoming_changes.rb` — Main page – `spec/system/page_objects/pages/admin_upcoming_change_item.rb` — Item component Key page object methods: – `change_item(setting_name)` — Get an item component by setting name – `has_change?` / `has_no_change?` — Visibility assertions – `select_enabled_for(option)` — Toggle the enabled dropdown – `add_group` / `remove_group` / `save_groups` — Group management – `has_enabled_for_success_toast?` — Verify success feedback System tests use `mock_upcoming_change_metadata` in `before` blocks. When revisiting pages to verify persistence, be aware of rate limiting on API calls. ### Testing the Scheduled Job Use `track_log_messages` to verify job output: “`ruby track_log_messages do |logger| described_class.new.execute({}) expect(logger.infos.join("\n")).to include("Expected message") end “` Set up event history with `UpcomingChangeEvent.create!` and clean up with `delete_all` as needed. ### Multisite Tests Cache isolation tests live in `spec/multisite/upcoming_changes_spec.rb`. Use `test_multisite_connection("default")` / `test_multisite_connection("second")` blocks and clean up cache keys explicitly per site. ### JavaScript Tests Notification type tests create notifications with `Notification.create()` and verify `director.description`, `director.linkHref`, and `director.icon`. Test singular, dual, and many-change scenarios plus backward compatibility with old data formats. ## File Reference | Area | Key Files | |——|———–| | Core module | `lib/upcoming_changes.rb` | | Event model | `app/models/upcoming_change_event.rb` | | Group model | `app/models/site_setting_group.rb` | | Settings integration | `lib/site_setting_extension.rb` (search for `upcoming_change`) | | Defaults provider | `lib/site_settings/defaults_provider.rb` (default override activation/resolution) | | Services | `app/services/upcoming_changes/*.rb` | | Group upsert | `app/services/site_setting/upsert_groups.rb` | | Controller | `admin/config/upcoming_changes_controller.rb` | | Scheduled job | `app/jobs/scheduled/check_upcoming_changes.rb` | | Problem check | `app/services/problem_check/upcoming_change_stable_opted_out.rb` | | Initializer | `config/initializers/015-track-upcoming-change-toggle.rb` | | Admin page | `admin/templates/admin-config/upcoming-changes.gjs` | | Admin container | `admin/components/admin-config-areas/upcoming-changes.gjs` | | Admin item | `admin/components/admin-config-areas/upcoming-change-item.gjs` | | User view | `admin/components/admin-user-upcoming-changes.gjs` | | Site settings svc | `frontend/discourse/app/services/site-settings.js` | | App controller | `frontend/discourse/app/controllers/application.js` | | Notifications | `frontend/discourse/app/lib/notification-types/upcoming-change-*.js` | | Sidebar | `frontend/discourse/app/lib/sidebar/admin-sidebar.js` | | Constants | `frontend/discourse/app/lib/constants.js` | | Styles | `app/assets/stylesheets/admin/upcoming-changes.scss` | | Core spec | `spec/lib/upcoming_changes_spec.rb` | | Request spec | `spec/requests/admin/config/upcoming_changes_controller_spec.rb` | | Admin system spec | `spec/system/admin_upcoming_changes_spec.rb` | | Member system spec | `spec/system/member_upcoming_changes_spec.rb` | | Job spec | `spec/jobs/scheduled/check_upcoming_changes_spec.rb` | | Multisite spec | `spec/multisite/upcoming_changes_spec.rb` | | Page objects | `spec/system/page_objects/pages/admin_upcoming_changes.rb`, `admin_upcoming_change_item.rb` | | Test helpers | `spec/support/helpers.rb` (search for `mock_upcoming_change_metadata`) |