Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,80 @@ public static string BundleRegistryKey(string productGroup) =>
public static string ChangelogRegistryKey(string poolGroup) =>
$"{ChangelogPrefix}{poolGroup}/{RegistryFileName}";

/// <summary>
/// The notes-index key for one target within a repo: <c>changelog/{org}/{repo}/notes-{target}.json</c>.
/// Repo-level and branch-agnostic — all notes for a target, regardless of which branch they were authored on.
/// </summary>
public static string NotesIndexKey(string org, string repo, string target) =>
$"{ChangelogPrefix}{org}/{repo}/notes-{target}.json";

/// <summary>
/// The S3 prefix that covers all branches and notes indexes of one repo: <c>changelog/{org}/{repo}/</c>.
/// Used by the notes reconciler to list the full repo tree.
/// </summary>
public static string RepoPrefix(string org, string repo) =>
$"{ChangelogPrefix}{org}/{repo}/";

/// <summary>
/// Returns true when <paramref name="key"/> is a notes-index key of the form
/// <c>changelog/{org}/{repo}/notes-{target}.json</c> (exactly two group segments, then
/// a <c>notes-</c>-prefixed JSON file with a non-empty target slug).
/// </summary>
public static bool IsNotesIndex(string key)
{
if (!key.StartsWith(ChangelogPrefix, StringComparison.Ordinal))
return false;
if (!key.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
return false;

var rest = key.AsSpan(ChangelogPrefix.Length);

// Expect exactly {org}/{repo}/notes-{target}.json
var firstSlash = rest.IndexOf('/');
if (firstSlash <= 0)
return false;
var org = rest[..firstSlash];

var afterOrg = rest[(firstSlash + 1)..];
var secondSlash = afterOrg.IndexOf('/');
if (secondSlash <= 0)
return false;
var repo = afterOrg[..secondSlash];

var file = afterOrg[(secondSlash + 1)..];

// No further slashes — this must be a direct child of {org}/{repo}/
if (file.IndexOf('/') >= 0)
return false;

// Validate org/repo segments and require the notes- prefix with a non-empty target slug
if (!IsValidSegment(org, SegmentKind.Org) || !IsValidSegment(repo, SegmentKind.RepoOrBranch))
return false;

const string notesPrefix = "notes-";
if (!file.StartsWith(notesPrefix, StringComparison.Ordinal))
return false;

var targetSlug = file[notesPrefix.Length..^".json".Length];
return targetSlug.Length > 0 && IsValidSegment(targetSlug, SegmentKind.RepoOrBranch);
}

/// <summary>
/// Extracts the <c>{org}/{repo}</c> group from a <c>changelog/{org}/{repo}/notes-{target}.json</c> key.
/// Returns null when the key is not a valid notes-index key.
/// </summary>
public static string? ExtractNotesRepo(string key)
{
if (!IsNotesIndex(key))
return null;

var rest = key.AsSpan(ChangelogPrefix.Length);
var firstSlash = rest.IndexOf('/');
var afterOrg = rest[(firstSlash + 1)..];
var secondSlash = afterOrg.IndexOf('/');
return $"{rest[..firstSlash]}/{afterOrg[..secondSlash]}";
}

/// <summary>
/// Extracts the product group from a <c>bundle/{product}/{file}</c> key, or null when
/// <paramref name="s3Key"/> is not a bundle key with a valid product segment ahead of the file name.
Expand Down
3 changes: 2 additions & 1 deletion src/infra/docs-lambda-changelog-scrubber/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ async Task<SQSBatchResponse> Handler(SQSEvent ev, ILambdaContext context)
var scrubber = new ChangelogContentScrubber(logFactory, allowRepos);
var reconciler = new BundleRegistryReconciler(logFactory, s3Client, publicBucketName, metrics: metrics);
var shallowReconciler = new ShallowRegistryReconciler(logFactory, s3Client, publicBucketName, metrics: metrics);
var processor = new ScrubberProcessor(logFactory, s3Client, publicBucketName, scrubber, reconciler, shallowReconciler, metrics);
var notesReconciler = new NotesIndexReconciler(logFactory, s3Client, publicBucketName, metrics: metrics);
var processor = new ScrubberProcessor(logFactory, s3Client, publicBucketName, scrubber, reconciler, shallowReconciler, notesReconciler, metrics);

var messages = ev.Records.Select(r => new ScrubberQueueMessage(r.MessageId, r.Body)).ToList();
var failedIds = await processor.ProcessAsync(messages, CancellationToken.None);
Expand Down
48 changes: 33 additions & 15 deletions src/services/Elastic.Changelog/Reconciliation/ChangelogScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,27 @@

namespace Elastic.Changelog.Reconciliation;

/// <summary>The two registry scope families in the changelog bucket key layout.</summary>
/// <summary>The registry scope families in the changelog bucket key layout.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<ChangelogScopeKind>))]
public enum ChangelogScopeKind
{
/// <summary>A product bundle scope: <c>bundle/{product}/…</c>.</summary>
Bundle,

/// <summary>An authoring-pool scope: <c>changelog/{org}/{repo}/{branch}/…</c>.</summary>
Changelog
Changelog,

/// <summary>A repo-level notes scope: <c>changelog/{org}/{repo}/</c> (branch-agnostic).</summary>
Notes
}

/// <summary>
/// Identifies one registry scope in the changelog bundles bucket — a product bundle pool
/// (<c>bundle/{product}/</c>) or an authoring changelog pool
/// (<c>changelog/{org}/{repo}/{branch}/</c>) — and derives the scope's key prefix and
/// <c>registry.json</c> key. Segments are validated on construction via
/// <see cref="ChangelogKeys"/>, so a scope instance can always be composed into safe S3 keys.
/// (<c>bundle/{product}/</c>), an authoring changelog pool
/// (<c>changelog/{org}/{repo}/{branch}/</c>), or a repo-level notes scope
/// (<c>changelog/{org}/{repo}/</c>) — and derives the scope's key prefix. Segments are
/// validated on construction via <see cref="ChangelogKeys"/>, so a scope instance can
/// always be composed into safe S3 keys.
/// </summary>
public sealed record ChangelogScope
{
Expand All @@ -39,16 +43,20 @@ private ChangelogScope(ChangelogScopeKind kind, string group)

/// <summary>
/// The grouping segment(s): the product for a bundle scope, the
/// <c>{org}/{repo}/{branch}</c> prefix for a changelog scope.
/// <c>{org}/{repo}/{branch}</c> prefix for a changelog scope, or
/// <c>{org}/{repo}</c> for a notes scope.
/// </summary>
public string Group { get; }

/// <summary>The S3 key prefix of every object in this scope, ending in <c>/</c>.</summary>
public string Prefix => Kind == ChangelogScopeKind.Bundle
? $"{ChangelogKeys.BundlePrefix}{Group}/"
: $"{ChangelogKeys.ChangelogPrefix}{Group}/";
public string Prefix => Kind switch
{
ChangelogScopeKind.Bundle => $"{ChangelogKeys.BundlePrefix}{Group}/",
ChangelogScopeKind.Notes => $"{ChangelogKeys.ChangelogPrefix}{Group}/",
_ => $"{ChangelogKeys.ChangelogPrefix}{Group}/"
};

/// <summary>The S3 key of this scope's <c>registry.json</c> manifest.</summary>
/// <summary>The S3 key of this scope's <c>registry.json</c> manifest (bundle and changelog scopes only).</summary>
public string RegistryKey => Kind == ChangelogScopeKind.Bundle
? ChangelogKeys.BundleRegistryKey(Group)
: ChangelogKeys.ChangelogRegistryKey(Group);
Expand All @@ -71,17 +79,27 @@ public static bool TryCreateChangelog(string? org, string? repo, string? branch,
return scope is not null;
}

/// <summary>Creates a notes scope for <paramref name="org"/>/<paramref name="repo"/>; false when any segment is invalid.</summary>
public static bool TryCreateNotes(string? org, string? repo, [NotNullWhen(true)] out ChangelogScope? scope)
{
scope = ChangelogKeys.IsValidOrg(org) && ChangelogKeys.IsValidRepo(repo)
? new ChangelogScope(ChangelogScopeKind.Notes, $"{org}/{repo}")
: null;
return scope is not null;
}

/// <summary>
/// Derives the scope an object key belongs to — <c>bundle/{product}/{file}</c> or
/// <c>changelog/{org}/{repo}/{branch}/{file}</c>, including the scope's own
/// <c>registry.json</c> key. False when the key sits outside both layouts or a segment
/// fails validation.
/// Derives the scope an object key belongs to — <c>bundle/{product}/{file}</c>,
/// <c>changelog/{org}/{repo}/{branch}/{file}</c>, or <c>changelog/{org}/{repo}/notes-*.json</c>.
/// False when the key sits outside all layouts or a segment fails validation.
/// </summary>
public static bool TryFromKey(string key, [NotNullWhen(true)] out ChangelogScope? scope)
{
scope = null;
if (ChangelogKeys.ExtractBundleGroup(key) is { } product)
scope = new ChangelogScope(ChangelogScopeKind.Bundle, product);
else if (ChangelogKeys.ExtractNotesRepo(key) is { } repo)
scope = new ChangelogScope(ChangelogScopeKind.Notes, repo);
else if (ChangelogKeys.ExtractChangelogGroup(key) is { } pool)
scope = new ChangelogScope(ChangelogScopeKind.Changelog, pool);
return scope is not null;
Expand Down
31 changes: 31 additions & 0 deletions src/services/Elastic.Changelog/Reconciliation/NotesIndex.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Text.Json.Serialization;

namespace Elastic.Changelog.Reconciliation;

/// <summary>
/// Notes index published at <c>changelog/{org}/{repo}/notes-{target}.json</c>.
/// Lists pool-relative paths of all <c>note-*.yml</c> fragments for one target,
/// across every branch of the repo.
/// </summary>
/// <remarks>
/// Contents are paths, not bodies — the note files remain the single source of truth.
/// A stale index can only omit or over-list, never serve stale prose. Bundling a target
/// is therefore 1 GET for the index + one GET per listed note.
/// </remarks>
public sealed record NotesIndex
{
/// <summary>Pool-relative paths of notes for this target, e.g. <c>["main/note-slow-rollover.yml"]</c>.</summary>
public required IReadOnlyList<string> Notes { get; init; }
}

[JsonSourceGenerationOptions(
WriteIndented = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
)]
[JsonSerializable(typeof(NotesIndex))]
public sealed partial class NotesIndexJsonContext : JsonSerializerContext;
Loading
Loading