From 9bd241c8e9178e7bdee87fa4813ba674a187f968 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 24 Apr 2026 11:00:27 +0200 Subject: [PATCH] fix(cross-links): use codex path shape in fallback error URL When the codex link index can't be fetched (e.g. CI without credentials to reach the private codex-link-index repo), the cross-link resolver falls through to an error message containing a best-effort URL to the repo's links.json. That fallback was hardcoded to the public-S3 layout `elastic/{scheme}/main/links.json`, so errors against a codex/internal registry pointed at a non-existent path under codex-link-index. Thread the per-repo DocSetRegistry through FetchedCrossLinks and pick the codex layout (`{env}/elastic/{scheme}/links.json`) when the entry is non-public, keeping the existing S3 layout for public entries. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CrossLinks/CrossLinkFetcher.cs | 7 ++ .../CrossLinks/CrossLinkResolver.cs | 20 ++++- .../DocSetConfigurationCrossLinkFetcher.cs | 3 + .../CrossLinks/UriEnvironmentResolverTests.cs | 80 +++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs index c0c7d21e76..8e84d42754 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs @@ -28,6 +28,12 @@ public record FetchedCrossLinks /// public FrozenDictionary? RegistryUrlsByRepository { get; init; } + /// + /// Optional map of repository name to the declared for that cross-link entry. + /// Used to pick the correct links.json path shape in error messages when the index could not be fetched. + /// + public FrozenDictionary? RegistryByRepository { get; init; } + /// /// Set of repository names that belong to a codex (non-public) registry. /// Used by the URI resolver to generate codex URLs instead of public preview URLs. @@ -40,6 +46,7 @@ public record FetchedCrossLinks LinkReferences = new Dictionary().ToFrozenDictionary(), LinkIndexEntries = new Dictionary().ToFrozenDictionary(), RegistryUrlsByRepository = null, + RegistryByRepository = null, CodexRepositories = null }; } diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs index 4fd7eb6206..abaf584c2b 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs @@ -4,6 +4,7 @@ using System.Collections.Frozen; using System.Diagnostics.CodeAnalysis; +using Elastic.Documentation.Configuration; namespace Elastic.Documentation.Links.CrossLinks; @@ -107,7 +108,7 @@ public static bool TryResolve( var baseUrl = GetLinksJsonBaseUrl(registryUrl); var linksJson = fetchedCrossLinks.LinkIndexEntries.TryGetValue(crossLinkUri.Scheme, out var indexEntry) ? $"{baseUrl}/{indexEntry.Path}" - : $"{baseUrl}/elastic/{crossLinkUri.Scheme}/main/links.json"; + : BuildFallbackLinksJsonUrl(baseUrl, crossLinkUri.Scheme, fetchedCrossLinks); errorEmitter($"'{originalLookupPath}' is not a valid link in the '{crossLinkUri.Scheme}' cross link index: {linksJson}"); resolvedUri = null; @@ -257,4 +258,21 @@ private static string GetLinksJsonBaseUrl(string registryUrl) return registryUrl.Replace("/link-index.json", "", StringComparison.OrdinalIgnoreCase).TrimEnd('/'); return registryUrl.TrimEnd('/'); } + + /// + /// Builds a best-effort links.json URL to show in error messages when the index could not be fetched + /// and no is available. Codex/internal indexes use + /// {env}/elastic/{scheme}/links.json; the public S3 index uses elastic/{scheme}/main/links.json. + /// + private static string BuildFallbackLinksJsonUrl(string baseUrl, string scheme, FetchedCrossLinks fetchedCrossLinks) + { + if (fetchedCrossLinks.RegistryByRepository is not null + && fetchedCrossLinks.RegistryByRepository.TryGetValue(scheme, out var registry) + && registry != DocSetRegistry.Public) + { + return $"{baseUrl}/{registry.ToStringFast(true)}/elastic/{scheme}/links.json"; + } + + return $"{baseUrl}/elastic/{scheme}/main/links.json"; + } } diff --git a/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs index 0af5a856b4..b091ee5ea3 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs @@ -27,6 +27,7 @@ public override async Task FetchCrossLinks(Cancel ctx) var linkReferences = new Dictionary(); var linkIndexEntries = new Dictionary(); var registryUrlsByRepository = new Dictionary(); + var registryByRepository = new Dictionary(); var codexRepositories = new HashSet(); var declaredRepositories = new HashSet(); @@ -36,6 +37,7 @@ public override async Task FetchCrossLinks(Cancel ctx) foreach (var entry in configuration.CrossLinkEntries) { _ = declaredRepositories.Add(entry.Repository); + registryByRepository[entry.Repository] = entry.Registry; var isCodexEntry = useDualRegistry && entry.Registry != DocSetRegistry.Public; var reader = isCodexEntry ? _codexReader! : publicReader; @@ -85,6 +87,7 @@ public override async Task FetchCrossLinks(Cancel ctx) LinkReferences = linkReferences.ToFrozenDictionary(), LinkIndexEntries = linkIndexEntries.ToFrozenDictionary(), RegistryUrlsByRepository = registryUrlsByRepository.ToFrozenDictionary(), + RegistryByRepository = registryByRepository.ToFrozenDictionary(), CodexRepositories = codexRepositories.Count > 0 ? codexRepositories.ToFrozenSet() : null, }; } diff --git a/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs b/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs index bfc189f6fa..8072ebba37 100644 --- a/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs +++ b/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs @@ -4,6 +4,9 @@ using System.Collections.Frozen; using AwesomeAssertions; +using Elastic.Documentation; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; namespace Elastic.Markdown.Tests.CrossLinks; @@ -223,3 +226,80 @@ public void CrossRepoRedirect_TargetInCodexRepo_ResolvesToCodexPath() resolvedUri.ToString().Should().Be("/r/kibana/get-started"); } } + +public class CrossLinkResolverFallbackUrlTests +{ + private static FetchedCrossLinks BuildFallbackOnlyCrossLinks(string repository, string registryUrl, DocSetRegistry registry) + { + var emptyRepositoryLinks = new RepositoryLinks + { + Links = [], + Origin = new GitCheckoutInformation + { + Branch = "main", + RepositoryName = repository, + Remote = "origin", + Ref = "refs/heads/main" + }, + UrlPathPrefix = "", + CrossLinks = [] + }; + return new FetchedCrossLinks + { + DeclaredRepositories = [repository], + LinkReferences = new Dictionary { [repository] = emptyRepositoryLinks }.ToFrozenDictionary(), + LinkIndexEntries = new Dictionary().ToFrozenDictionary(), + RegistryUrlsByRepository = new Dictionary { [repository] = registryUrl }.ToFrozenDictionary(), + RegistryByRepository = new Dictionary { [repository] = registry }.ToFrozenDictionary() + }; + } + + [Fact] + public void InternalRegistry_NoIndexEntry_UsesCodexInternalPath() + { + var crossLinks = BuildFallbackOnlyCrossLinks( + "platform-observability-team", + "https://github.com/elastic/codex-link-index", + DocSetRegistry.Internal + ); + + string? emittedError = null; + var resolver = new IsolatedBuildEnvironmentUriResolver(); + var success = CrossLinkResolver.TryResolve( + s => emittedError = s, + crossLinks, + resolver, + new Uri("platform-observability-team://index.md", UriKind.Absolute), + out _ + ); + + success.Should().BeFalse(); + emittedError.Should().NotBeNull(); + emittedError.Should().Contain("https://github.com/elastic/codex-link-index/blob/main/internal/elastic/platform-observability-team/links.json"); + emittedError.Should().NotContain("/main/links.json"); + } + + [Fact] + public void PublicRegistry_NoIndexEntry_UsesPublicS3Path() + { + var crossLinks = BuildFallbackOnlyCrossLinks( + "docs-content", + "https://elastic-docs-link-index.s3.us-east-2.amazonaws.com", + DocSetRegistry.Public + ); + + string? emittedError = null; + var resolver = new IsolatedBuildEnvironmentUriResolver(); + var success = CrossLinkResolver.TryResolve( + s => emittedError = s, + crossLinks, + resolver, + new Uri("docs-content://index.md", UriKind.Absolute), + out _ + ); + + success.Should().BeFalse(); + emittedError.Should().NotBeNull(); + emittedError.Should().Contain("https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/docs-content/main/links.json"); + } +}