diff --git a/app/assets/stylesheets/components/topics.css b/app/assets/stylesheets/components/topics.css index 2cf4731..2c94d8f 100644 --- a/app/assets/stylesheets/components/topics.css +++ b/app/assets/stylesheets/components/topics.css @@ -331,6 +331,20 @@ a.topic-icon { } } +.activity-ignore { + display: none; + color: var(--color-text-secondary); +} + +.activity-ignore.is-ignored { + display: inline-block; + background-color: var(--color-bg-activity-team); +} + +.topic-row:hover .activity-ignore { + display: inline-block; +} + .is-hidden { display: none !important; } diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 1536fa3..e79fa03 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -1,14 +1,15 @@ class TopicsController < ApplicationController include DraftSidebarLoader - before_action :set_topic, only: [ :show, :message_batch, :attachments_sidebar, :patchsets_sidebar, :aware, :read_all, :unread_all, :star, :unstar, :latest_patchset, :summary, :messages ] - before_action :require_authentication, only: [ :aware, :read_all, :unread_all, :star, :unstar ] + before_action :set_topic, only: [ :show, :message_batch, :attachments_sidebar, :patchsets_sidebar, :aware, :read_all, :unread_all, :star, :unstar, :ignore, :unignore, :latest_patchset, :summary, :messages ] + before_action :require_authentication, only: [ :aware, :read_all, :unread_all, :star, :unstar, :ignore, :unignore ] TOPIC_LIST_PRELOADS = [ :creator, { creator_person: :default_alias }, { last_sender_person: :default_alias } ].freeze def index @search_query = nil base_query = Topic.includes(*TOPIC_LIST_PRELOADS) + base_query = apply_default_ignore_filter(base_query) if user_signed_in? apply_cursor_pagination(base_query) preload_topic_participants @@ -228,6 +229,30 @@ def unstar end end + def ignore + TopicIgnore.find_or_create_by!(user: current_user, topic: @topic) + respond_to do |format| + format.turbo_stream { render :update_ignore_state } + format.json { render json: { ignored: true } } + format.html { redirect_to topic_path(@topic) } + end + rescue ActiveRecord::RecordNotUnique + respond_to do |format| + format.turbo_stream { render :update_ignore_state } + format.json { render json: { ignored: true } } + format.html { redirect_to topic_path(@topic) } + end + end + + def unignore + TopicIgnore.where(user: current_user, topic: @topic).destroy_all + respond_to do |format| + format.turbo_stream { render :update_ignore_state } + format.json { render json: { ignored: false } } + format.html { redirect_to topic_path(@topic) } + end + end + def latest_patchset latest_message = latest_patchset_message return head :not_found unless latest_message @@ -569,6 +594,16 @@ def assign_branch_segments! end end + # Starring always wins: an ignored-and-starred topic still appears. + def apply_default_ignore_filter(base_query) + user_id = current_user.id + base_query.where( + "topics.id NOT IN (SELECT topic_id FROM topic_ignores WHERE user_id = ?) " \ + "OR topics.id IN (SELECT topic_id FROM topic_stars WHERE user_id = ?)", + user_id, user_id + ) + end + def apply_cursor_pagination(base_query) @viewing_since = viewing_since_param diff --git a/app/helpers/topics_helper.rb b/app/helpers/topics_helper.rb index 75d3e8b..edfdfeb 100644 --- a/app/helpers/topics_helper.rb +++ b/app/helpers/topics_helper.rb @@ -160,6 +160,24 @@ def star_icon_html(topic:, star_data:) end end + def ignore_icon_html(topic:, ignored:) + ignored = ignored || false + path = ignored ? unignore_topic_path(topic) : ignore_topic_path(topic) + method = ignored ? :delete : :post + icon_class = ignored ? "fa-solid fa-eye-slash" : "fa-regular fa-eye-slash" + classes = [ "topic-icon", "activity-ignore" ] + classes << "is-ignored" if ignored + + link_to path, + method: method, + data: { turbo_method: method, turbo_stream: true }, + class: classes.join(" "), + title: ignored ? "Unignore" : "Ignore", + id: dom_id(topic, "ignore_button") do + tag.i(class: icon_class) + end + end + # Replaces app/views/topics/_participation_icon.html.slim def participation_icon_html(topic:, participation:) participation = participation || {} diff --git a/app/models/topic_ignore.rb b/app/models/topic_ignore.rb new file mode 100644 index 0000000..bed9948 --- /dev/null +++ b/app/models/topic_ignore.rb @@ -0,0 +1,21 @@ +class TopicIgnore < ApplicationRecord + belongs_to :user + belongs_to :topic + + validates :user_id, uniqueness: { scope: :topic_id } + + def self.toggle_ignore(user:, topic:) + existing = find_by(user: user, topic: topic) + if existing + existing.destroy + false + else + create!(user: user, topic: topic) + true + end + end + + def self.ignored_by_user?(user:, topic:) + exists?(user: user, topic: topic) + end +end diff --git a/app/services/search/query_builder.rb b/app/services/search/query_builder.rb index 4e3deca..37f0687 100644 --- a/app/services/search/query_builder.rb +++ b/app/services/search/query_builder.rb @@ -20,6 +20,11 @@ def build return Result.new(relation: Topic.none, warnings: []) if @ast.nil? relation = apply_node(@ast, Topic.all) + + if @user && !has_ignored_selector?(@ast) + relation = apply_default_ignore_filter(relation) + end + Result.new(relation: relation, warnings: @warnings) end @@ -99,6 +104,8 @@ def apply_selector(node, relation) apply_new_selector(value, relation, negated: negated) when :starred apply_starred_selector(value, relation, negated: negated) + when :ignored + apply_ignored_selector(value, relation, negated: negated) when :notes apply_notes_selector(value, relation, negated: negated) when :tag @@ -579,6 +586,44 @@ def apply_starred_selector(value, relation, negated:) end end + def apply_ignored_selector(value, relation, negated:) + result = @value_resolver.resolve_state_subject(value) + @warnings.concat(result.warnings) + + user_ids = result.user_ids + return relation if user_ids.empty? + + ignored_topic_ids = TopicIgnore.where(user_id: user_ids).select(:topic_id) + + if negated + relation.where.not(id: ignored_topic_ids) + else + relation.where(id: ignored_topic_ids) + end + end + + def apply_default_ignore_filter(relation) + user_id = @user.id + relation.where( + "topics.id NOT IN (SELECT topic_id FROM topic_ignores WHERE user_id = ?) " \ + "OR topics.id IN (SELECT topic_id FROM topic_stars WHERE user_id = ?)", + user_id, user_id + ) + end + + def has_ignored_selector?(node) + return false if node.nil? + + case node[:type] + when :selector + node[:key] == :ignored + when :and, :or + node[:children].any? { |child| has_ignored_selector?(child) } + else + false + end + end + def apply_notes_selector(value, relation, negated:) result = @value_resolver.resolve_state_subject(value) @warnings.concat(result.warnings) diff --git a/app/services/search/query_parser.rb b/app/services/search/query_parser.rb index d138106..cbece74 100644 --- a/app/services/search/query_parser.rb +++ b/app/services/search/query_parser.rb @@ -43,7 +43,7 @@ class Grammar < Parslet::Parser str("title") | str("body") | str("contributors") | str("participants") | str("messages") | str("unread") | str("reading") | str("read") | str("new") | - str("starred") | str("notes") | str("tag") | + str("starred") | str("ignored") | str("notes") | str("tag") | str("has") | str("commitfest") | str("list") ).as(:selector_key) end diff --git a/app/services/search/query_validator.rb b/app/services/search/query_validator.rb index 04b71b9..4ed14c1 100644 --- a/app/services/search/query_validator.rb +++ b/app/services/search/query_validator.rb @@ -16,7 +16,7 @@ class QueryValidator AUTHOR_SELECTORS = %i[from starter last_from].freeze - STATE_SELECTORS = %i[unread read reading new starred notes].freeze + STATE_SELECTORS = %i[unread read reading new starred ignored notes].freeze CONTENT_SELECTORS = %i[title body].freeze diff --git a/app/services/topic_list_personalization.rb b/app/services/topic_list_personalization.rb index e19c622..43304b1 100644 --- a/app/services/topic_list_personalization.rb +++ b/app/services/topic_list_personalization.rb @@ -6,6 +6,7 @@ def initialize(user:, topics:) preload_states preload_note_counts preload_star_data + preload_ignore_data preload_participation end @@ -29,6 +30,10 @@ def star_data_for(topic) @star_data[topic.id] || { starred_by_me: false, team_starrers: [] } end + def ignored_for(topic) + @ignored_topic_ids.include?(topic.id) + end + private attr_reader :user, :topics, :topic_ids @@ -106,6 +111,13 @@ def preload_star_data end end + def preload_ignore_data + @ignored_topic_ids = Set.new + return if topic_ids.empty? + + @ignored_topic_ids = TopicIgnore.where(user:, topic_id: topic_ids).pluck(:topic_id).to_set + end + def preload_participation @participation = {} return if topic_ids.empty? diff --git a/app/views/topics/_status_cell.html.slim b/app/views/topics/_status_cell.html.slim index 75f3828..934cd3d 100644 --- a/app/views/topics/_status_cell.html.slim +++ b/app/views/topics/_status_cell.html.slim @@ -6,6 +6,7 @@ - reading_unread_count = [total_count - read_count, 0].max - status_class = "status-#{status}" - status_class = "#{status_class} has-new-replies" if status.to_s == "reading" +- ignored = local_assigns[:ignored] || false - star_data = star_data || {} - icons_html = capture do - if status.to_s == "reading" @@ -13,6 +14,7 @@ i.fa-solid.fa-envelope span.topic-icon-badge.topic-icon-badge-sup = reading_unread_count = star_icon_html(topic: topic, star_data: star_data) + = ignore_icon_html(topic: topic, ignored: ignored) = note_icon_html(topic: topic, count: note_count.to_i) = team_readers_icon_html(topic: topic, readers: team_readers) - commit_summary = @commit_summaries&.dig(topic.id) diff --git a/app/views/topics/_topics.html.slim b/app/views/topics/_topics.html.slim index f816000..c45e9e0 100644 --- a/app/views/topics/_topics.html.slim +++ b/app/views/topics/_topics.html.slim @@ -17,6 +17,7 @@ - participation = personalization ? personalization.participation_for(topic) : {} - team_readers = personalization ? personalization.team_readers_for(topic) : [] - star_data = personalization ? personalization.star_data_for(topic) : {} + - ignored = personalization ? personalization.ignored_for(topic) : false - row_class = [ "topic-row" ] - if personalization - status = state[:status] || "new" @@ -24,7 +25,7 @@ - row_class << "has-new-replies" if status.to_s == "reading" tr id=dom_id(topic) class=row_class.join(" ") data-topic-id=topic.id data-last-message-id=topic.last_message_id - = render partial: "topics/status_cell", locals: { topic: topic, state: state, note_count: note_count, team_readers: team_readers, star_data: star_data } + = render partial: "topics/status_cell", locals: { topic: topic, state: state, note_count: note_count, team_readers: team_readers, star_data: star_data, ignored: ignored } td.topic-mailing-lists data-label="Mailing Lists" - topic_lists = @topic_mailing_lists_map&.dig(topic.id) || [] - if topic_lists.any? diff --git a/app/views/topics/update_ignore_state.turbo_stream.slim b/app/views/topics/update_ignore_state.turbo_stream.slim new file mode 100644 index 0000000..f6c59af --- /dev/null +++ b/app/views/topics/update_ignore_state.turbo_stream.slim @@ -0,0 +1 @@ += turbo_stream.remove dom_id(@topic) diff --git a/config/routes.rb b/config/routes.rb index 4ab9e24..f40cf3a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,6 +81,8 @@ post :unread_all post :star delete :unstar + post :ignore + delete :unignore get :latest_patchset get :message_batch get :attachments_sidebar diff --git a/db/migrate/20260713113313_create_topic_ignores.rb b/db/migrate/20260713113313_create_topic_ignores.rb new file mode 100644 index 0000000..950a67c --- /dev/null +++ b/db/migrate/20260713113313_create_topic_ignores.rb @@ -0,0 +1,12 @@ +class CreateTopicIgnores < ActiveRecord::Migration[8.0] + def change + create_table :topic_ignores do |t| + t.references :user, null: false, foreign_key: true + t.references :topic, null: false, foreign_key: true + + t.timestamps + end + + add_index :topic_ignores, [ :user_id, :topic_id ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 327074e..898162a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -797,6 +797,16 @@ t.index ["user_id"], name: "index_thread_awarenesses_on_user_id" end + create_table "topic_ignores", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "topic_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["topic_id"], name: "index_topic_ignores_on_topic_id" + t.index ["user_id", "topic_id"], name: "index_topic_ignores_on_user_id_and_topic_id", unique: true + t.index ["user_id"], name: "index_topic_ignores_on_user_id" + end + create_table "topic_mailing_lists", force: :cascade do |t| t.bigint "topic_id", null: false t.bigint "mailing_list_id", null: false @@ -845,6 +855,18 @@ t.index ["user_id"], name: "index_topic_stars_on_user_id" end + create_table "topic_subscriptions", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "topic_id", null: false + t.string "unsubscribe_token", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["topic_id"], name: "index_topic_subscriptions_on_topic_id" + t.index ["unsubscribe_token"], name: "index_topic_subscriptions_on_unsubscribe_token", unique: true + t.index ["user_id", "topic_id"], name: "index_topic_subscriptions_on_user_id_and_topic_id", unique: true + t.index ["user_id"], name: "index_topic_subscriptions_on_user_id" + end + create_table "topics", force: :cascade do |t| t.string "title", null: false t.bigint "creator_id", null: false @@ -906,6 +928,7 @@ t.datetime "deleted_at" t.bigint "person_id", null: false t.enum "mention_restriction", default: "anyone", null: false, enum_type: "user_mention_restriction" + t.boolean "bold_unread_threads", default: false, null: false t.boolean "open_threads_at_first_unread", default: false, null: false t.datetime "last_login_at" t.boolean "collapse_read_messages", default: true, null: false @@ -918,7 +941,7 @@ add_foreign_key "admin_email_changes", "users", column: "performed_by_id" add_foreign_key "admin_email_changes", "users", column: "target_user_id" add_foreign_key "aliases", "people" - add_foreign_key "aliases", "users", validate: false + add_foreign_key "aliases", "users" add_foreign_key "attachments", "messages" add_foreign_key "commit_files", "commits" add_foreign_key "commit_people", "commits" @@ -945,7 +968,7 @@ add_foreign_key "message_read_ranges", "topics" add_foreign_key "message_read_ranges", "users" add_foreign_key "messages", "aliases", column: "sender_id" - add_foreign_key "messages", "identities", column: "sent_via_identity_id", validate: false + add_foreign_key "messages", "identities", column: "sent_via_identity_id" add_foreign_key "messages", "messages", column: "reply_to_id" add_foreign_key "messages", "people", column: "sender_person_id" add_foreign_key "messages", "topics" @@ -978,6 +1001,8 @@ add_foreign_key "team_members", "users" add_foreign_key "thread_awarenesses", "topics" add_foreign_key "thread_awarenesses", "users" + add_foreign_key "topic_ignores", "topics" + add_foreign_key "topic_ignores", "users" add_foreign_key "topic_mailing_lists", "mailing_lists" add_foreign_key "topic_mailing_lists", "topics" add_foreign_key "topic_merges", "topics", column: "source_topic_id" @@ -987,12 +1012,14 @@ add_foreign_key "topic_participants", "topics" add_foreign_key "topic_stars", "topics" add_foreign_key "topic_stars", "users" + add_foreign_key "topic_subscriptions", "topics" + add_foreign_key "topic_subscriptions", "users" add_foreign_key "topics", "aliases", column: "creator_id" add_foreign_key "topics", "messages", column: "last_message_id" add_foreign_key "topics", "people", column: "creator_person_id" add_foreign_key "topics", "people", column: "last_sender_person_id" add_foreign_key "topics", "topics", column: "merged_into_topic_id" - add_foreign_key "user_features", "users", validate: false + add_foreign_key "user_features", "users" add_foreign_key "user_tokens", "users" add_foreign_key "users", "people" end diff --git a/deploy/backup/private_tables.txt b/deploy/backup/private_tables.txt index cdc3b9e..e127158 100644 --- a/deploy/backup/private_tables.txt +++ b/deploy/backup/private_tables.txt @@ -19,6 +19,7 @@ saved_searches team_members teams thread_awarenesses +topic_ignores topic_merges topic_stars user_features diff --git a/spec/factories/topic_ignores.rb b/spec/factories/topic_ignores.rb new file mode 100644 index 0000000..4ddefbe --- /dev/null +++ b/spec/factories/topic_ignores.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :topic_ignore do + association :user + association :topic + end +end diff --git a/spec/models/topic_ignore_spec.rb b/spec/models/topic_ignore_spec.rb new file mode 100644 index 0000000..be9f155 --- /dev/null +++ b/spec/models/topic_ignore_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe TopicIgnore, type: :model do + describe 'associations' do + it { should belong_to(:user) } + it { should belong_to(:topic) } + end + + describe 'validations' do + it 'prevents duplicate ignores' do + user = create(:user) + topic = create(:topic) + create(:topic_ignore, user: user, topic: topic) + + duplicate = build(:topic_ignore, user: user, topic: topic) + expect(duplicate).not_to be_valid + end + end + + describe '.toggle_ignore' do + let(:user) { create(:user) } + let(:topic) { create(:topic) } + + it 'creates an ignore record if none exists' do + result = TopicIgnore.toggle_ignore(user: user, topic: topic) + expect(result).to be true + expect(TopicIgnore.count).to eq(1) + end + + it 'removes an ignore record if one exists' do + create(:topic_ignore, user: user, topic: topic) + result = TopicIgnore.toggle_ignore(user: user, topic: topic) + expect(result).to be false + expect(TopicIgnore.count).to eq(0) + end + end + + describe '.ignored_by_user?' do + let(:user) { create(:user) } + let(:topic) { create(:topic) } + + it 'returns true when user has ignored the topic' do + create(:topic_ignore, user: user, topic: topic) + expect(TopicIgnore.ignored_by_user?(user: user, topic: topic)).to be true + end + + it 'returns false when user has not ignored the topic' do + expect(TopicIgnore.ignored_by_user?(user: user, topic: topic)).to be false + end + end +end diff --git a/spec/requests/topics_spec.rb b/spec/requests/topics_spec.rb index 508eaf3..c5e9774 100644 --- a/spec/requests/topics_spec.rb +++ b/spec/requests/topics_spec.rb @@ -157,6 +157,34 @@ def attach_verified_alias(user, email:, primary: true) end end end + + context "ignore filter on index" do + let!(:user) { create(:user) } + let!(:normal_topic) { create(:topic) } + let!(:ignored_topic) { create(:topic) } + let!(:ignored_and_starred_topic) { create(:topic) } + let!(:msg1) { create(:message, topic: normal_topic, created_at: 3.hours.ago) } + let!(:msg2) { create(:message, topic: ignored_topic, created_at: 2.hours.ago) } + let!(:msg3) { create(:message, topic: ignored_and_starred_topic, created_at: 1.hour.ago) } + + before do + sign_in_as(user) + create(:topic_ignore, user: user, topic: ignored_topic) + create(:topic_ignore, user: user, topic: ignored_and_starred_topic) + create(:topic_star, user: user, topic: ignored_and_starred_topic) + end + + it "excludes ignored topics" do + get topics_path + expect(response.body).to include(normal_topic.title) + expect(response.body).not_to include(ignored_topic.title) + end + + it "shows ignored topics that are also starred (star wins)" do + get topics_path + expect(response.body).to include(ignored_and_starred_topic.title) + end + end end describe "GET /topics/:id" do @@ -519,6 +547,19 @@ def attach_verified_alias(user, email:, primary: true) expect(response).to have_http_status(:not_found) end end + + context "when signed in, search renders without user-state frame" do + let!(:user) { create(:user) } + let!(:topic1) { create(:topic, title: "pgconf planning") } + let!(:msg1) { create(:message, topic: topic1, body: "test") } + + before { sign_in_as(user) } + + it "does not include user-state-root turbo frame" do + get search_topics_path, params: { q: "pgconf" } + expect(response.body).not_to include('id="user-state-root"') + end + end end describe "GET /topics/search personalization" do @@ -715,6 +756,62 @@ def attach_verified_alias(user, email:, primary: true) expect(response.body).to include(message.body) end end + + describe "POST /topics/:id/ignore" do + let!(:user) { create(:user) } + let!(:creator) { create(:alias) } + let!(:topic) { create(:topic, creator: creator) } + let!(:message) { create(:message, topic: topic, sender: creator) } + + context "when signed in" do + before { sign_in_as(user) } + + it "creates a TopicIgnore record" do + expect { + post ignore_topic_path(topic), headers: { "Accept" => "application/json" } + }.to change(TopicIgnore, :count).by(1) + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body)["ignored"]).to be true + end + + it "is idempotent" do + create(:topic_ignore, user: user, topic: topic) + expect { + post ignore_topic_path(topic), headers: { "Accept" => "application/json" } + }.not_to change(TopicIgnore, :count) + expect(response).to have_http_status(:success) + end + end + + context "when not signed in" do + it "redirects to sign in" do + post ignore_topic_path(topic) + expect(response).to redirect_to(new_session_path) + end + end + end + + describe "DELETE /topics/:id/unignore" do + let!(:user) { create(:user) } + let!(:creator) { create(:alias) } + let!(:topic) { create(:topic, creator: creator) } + let!(:message) { create(:message, topic: topic, sender: creator) } + + context "when signed in" do + before do + sign_in_as(user) + create(:topic_ignore, user: user, topic: topic) + end + + it "destroys the TopicIgnore record" do + expect { + delete unignore_topic_path(topic), headers: { "Accept" => "application/json" } + }.to change(TopicIgnore, :count).by(-1) + expect(response).to have_http_status(:success) + expect(JSON.parse(response.body)["ignored"]).to be false + end + end + end end RSpec.describe 'Topics show — drafts sidebar', type: :request do diff --git a/spec/services/search/query_builder_spec.rb b/spec/services/search/query_builder_spec.rb index c71ebaa..c73404d 100644 --- a/spec/services/search/query_builder_spec.rb +++ b/spec/services/search/query_builder_spec.rb @@ -352,6 +352,54 @@ def build_query(query_string) end end + describe 'ignored:me' do + let!(:ignored_topic) { create(:topic) } + let!(:normal_topic) { create(:topic) } + let!(:ignored_starred_topic) { create(:topic) } + + before do + create(:topic_ignore, user: user, topic: ignored_topic) + create(:topic_ignore, user: user, topic: ignored_starred_topic) + create(:topic_star, user: user, topic: ignored_starred_topic) + end + + it 'returns only ignored topics when ignored:me is used' do + result = build_query('ignored:me') + expect(result.relation).to include(ignored_topic) + expect(result.relation).to include(ignored_starred_topic) + expect(result.relation).not_to include(normal_topic) + end + end + + describe 'default ignore exclusion' do + let!(:ignored_topic) { create(:topic) } + let!(:normal_topic) { create(:topic) } + let!(:ignored_starred_topic) { create(:topic) } + + before do + create(:topic_ignore, user: user, topic: ignored_topic) + create(:topic_ignore, user: user, topic: ignored_starred_topic) + create(:topic_star, user: user, topic: ignored_starred_topic) + end + + it 'excludes ignored topics from general search results' do + result = build_query('starred:me') + expect(result.relation).not_to include(ignored_topic) + end + + it 'includes ignored-but-starred topics (star wins)' do + result = build_query('starred:me') + expect(result.relation).to include(ignored_starred_topic) + end + + it 'excludes ignored topics from a general text search' do + ignored_topic.update!(title: 'unique-xyzzy-word') + create(:message, topic: ignored_topic, body: 'unique-xyzzy-word content') + result = build_query('unique-xyzzy-word') + expect(result.relation).not_to include(ignored_topic) + end + end + describe 'commitfest: selector' do let!(:tag) { create(:commitfest_tag) } let!(:commitfest_first) { create(:commitfest, name: 'PGX-Final') }