mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-11 16:30:53 +08:00
This PR introduces part one of the "Upcoming changes" interface for Discourse admins. The upcoming changes feature is an enhancement around our existing site-setting based feature flagging and experiments system. With some light metadata, we can give admins a much better overview of the current work we are doing, with ways for them to opt-out in early stages and opt-in to things that we haven’t yet turned on by default for them. This system, along with encouraging a more liberal use of site setting flags for features, experiments, and refactors in the app, should minimise the problem of breakages and disruptions for all Discourse users. It is also our intent with this system for it to be easier for designers to add and remove these changes. Finally, it also gives us a kind of running changelog that we can use to communicate with site owners before releases and “What’s new?” updates. ### FOR REVIEWERS This initial PR is gated behind a hidden `enable_upcoming_changes` site setting, because there is still more work to do before we reveal this to admins. To test the UI, you can add this metadata under any boolean-based site setting, though upcoming change settings will specifically be hidden: ``` upcoming_change: status: "alpha" (see UpcomingChanges.statuses.keys) impact: "feature,staff" (feature|other for the first part, staff|admins|moderators|all_members|developers for the second part) learn_more_url: "https://some.url" ``` To test the images, add an image under `public/images/upcoming_changes` with the file name as `SITE_SETTING_NAME.png` ### Interface Admins can see the following in the interface for upcoming changes: * The status of the change. Changes can progress along these statuses: * Pre-Alpha * Alpha * Beta * Stable * Permanent * The impact of the change. This is split into Type and Role. Type can be "Feature" or "Other" for now. Changes may affect the following roles: * Admins * Moderators * Staff * All members * The plugin that is making the change * The groups that are opted-in to the change. Admins can control these groups for a gradual rollout. If a change is enabled, it is limited to these groups. * In some cases, an image related to the change, behind the "Preview" link * A link to learn more about the change Admins can filter the changes by name, description, plugin, status, impact type, and whether the change is enabled. ### Promotion system For our hosted customers, we intend to have a status-based auto-promotion system as changes progress. For all sites, once a change reaches the Stable status, if an admin opts-out of that change it will generate an admin problem message that will be shown on the dashboard. For self-hosted Discourse admins, changes will only be forcibly enabled when they reach the Permanent state. ### Notification system A notification system for upcoming changes so admins can stay informed will be added in a followup PR. --------- Co-authored-by: awesomerobot <kris.aubuchon@discourse.org>
360 lines
11 KiB
Ruby
360 lines
11 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
GIT_INITIAL_BRANCH_SUPPORTED =
|
|
Gem::Version.new(`git --version`.match(/[\d\.]+/)[0]) >= Gem::Version.new("2.28.0")
|
|
|
|
module Helpers
|
|
extend ActiveSupport::Concern
|
|
|
|
class NotAThemeError < StandardError
|
|
end
|
|
class NotAComponentThemeError < StandardError
|
|
end
|
|
|
|
def self.next_seq
|
|
@next_seq = (@next_seq || 0) + 1
|
|
end
|
|
|
|
def log_in(fabricator = nil)
|
|
user = Fabricate(fabricator || :user)
|
|
log_in_user(user)
|
|
user
|
|
end
|
|
|
|
def log_in_user(user)
|
|
cookie_jar = ActionDispatch::Request.new(request.env).cookie_jar
|
|
provider = Discourse.current_user_provider.new(request.env)
|
|
provider.log_on_user(user, session, cookie_jar)
|
|
provider
|
|
end
|
|
|
|
def log_out_user(provider)
|
|
provider.log_off_user(session, cookies)
|
|
end
|
|
|
|
def fixture_file(filename)
|
|
return "" if filename.blank?
|
|
file_path = File.expand_path(Rails.root + "spec/fixtures/" + filename)
|
|
File.read(file_path)
|
|
end
|
|
|
|
def build(*args)
|
|
Fabricate.build(*args)
|
|
end
|
|
|
|
def create_topic(args = {})
|
|
args[:title] ||= "This is my title #{Helpers.next_seq}"
|
|
user = args.delete(:user)
|
|
user = Fabricate(:user, refresh_auto_groups: true) if !user
|
|
guardian = Guardian.new(user)
|
|
args[:category] = args[:category].id if args[:category].is_a?(Category)
|
|
TopicCreator.create(user, guardian, args)
|
|
end
|
|
|
|
def create_post(args = {})
|
|
# Pretty much all the tests with `create_post` will fail without this
|
|
# since allow_uncategorized_topics is now false by default
|
|
SiteSetting.allow_uncategorized_topics = true unless args[:allow_uncategorized_topics] == false
|
|
|
|
args[:title] ||= "This is my title #{Helpers.next_seq}"
|
|
args[:raw] ||= "This is the raw body of my post, it is cool #{Helpers.next_seq}"
|
|
args[:topic_id] = args[:topic].id if args[:topic]
|
|
user = args.delete(:user) || Fabricate(:user, refresh_auto_groups: true)
|
|
args[:category] = args[:category].id if args[:category].is_a?(Category)
|
|
creator = PostCreator.new(user, args)
|
|
post = creator.create
|
|
|
|
raise StandardError.new(creator.errors.full_messages.join(" ")) if creator.errors.present?
|
|
|
|
post
|
|
end
|
|
|
|
def stub_guardian(user)
|
|
guardian = Guardian.new(user)
|
|
yield(guardian) if block_given?
|
|
Guardian.stubs(new: guardian).with(user, anything)
|
|
end
|
|
|
|
def wait_for(on_fail: nil, timeout: 1, &blk)
|
|
i = 0
|
|
result = false
|
|
while !result && i < timeout * 1000
|
|
result = blk.call
|
|
i += 1
|
|
sleep 0.001
|
|
end
|
|
|
|
on_fail&.call
|
|
expect(result).to eq(true)
|
|
end
|
|
|
|
def email(email_name)
|
|
fixture_file("emails/#{email_name}.eml")
|
|
end
|
|
|
|
def create_staff_only_tags(tag_names)
|
|
create_limited_tags("Staff Tags", Group::AUTO_GROUPS[:staff], tag_names)
|
|
end
|
|
|
|
def create_admin_only_tags(tag_names)
|
|
create_limited_tags("Admin Tags", Group::AUTO_GROUPS[:admins], tag_names)
|
|
end
|
|
|
|
def create_limited_tags(tag_group_name, group_id, tag_names)
|
|
tag_group = Fabricate(:tag_group, name: tag_group_name)
|
|
TagGroupPermission.where(
|
|
tag_group: tag_group,
|
|
group_id: Group::AUTO_GROUPS[:everyone],
|
|
permission_type: TagGroupPermission.permission_types[:full],
|
|
).update(permission_type: TagGroupPermission.permission_types[:readonly])
|
|
TagGroupPermission.create!(
|
|
tag_group: tag_group,
|
|
group_id: group_id,
|
|
permission_type: TagGroupPermission.permission_types[:full],
|
|
)
|
|
tag_names.each do |name|
|
|
tag_group.tags << (Tag.where(name: name).first || Fabricate(:tag, name: name))
|
|
end
|
|
end
|
|
|
|
def create_hidden_tags(tag_names)
|
|
tag_group = Fabricate(:tag_group, name: "Hidden Tags", permissions: { staff: :full })
|
|
tag_names.each do |name|
|
|
tag_group.tags << (Tag.where(name: name).first || Fabricate(:tag, name: name))
|
|
end
|
|
end
|
|
|
|
def sorted_tag_names(tag_records)
|
|
tag_records.map { |t| t.is_a?(String) ? t : t.name }.sort
|
|
end
|
|
|
|
def expect_same_tag_names(a, b)
|
|
expect(sorted_tag_names(a)).to eq(sorted_tag_names(b))
|
|
end
|
|
|
|
def capture_output(output_name)
|
|
if ENV["RAILS_ENABLE_TEST_STDOUT"]
|
|
yield
|
|
return
|
|
end
|
|
|
|
previous_output = output_name == :stdout ? $stdout : $stderr
|
|
|
|
io = StringIO.new
|
|
output_name == :stdout ? $stdout = io : $stderr = io
|
|
|
|
yield
|
|
io.string
|
|
ensure
|
|
output_name == :stdout ? $stdout = previous_output : $stderr = previous_output
|
|
end
|
|
|
|
def capture_stdout(&block)
|
|
capture_output(:stdout, &block)
|
|
end
|
|
|
|
def capture_stderr(&block)
|
|
capture_output(:stderr, &block)
|
|
end
|
|
|
|
def set_subfolder(new_root)
|
|
global_setting :relative_url_root, new_root
|
|
|
|
old_root = ActionController::Base.config.relative_url_root
|
|
ActionController::Base.config.relative_url_root = new_root
|
|
Rails.application.routes.stubs(:relative_url_root).returns(new_root)
|
|
|
|
before_next_spec { ActionController::Base.config.relative_url_root = old_root }
|
|
|
|
if RSpec.current_example.metadata[:type] == :system
|
|
Capybara.app.map("/") { run lambda { |env| [404, {}, [""]] } }
|
|
Capybara.app.map(new_root) { run Rails.application }
|
|
|
|
before_next_spec do
|
|
Capybara.app.map(new_root) { run lambda { |env| [404, {}, [""]] } }
|
|
Capybara.app.map("/") { run Rails.application }
|
|
end
|
|
end
|
|
end
|
|
|
|
def setup_git_repo(files)
|
|
repo_dir = Dir.mktmpdir
|
|
`cd #{repo_dir} && git init . #{"--initial-branch=main" if GIT_INITIAL_BRANCH_SUPPORTED}`
|
|
`cd #{repo_dir} && git config user.email 'someone@cool.com'`
|
|
`cd #{repo_dir} && git config user.name 'The Cool One'`
|
|
`cd #{repo_dir} && git config commit.gpgsign 'false'`
|
|
files.each do |name, data|
|
|
FileUtils.mkdir_p(Pathname.new("#{repo_dir}/#{name}").dirname)
|
|
File.write("#{repo_dir}/#{name}", data)
|
|
`cd #{repo_dir} && git add #{name}`
|
|
end
|
|
`cd #{repo_dir} && git commit -am 'first commit'`
|
|
repo_dir
|
|
end
|
|
|
|
def add_to_git_repo(repo_dir, files)
|
|
files.each do |name, data|
|
|
FileUtils.mkdir_p(Pathname.new("#{repo_dir}/#{name}").dirname)
|
|
File.write("#{repo_dir}/#{name}", data)
|
|
`cd #{repo_dir} && git add #{name}`
|
|
end
|
|
`cd #{repo_dir} && git commit -am 'add #{files.size} files'`
|
|
repo_dir
|
|
end
|
|
|
|
def stub_const(target, const, value)
|
|
old = target.const_get(const)
|
|
target.send(:remove_const, const)
|
|
target.const_set(const, value)
|
|
yield
|
|
ensure
|
|
target.send(:remove_const, const)
|
|
target.const_set(const, old)
|
|
end
|
|
|
|
def track_sql_queries
|
|
queries = []
|
|
callback = ->(*, payload) do
|
|
queries << payload.fetch(:sql) if %w[CACHE SCHEMA].exclude?(payload.fetch(:name))
|
|
end
|
|
|
|
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
|
|
ActiveSupport::Notifications.subscribed(callback, "sql.mini_sql") { yield }
|
|
end
|
|
|
|
queries
|
|
end
|
|
|
|
def stub_ip_lookup(stub_addr, ips)
|
|
Addrinfo
|
|
.stubs(:getaddrinfo)
|
|
.with { |addr, _| addr == stub_addr }
|
|
.returns(
|
|
ips.map { |ip| Addrinfo.new([IPAddr.new(ip).ipv6? ? "AF_INET6" : "AF_INET", 80, nil, ip]) },
|
|
)
|
|
end
|
|
|
|
def with_search_indexer_enabled
|
|
SearchIndexer.enable
|
|
yield
|
|
ensure
|
|
SearchIndexer.disable
|
|
end
|
|
|
|
# Uploads a theme from a directory.
|
|
#
|
|
# @param set_theme_as_default [Boolean] Whether to set the uploaded theme as the default theme for the site. Defaults to true.
|
|
#
|
|
# @return [Theme] The uploaded theme model given by `models/theme.rb`.
|
|
#
|
|
# @example Upload a theme and set it as default
|
|
# upload_theme("/path/to/theme")
|
|
def upload_theme(set_theme_as_default: true)
|
|
theme = RemoteTheme.import_theme_from_directory(directory_from_caller)
|
|
|
|
if theme.component
|
|
raise NotAThemeError,
|
|
"Uploaded theme is a theme component, please use the `upload_theme_component` method instead."
|
|
end
|
|
|
|
theme.set_default! if set_theme_as_default
|
|
theme
|
|
end
|
|
|
|
# Invokes a Rake task in a way that is safe for the test environment
|
|
def invoke_rake_task(task_name, *args)
|
|
Rake::Task[task_name].invoke(*args)
|
|
ensure
|
|
Rake::Task[task_name].reenable
|
|
end
|
|
|
|
# Uploads a theme component from a directory.
|
|
#
|
|
# @param parent_theme_id [Integer] The ID of the theme to add the theme component to. Defaults to `SiteSetting.default_theme_id`.
|
|
#
|
|
# @return [Theme] The uploaded theme model given by `models/theme.rb`.
|
|
#
|
|
# @example Upload a theme component
|
|
# upload_theme_component("/path/to/theme_component")
|
|
#
|
|
# @example Upload a theme component and add it to a specific theme
|
|
# upload_theme_component("/path/to/theme_component", parent_theme_id: 123)
|
|
def upload_theme_component(parent_theme_id: SiteSetting.default_theme_id)
|
|
theme = RemoteTheme.import_theme_from_directory(directory_from_caller)
|
|
|
|
if !theme.component
|
|
raise NotAComponentThemeError,
|
|
"Uploaded theme is not a theme component, please use the `upload_theme` method instead."
|
|
end
|
|
|
|
Theme.find(parent_theme_id).child_themes << theme
|
|
theme
|
|
end
|
|
|
|
def upload_theme_or_component
|
|
upload_theme
|
|
rescue NotAThemeError
|
|
upload_theme_component
|
|
end
|
|
|
|
# Runs named migration for a given theme.
|
|
#
|
|
# @params [Theme] theme The theme to run the migration for.
|
|
# @params [String] migration_name The name of the migration to run.
|
|
#
|
|
# @return [nil]
|
|
#
|
|
# @example
|
|
# run_theme_migration(theme, "0001-migrate-some-settings")
|
|
def run_theme_migration(theme, migration_name)
|
|
migration_theme_field = theme.theme_fields.find_by(name: migration_name)
|
|
theme.migrate_settings(fields: [migration_theme_field], allow_out_of_sequence_migration: true)
|
|
nil
|
|
end
|
|
|
|
def enable_current_plugin
|
|
plugin = Discourse.plugins_by_name[directory_from_caller.split("/").last]
|
|
return if plugin.enabled?
|
|
SiteSetting.public_send("#{plugin.enabled_site_setting}=", true)
|
|
end
|
|
|
|
def try_until_success(timeout: 3, frequency: 0.01)
|
|
start ||= Time.zone.now
|
|
backoff ||= frequency
|
|
yield
|
|
rescue RSpec::Expectations::ExpectationNotMetError
|
|
raise if Time.zone.now >= start + timeout.seconds
|
|
sleep backoff
|
|
backoff += frequency
|
|
retry
|
|
end
|
|
|
|
def mock_upcoming_change_metadata(metadata)
|
|
@original_upcoming_changes_metadata = SiteSetting.upcoming_change_metadata.dup
|
|
|
|
# We do this because upcoming changes are ephemeral in site settings,
|
|
# so we cannot rely on them for specs. Instead we can fake some metadata
|
|
# for an existing stable setting.
|
|
SiteSetting.instance_variable_set(
|
|
:@upcoming_change_metadata,
|
|
@original_upcoming_changes_metadata.merge(metadata),
|
|
)
|
|
end
|
|
|
|
def clear_mocked_upcoming_change_metadata
|
|
SiteSetting.instance_variable_set(
|
|
:@upcoming_change_metadata,
|
|
@original_upcoming_changes_metadata,
|
|
)
|
|
end
|
|
|
|
private
|
|
|
|
def directory_from_caller
|
|
caller.each do |line|
|
|
if (split = line.split(%r{/spec/*/.+_spec.rb})).length > 1
|
|
return split.first
|
|
end
|
|
end
|
|
end
|
|
end
|