diff --git a/app/services/search/query_builder.rb b/app/services/search/query_builder.rb index 331cc9b..c08aec3 100644 --- a/app/services/search/query_builder.rb +++ b/app/services/search/query_builder.rb @@ -123,6 +123,8 @@ def apply_selector(node, relation) apply_last_date_selector(:>=, value, relation, negated: negated) when :last_before apply_last_date_selector(:<, value, relation, negated: negated) + when :commitfest + apply_commitfest_selector(value, relation, negated: negated, conditions: conditions) else @warnings << "Unknown selector: #{key}" relation @@ -842,6 +844,53 @@ def apply_last_date_selector(operator, value, relation, negated:) relation.where("topics.last_message_at #{actual_operator} ?", date) end + def apply_commitfest_selector(value, relation, negated:, conditions:) + relation = relation.left_joins( + commitfest_patch_topics: { + commitfest_patch: [ + :commitfest_tags, + { commitfest_patch_commitfests: :commitfest } + ] + } + ) + + if value.present? + if negated + relation = relation.where.not(commitfest: { name: value }) + else + relation = relation.where(commitfest: { name: value }) + end + end + + apply_commitfest_with_conditions(relation, negated: negated, conditions: conditions || []) + end + + def apply_commitfest_with_conditions(relation, negated:, conditions:) + conditions.each do |condition| + value = condition[:value] + + relation = case condition[:key] + when :name + column = Commitfest.arel_table[:name] + apply_commitfest_filter(relation, column, value, negated:) + when :status + column = CommitfestPatchCommitfest.arel_table[:status] + apply_commitfest_filter(relation, column, value, negated:) + when :tag + column = CommitfestTag.arel_table[:name] + apply_commitfest_filter(relation, column, value, negated:) + end + end + + relation + end + + def apply_commitfest_filter(relation, column, value, negated:) + predicate = column.lower.eq(value.downcase) + predicate = predicate.not if negated + relation.where(predicate) + end + # === Helpers === def sanitize_like(value) diff --git a/app/services/search/query_parser.rb b/app/services/search/query_parser.rb index 5a52486..c90ba2a 100644 --- a/app/services/search/query_parser.rb +++ b/app/services/search/query_parser.rb @@ -44,7 +44,7 @@ class Grammar < Parslet::Parser str("contributors") | str("participants") | str("messages") | str("unread") | str("reading") | str("read") | str("new") | str("starred") | str("notes") | str("tag") | - str("has") + str("has") | str("commitfest") ).as(:selector_key) end @@ -60,7 +60,7 @@ class Grammar < Parslet::Parser str("first_after") | str("first_before") | str("added_after") | str("added_before") | str("messages") | str("count") | str("from") | - str("body") | str("name") + str("body") | str("name") | str("status") | str("tag") ).as(:condition_key) end diff --git a/app/services/search/query_validator.rb b/app/services/search/query_validator.rb index fee6b2f..a86fb4c 100644 --- a/app/services/search/query_validator.rb +++ b/app/services/search/query_validator.rb @@ -22,10 +22,13 @@ class QueryValidator TAG_SELECTORS = %i[tag].freeze + COMMITFEST_SELECTORS = %i[commitfest] + ALL_SELECTORS = (DATE_SELECTORS + COUNT_SELECTORS + AUTHOR_SELECTORS + - STATE_SELECTORS + CONTENT_SELECTORS + TAG_SELECTORS + [ :has ]).freeze + STATE_SELECTORS + CONTENT_SELECTORS + TAG_SELECTORS + + COMMITFEST_SELECTORS + [ :has ]).freeze - HAS_VALUES = %w[attachment patch contributor committer core_team].freeze + HAS_VALUES = %w[attachment patch contributor committer core_team commitfest].freeze # Valid sub-conditions for each parent selector VALID_SUB_CONDITIONS = { @@ -34,7 +37,8 @@ class QueryValidator attachment: %i[from count name], patch: %i[from count] }, - tag: %i[from added_before added_after] + tag: %i[from added_before added_after], + commitfest: %i[name status tag] }.freeze # Sub-condition keywords that require date values @@ -104,6 +108,8 @@ def validate_selector(node) validate_content_selector(node) when *TAG_SELECTORS validate_tag_selector(node) + when *COMMITFEST_SELECTORS + validate_commitfest_selector(node) when :has validate_has_selector(node) else @@ -123,8 +129,8 @@ def validate_selector(node) end def supports_empty_value_with_conditions?(key, conditions) - # tag: can have empty value with conditions (e.g., tag:[from:me]) - key == :tag && conditions.present? + # tag: and commitfest: can have empty value with conditions (e.g., tag:[from:me], commitfest:[tag:bugfix]) + key.in?([ :tag, :commitfest ]) && conditions.present? end def validate_conditions(parent_key, parent_value, conditions) @@ -182,6 +188,8 @@ def get_valid_sub_conditions(parent_key, parent_value) has_conditions[normalized_value] || [] when :tag VALID_SUB_CONDITIONS[:tag] || [] + when :commitfest + VALID_SUB_CONDITIONS[:commitfest] || [] else [] end @@ -252,6 +260,10 @@ def validate_tag_selector(node) node end + def validate_commitfest_selector(node) + node + end + def validate_has_selector(node) value = node[:value].to_s.downcase diff --git a/app/views/help/pages/search.md b/app/views/help/pages/search.md index c9709aa..7ddd417 100644 --- a/app/views/help/pages/search.md +++ b/app/views/help/pages/search.md @@ -105,6 +105,19 @@ last_after:1w # Topics with activity in the last week messages_before:yesterday # Messages sent before yesterday ``` +### Commitfest Selectors + +Search for topics by commitfest. + + +| Selector | Description | Example | +|----------|-------------|---------| +| `commitfest:name` | Topics from commitfest with this name | `commitfest:PG19-Final` | +| `commitfest:[name:name]` | Topics from commitfest with this name | `commitfest:[name:PG19-Draft]` | +| `commitfest:[status:status]` | Topics from commitfest with this status | `commitfest:[status:commited]` | +| `commitfest:[tag:tag]` | Topics from commitfest with this tag | `commitfest:[tag:bugfix]` | + + ### Count Selectors | Selector | Description | Example | diff --git a/spec/factories/commitfest_patch_commitfests.rb b/spec/factories/commitfest_patch_commitfests.rb new file mode 100644 index 0000000..ef1b56f --- /dev/null +++ b/spec/factories/commitfest_patch_commitfests.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :commitfest_patch_commitfest do + commitfest + commitfest_patch + + status { "Open" } + end +end diff --git a/spec/factories/commitfest_patch_tags.rb b/spec/factories/commitfest_patch_tags.rb new file mode 100644 index 0000000..60d7612 --- /dev/null +++ b/spec/factories/commitfest_patch_tags.rb @@ -0,0 +1,4 @@ +FactoryBot.define do + factory :commitfest_patch_tag do + end +end diff --git a/spec/factories/commitfest_patch_topics.rb b/spec/factories/commitfest_patch_topics.rb new file mode 100644 index 0000000..1e292fb --- /dev/null +++ b/spec/factories/commitfest_patch_topics.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :commitfest_patch_topic do + commitfest_patch + topic + end +end diff --git a/spec/factories/commitfest_patches.rb b/spec/factories/commitfest_patches.rb new file mode 100644 index 0000000..552003e --- /dev/null +++ b/spec/factories/commitfest_patches.rb @@ -0,0 +1,27 @@ +FactoryBot.define do + factory :commitfest_patch do + sequence(:external_id) + sequence(:title) { |n| "Patch Title ##{n}" } + + trait :with_topic do + transient { topic { create(:topic) } } + after(:create) do |cp, ctx| + create(:commitfest_patch_topic, commitfest_patch: cp, topic: ctx.topic) + end + end + + trait :with_commitfest do + transient { commitfest { create(:commitfest) } } + after(:create) do |cp, ctx| + create(:commitfest_patch_commitfest, commitfest_patch: cp, commitfest: ctx.commitfest) + end + end + + trait :with_tag do + transient { commitfest_tag { create(:commitfest_tag) } } + after(:create) do |cp, ctx| + create(:commitfest_patch_tag, commitfest_patch: cp, commitfest_tag: ctx.commitfest_tag) + end + end + end +end diff --git a/spec/factories/commitfest_tags.rb b/spec/factories/commitfest_tags.rb new file mode 100644 index 0000000..867fa4a --- /dev/null +++ b/spec/factories/commitfest_tags.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :commitfest_tag do + name { "Bugfix" } + end +end diff --git a/spec/factories/commitfests.rb b/spec/factories/commitfests.rb new file mode 100644 index 0000000..f605285 --- /dev/null +++ b/spec/factories/commitfests.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :commitfest do + sequence(:external_id) + sequence(:name) { |n| "PG#{n}-Final" } + status { "Open" } + start_date { 1.month.ago } + end_date { 1.month.from_now } + end +end diff --git a/spec/services/search/query_builder_spec.rb b/spec/services/search/query_builder_spec.rb index 8ea425e..c71ebaa 100644 --- a/spec/services/search/query_builder_spec.rb +++ b/spec/services/search/query_builder_spec.rb @@ -351,6 +351,45 @@ def build_query(query_string) end end end + + describe 'commitfest: selector' do + let!(:tag) { create(:commitfest_tag) } + let!(:commitfest_first) { create(:commitfest, name: 'PGX-Final') } + let!(:commitfest_second) { create(:commitfest) } + let!(:topic_first) { create(:topic) } + let!(:topic_second) { create(:topic) } + + let!(:patch_first) { create(:commitfest_patch, :with_topic, :with_commitfest, :with_tag, topic: topic_first, commitfest: commitfest_first, commitfest_tag: tag) } + let!(:patch_second) { create(:commitfest_patch, :with_topic, topic: topic_second) } + + before do + create(:commitfest_patch_commitfest, commitfest: commitfest_second, commitfest_patch: patch_second, status: 'Commited') + end + + it 'filters topics with specific commitfest name' do + result = build_query('commitfest:PGX-Final') + expect(result.relation).to include(topic_first) + expect(result.relation).not_to include(topic_second) + end + + it 'filters topics with specific commitfest name from condition' do + result = build_query('commitfest:[name:PGX-Final]') + expect(result.relation).to include(topic_first) + expect(result.relation).not_to include(topic_second) + end + + it 'filters topics with specific commitfest status' do + result = build_query('commitfest:[status:commited]') + expect(result.relation).to include(topic_second) + expect(result.relation).not_to include(topic_first) + end + + it 'filters topics with specific commitfest tag' do + result = build_query('commitfest:[tag:bugfix]') + expect(result.relation).to include(topic_first) + expect(result.relation).not_to include(topic_second) + end + end end describe 'negation' do diff --git a/spec/services/search/query_parser_spec.rb b/spec/services/search/query_parser_spec.rb index 602399c..cc15d61 100644 --- a/spec/services/search/query_parser_spec.rb +++ b/spec/services/search/query_parser_spec.rb @@ -113,6 +113,31 @@ expect(result[:value]).to eq('') expect(result[:conditions].size).to eq(1) end + + it 'parses commitfest: selector' do + result = parser.parse('commitfest:PG19-Final') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:commitfest) + expect(result[:value]).to eq('PG19-Final') + end + + it 'parses commitfest: selector with status: condition' do + result = parser.parse('commitfest:PG19-Draft[status:commited]') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:commitfest) + expect(result[:value]).to eq('PG19-Draft') + expect(result[:conditions].size).to eq(1) + expect(result[:conditions][0][:key]).to eq(:status) + expect(result[:conditions][0][:value]).to eq('commited') + end + + it 'parses commitfest: selector with empty value and from: condition' do + result = parser.parse('commitfest:[tag:bugfix]') + expect(result[:type]).to eq(:selector) + expect(result[:key]).to eq(:commitfest) + expect(result[:value]).to eq('') + expect(result[:conditions].size).to eq(1) + end end context 'with negation' do diff --git a/spec/services/search/query_validator_spec.rb b/spec/services/search/query_validator_spec.rb index 8abdd84..38ae377 100644 --- a/spec/services/search/query_validator_spec.rb +++ b/spec/services/search/query_validator_spec.rb @@ -145,6 +145,42 @@ end end + context 'with valid commitfest: selector' do + it 'passes valid commitfest name' do + ast = parser.parse('commitfest:PG19-Final') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + expect(result.ast[:key]).to eq(:commitfest) + end + + it 'passes commitfest name with status: condition' do + ast = parser.parse('commitfest:PG19-Final[status:commited]') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + expect(result.ast[:conditions].size).to eq(1) + end + + it 'passes commitfest name with tag: condition' do + ast = parser.parse('commitfest:PG19-Final[tag:commited]') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + expect(result.ast[:conditions].size).to eq(1) + end + + it 'passes commitfest with multiple conditions' do + ast = parser.parse('commitfest:PG19-Draft[status:in_progress, tag:bugfix]') + result = described_class.new(ast).validate + expect(result.warnings).to be_empty + expect(result.ast[:conditions].size).to eq(2) + end + + it 'warns on invalid condition for commitfest selector' do + ast = parser.parse('commitfest:[messages:>=10]') + result = described_class.new(ast).validate + expect(result.warnings).to include(/not valid for 'commitfest:'/i) + end + end + context 'with compound expressions' do it 'validates children of AND expressions' do ast = parser.parse('from:john first_after:invalid')