diff --git a/modules/wikis/app/services/wikis/concerns/update_reverse_inline_wiki_page_links.rb b/modules/wikis/app/services/wikis/concerns/update_reverse_inline_wiki_page_links.rb index be443b9b67c1..44828c1be663 100644 --- a/modules/wikis/app/services/wikis/concerns/update_reverse_inline_wiki_page_links.rb +++ b/modules/wikis/app/services/wikis/concerns/update_reverse_inline_wiki_page_links.rb @@ -32,16 +32,31 @@ module Wikis::Concerns module UpdateReverseInlineWikiPageLinks extend ActiveSupport::Concern + # Mirrors the prefix character class of the inline-text macro matcher. + # The trailing `(?!\w)` on the semantic branch keeps `#PROJ-1abc` from + # matching `#PROJ-1`; the numeric branch deliberately has no trailing + # boundary to preserve historic behaviour for inputs like `#13-blubb`. + # rubocop:disable Style/RedundantRegexpEscape + WP_REF_RE = / + (?:[[:space:],~>\#\(\[\-]|^)\# + (?: + (\d+) + | + (#{WorkPackage::SemanticIdentifier::SEMANTIC_ID_PATTERN.source})(?!\w) + ) + /x + # rubocop:enable Style/RedundantRegexpEscape + def update_reverse_inline_wiki_page_links(wiki_page) provider = Wikis::InternalProvider.enabled.first return if provider.nil? Wikis::ReverseInlinePageLink.where(provider:, identifier: wiki_page.id).delete_all - find_wp_links(wiki_page.text).uniq.each do |wp_id| - wp = WorkPackage.find_by(id: wp_id) - next if wp.nil? + identifiers = find_wp_links(wiki_page.text).uniq + return if identifiers.empty? + WorkPackage.where_display_id_in(identifiers).find_each do |wp| Wikis::ReverseInlinePageLink.create!(linkable: wp, provider:, identifier: wiki_page.id) end end @@ -51,9 +66,7 @@ def update_reverse_inline_wiki_page_links(wiki_page) def find_wp_links(text) return [] if text.blank? - # extracted prefix from lib/open_project/text_formatting/matchers/resource_links_matcher.rb - # adding # as additional prefix - text.scan(/(?:[[:space:],~>#\(\[\-]|^)#([0-9]+)/) # rubocop:disable Style/RedundantRegexpEscape + text.scan(WP_REF_RE).map { |numeric, semantic| numeric || semantic } end end end diff --git a/modules/wikis/spec/services/wiki_pages/create_service_spec.rb b/modules/wikis/spec/services/wiki_pages/create_service_spec.rb index 85b051128ec0..b75c34650bed 100644 --- a/modules/wikis/spec/services/wiki_pages/create_service_spec.rb +++ b/modules/wikis/spec/services/wiki_pages/create_service_spec.rb @@ -179,6 +179,20 @@ end end + context "when a numeric reference is immediately followed by alphanumeric text" do + # The numeric branch of WP_REF_RE has no trailing `(?!\w)` boundary — + # historic behaviour matches `#13` inside `#13-blubb` and similar + # shapes. Locked here so a future tightening of the boundary can't + # silently strip reverse-links from existing wiki content. + let(:text) { "Trailing: ##{work_package.id}abc" } + + it "still creates a reverse page link from the numeric prefix" do + subject + + expect(reverse_link_finder.count).to eq(1) + end + end + context "when the internal provider is disabled" do let(:internal_provider) { create(:internal_wiki_provider, enabled: false) } @@ -202,4 +216,124 @@ expect(Wikis::ReverseInlinePageLink.count).to eq(0) end end + + context "with a semantic-identifier reference", + with_flag: { semantic_work_package_ids: true }, + with_settings: { work_packages_identifier: "semantic" } do + let(:project) { create(:project, :semantic) } + let(:work_package) do + create(:work_package, project:).tap do |wp| + wp.allocate_and_register_semantic_id + wp.reload + end + end + + context "when the reference uses the semantic identifier" do + let(:text) { "See ##{work_package.identifier} for context." } + + it "creates a reverse page link" do + subject + + expect(reverse_link_finder.count).to eq(1) + end + end + + context "when the semantic reference uses the ## widget syntax" do + let(:text) { "Block: ###{work_package.identifier}." } + + it "creates a reverse page link" do + subject + + expect(reverse_link_finder.count).to eq(1) + end + end + + context "when the semantic reference uses the ### widget syntax" do + let(:text) { "Detailed: ####{work_package.identifier}." } + + it "creates a reverse page link" do + subject + + expect(reverse_link_finder.count).to eq(1) + end + end + + context "when the project has been renamed and a historical alias is referenced" do + let(:text) { "Historical: #OLD-#{work_package.sequence_number}." } + + before do + WorkPackageSemanticAlias.create!(work_package:, identifier: "OLD-#{work_package.sequence_number}") + end + + it "still creates a reverse page link" do + subject + + expect(reverse_link_finder.count).to eq(1) + end + end + + context "when no work package matches the semantic reference" do + let(:text) { "Missing: #GHOST-99." } + + it "does not create a link" do + subject + + expect(Wikis::ReverseInlinePageLink.count).to eq(0) + end + end + + context "when the semantic identifier is followed by an alphanumeric word character" do + let(:text) { "Boundary: ##{work_package.identifier}abc." } + + it "does not create a link" do + subject + + expect(Wikis::ReverseInlinePageLink.count).to eq(0) + end + end + + context "when the body mixes a numeric and a semantic reference" do + let(:numeric_work_package) { create(:work_package) } + let(:text) do + "Mixed: ##{numeric_work_package.id} and ##{work_package.identifier}." + end + + it "creates a reverse page link per referenced work package" do + subject + + wiki_page = WikiPage.first + links = Wikis::ReverseInlinePageLink.where(provider: internal_provider, identifier: wiki_page.id) + expect(links.pluck(:linkable_id)).to contain_exactly(numeric_work_package.id, work_package.id) + end + end + + context "when several reference shapes resolve to the same work package" do + let(:text) do + "Triple: ##{work_package.id}, ##{work_package.identifier}, #OLD-#{work_package.sequence_number}." + end + + before do + WorkPackageSemanticAlias.create!(work_package:, identifier: "OLD-#{work_package.sequence_number}") + end + + it "creates a single reverse page link" do + subject + + expect(reverse_link_finder.count).to eq(1) + expect(reverse_link_finder.first.linkable).to eq(work_package) + end + end + end + + context "with a semantic-shape reference in classic mode", + with_flag: { semantic_work_package_ids: false }, + with_settings: { work_packages_identifier: "classic" } do + let(:text) { "See #PROJ-1 for context." } + + it "does not create a link" do + subject + + expect(Wikis::ReverseInlinePageLink.count).to eq(0) + end + end end