Skip to content

Commit c4473c9

Browse files
cottilcawlreakaleekclaudeelastic-observability-automation[bot]
authored
Add skip-labels to evaluate-pr's output (#3013)
* Add skip-labels to evaluate-pr's output * Add tests * Resolve conflicts * Fix docs-builder redirect tests (#3008) * Fix redirect tests * Remove changelog redirects implemented elsewhere * Search: Use default semantic_text inference, remove Jina mappings (#3014) Elasticsearch Serverless now defaults semantic_text to Jina, making the explicit Jina sub-fields redundant and the ELSER inference ID unnecessary. This removes both inference ID constants, all .jina field mappings, and lets semantic_text fields use the platform default. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: Update config/versions.yml eck 3.3.2 (#3019) Made with ❤️️ by updatecli Co-authored-by: elastic-observability-automation[bot] <180520183+elastic-observability-automation[bot]@users.noreply.github.com> * Deploy: Use write-scoped filesystem for apply command (#3021) The deploy apply command used RealRead which lacks AllowedSpecialFolder.Temp, causing ScopedFileSystemException when AwsS3SyncApplyStrategy stages files in /tmp/ for S3 upload. Switch to RealWrite which permits temp directory access. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update Azure EDOT CF version (#3022) +CC @zmoog * Enable AOT/trim analyzers on library projects and skip AOT publish on PRs (#2971) Add IsAotCompatible to 12 library projects referenced by docs-builder so Roslyn's trim/AOT analyzers (IL2026/IL3050) run during regular builds. This catches AOT issues at compile time, removing the need for the expensive native ILC publish step on pull requests. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * HTML: Omit version meta tags for versionless pages (#3020) * HTML: Omit version meta tags for versionless pages Versionless pages (serverless, cloud, etc.) were rendering the sentinel value 99999.0+ in product_version and DC.identifier meta tags. Now these tags are omitted entirely when the page's versioning system is versionless. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * HTML: Restore required modifier on CurrentVersion property Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Layout: Adapt to static elastic-nav by making secondary nav sticky (#3025) * Layout: Adapt to static elastic-nav by making secondary nav sticky The global elastic-nav.js (v2026-03) changed the header from fixed to static positioning. This makes the secondary docs nav sticky at the top and simplifies --offset-top to match its height. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Layout: Only make secondary nav sticky on md+ viewports On mobile the sticky secondary nav takes too much vertical space. Keep it static on small screens (--offset-top: 0) and only sticky on md+ where it provides persistent navigation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: Upgrade lodash to 4.18.x to fix high severity vulnerabilities Fixes code injection via _.template (GHSA-r5fr-rjxr-66jc) and prototype pollution via _.unset/_.omit (GHSA-f23m-r3pf-42rh). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Layout: Add bottom border to secondary nav The visual separator was coming from #main-container's border-top, which scrolls away. Adding border-b to the nav itself keeps the line visible while sticky. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Layout: Remove border-top from main-container The secondary nav now has border-b, so the main-container border-t was causing a double border. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * lint --------- Co-authored-by: Lisa Cawley <lcawley@elastic.co> Co-authored-by: Jan Calanog <jan.calanog@elastic.co> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: elastic-observability-automation[bot] <180520183+elastic-observability-automation[bot]@users.noreply.github.com> Co-authored-by: Fabrizio Ferri-Benedetti <fabri.ferribenedetti@elastic.co>
1 parent bd1e49d commit c4473c9

2 files changed

Lines changed: 212 additions & 3 deletions

File tree

src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,11 @@ public async Task<bool> EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr
7575
}
7676

7777
// Label-based skip check
78+
var skipLabels = CollectExcludeLabels(config.Rules?.Create);
7879
if (PrInfoProcessor.AreAllProductsBlocked(input.PrLabels, config.Rules?.Create))
7980
{
8081
_logger.LogInformation("Skipping: all products blocked by label rules");
81-
return await SetOutputs(PrEvaluationResult.Skipped);
82+
return await SetOutputs(PrEvaluationResult.Skipped, skipLabels: skipLabels);
8283
}
8384

8485
// Resolve title: prefer release notes from PR body, fall back to PR title
@@ -142,7 +143,8 @@ public async Task<bool> EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr
142143
PrEvaluationResult.NoLabel, title,
143144
resolvedDescription: description,
144145
labelTable: BuildLabelTable(config.LabelToType),
145-
productLabelTable: productLabelTable
146+
productLabelTable: productLabelTable,
147+
skipLabels: skipLabels
146148
);
147149
}
148150

@@ -169,7 +171,8 @@ private async Task<bool> SetOutputs(
169171
string? labelTable = null,
170172
string? productLabelTable = null,
171173
string? changelogDir = null,
172-
string? existingFilename = null)
174+
string? existingFilename = null,
175+
string? skipLabels = null)
173176
{
174177
var statusString = status == PrEvaluationResult.Success
175178
? ProceedStatus
@@ -196,10 +199,44 @@ private async Task<bool> SetOutputs(
196199
await coreService.SetOutputAsync("changelog-dir", changelogDir);
197200
if (existingFilename != null)
198201
await coreService.SetOutputAsync("existing-changelog-filename", existingFilename);
202+
if (skipLabels != null)
203+
await coreService.SetOutputAsync("skip-labels", skipLabels);
199204

200205
return true;
201206
}
202207

208+
/// <summary>
209+
/// Collects all exclude-mode labels from global and per-product create rules.
210+
/// Returns a comma-separated string of unique labels, or null when none are configured.
211+
/// </summary>
212+
internal static string? CollectExcludeLabels(CreateRules? createRules)
213+
{
214+
if (createRules == null)
215+
return null;
216+
217+
var labels = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
218+
219+
if (createRules is { Mode: FieldMode.Exclude, Labels.Count: > 0 })
220+
{
221+
foreach (var label in createRules.Labels)
222+
_ = labels.Add(label);
223+
}
224+
225+
if (createRules.ByProduct is { Count: > 0 })
226+
{
227+
foreach (var (_, productRules) in createRules.ByProduct)
228+
{
229+
if (productRules is { Mode: FieldMode.Exclude, Labels.Count: > 0 })
230+
{
231+
foreach (var label in productRules.Labels)
232+
_ = labels.Add(label);
233+
}
234+
}
235+
}
236+
237+
return labels.Count > 0 ? string.Join(",", labels) : null;
238+
}
239+
203240
/// <summary>
204241
/// Finds an existing changelog file for the given PR in the changelog directory.
205242
/// Returns the filename (not the full path) if found, or null.

tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
using Elastic.Changelog.GitHub;
99
using Elastic.Changelog.Tests.Changelogs;
1010
using Elastic.Documentation.Configuration;
11+
using Elastic.Documentation.Configuration.Changelog;
12+
using Elastic.Documentation.ReleaseNotes;
1113
using FakeItEasy;
1214

1315
namespace Elastic.Changelog.Tests.Evaluation;
@@ -638,4 +640,174 @@ New aggregation pipeline support
638640
result.Should().BeTrue();
639641
VerifyOutputSet("title", "New aggregation pipeline support");
640642
}
643+
644+
// --- CollectExcludeLabels unit tests ---
645+
646+
[Fact]
647+
public void CollectExcludeLabels_Null_ReturnsNull() =>
648+
ChangelogPrEvaluationService.CollectExcludeLabels(null).Should().BeNull();
649+
650+
[Fact]
651+
public void CollectExcludeLabels_NoLabels_ReturnsNull() =>
652+
ChangelogPrEvaluationService.CollectExcludeLabels(new CreateRules()).Should().BeNull();
653+
654+
[Fact]
655+
public void CollectExcludeLabels_GlobalExcludeLabels_ReturnsCommaSeparated()
656+
{
657+
var rules = new CreateRules
658+
{
659+
Mode = FieldMode.Exclude,
660+
Labels = [">non-issue", ">test"]
661+
};
662+
663+
var result = ChangelogPrEvaluationService.CollectExcludeLabels(rules);
664+
665+
result.Should().NotBeNull();
666+
result.Split(',').Should().BeEquivalentTo([">non-issue", ">test"]);
667+
}
668+
669+
[Fact]
670+
public void CollectExcludeLabels_IncludeMode_ReturnsNull()
671+
{
672+
var rules = new CreateRules
673+
{
674+
Mode = FieldMode.Include,
675+
Labels = [">non-issue"]
676+
};
677+
678+
ChangelogPrEvaluationService.CollectExcludeLabels(rules).Should().BeNull();
679+
}
680+
681+
[Fact]
682+
public void CollectExcludeLabels_PerProductExcludeOnly_ReturnsLabels()
683+
{
684+
var rules = new CreateRules
685+
{
686+
ByProduct = new Dictionary<string, CreateRules>
687+
{
688+
["cloud-hosted"] = new() { Mode = FieldMode.Exclude, Labels = [">skip-ech"] },
689+
["cloud-serverless"] = new() { Mode = FieldMode.Exclude, Labels = [">skip-ess"] }
690+
}
691+
};
692+
693+
var result = ChangelogPrEvaluationService.CollectExcludeLabels(rules);
694+
695+
result.Should().NotBeNull();
696+
result.Split(',').Should().BeEquivalentTo([">skip-ech", ">skip-ess"]);
697+
}
698+
699+
[Fact]
700+
public void CollectExcludeLabels_GlobalAndPerProduct_MergesUniqueLabels()
701+
{
702+
var rules = new CreateRules
703+
{
704+
Mode = FieldMode.Exclude,
705+
Labels = [">skip-all", ">shared"],
706+
ByProduct = new Dictionary<string, CreateRules>
707+
{
708+
["cloud-hosted"] = new() { Mode = FieldMode.Exclude, Labels = [">shared", ">skip-ech"] }
709+
}
710+
};
711+
712+
var result = ChangelogPrEvaluationService.CollectExcludeLabels(rules);
713+
714+
result.Should().NotBeNull();
715+
result.Split(',').Should().BeEquivalentTo([">skip-all", ">shared", ">skip-ech"]);
716+
}
717+
718+
[Fact]
719+
public void CollectExcludeLabels_PerProductIncludeMode_IgnoresIncludeProducts()
720+
{
721+
var rules = new CreateRules
722+
{
723+
Mode = FieldMode.Exclude,
724+
Labels = [">global"],
725+
ByProduct = new Dictionary<string, CreateRules>
726+
{
727+
["cloud-hosted"] = new() { Mode = FieldMode.Include, Labels = [">include-only"] }
728+
}
729+
};
730+
731+
var result = ChangelogPrEvaluationService.CollectExcludeLabels(rules);
732+
733+
result.Should().NotBeNull();
734+
result.Split(',').Should().BeEquivalentTo([">global"]);
735+
}
736+
737+
// --- skip-labels output integration tests ---
738+
739+
private const string ConfigWithExcludeRules = """
740+
pivot:
741+
types:
742+
feature: "type:feature"
743+
bug-fix: "type:bug"
744+
breaking-change: "type:breaking"
745+
enhancement:
746+
deprecation:
747+
docs:
748+
known-issue:
749+
other:
750+
regression:
751+
security:
752+
rules:
753+
create:
754+
exclude: ">non-issue, >test"
755+
""";
756+
757+
[Fact]
758+
public async Task EvaluatePr_WithExcludeRules_AllBlocked_OutputsSkipLabels()
759+
{
760+
await WriteMinimalConfig(content: ConfigWithExcludeRules);
761+
var service = CreateService();
762+
var args = DefaultArgs(prLabels: [">non-issue"]);
763+
764+
var result = await service.EvaluatePr(Collector, args, CancellationToken.None);
765+
766+
result.Should().BeTrue();
767+
VerifyOutputSet("status", "skipped");
768+
A.CallTo(() => _mockCore.SetOutputAsync("skip-labels", A<string>.That.Contains(">non-issue"))).MustHaveHappened();
769+
A.CallTo(() => _mockCore.SetOutputAsync("skip-labels", A<string>.That.Contains(">test"))).MustHaveHappened();
770+
}
771+
772+
[Fact]
773+
public async Task EvaluatePr_WithExcludeRules_NoLabel_OutputsSkipLabels()
774+
{
775+
await WriteMinimalConfig(content: ConfigWithExcludeRules);
776+
var service = CreateService();
777+
var args = DefaultArgs(prLabels: ["unrelated-label"]);
778+
779+
var result = await service.EvaluatePr(Collector, args, CancellationToken.None);
780+
781+
result.Should().BeTrue();
782+
VerifyOutputSet("status", "no-label");
783+
A.CallTo(() => _mockCore.SetOutputAsync("skip-labels", A<string>.That.Contains(">non-issue"))).MustHaveHappened();
784+
}
785+
786+
[Fact]
787+
public async Task EvaluatePr_WithoutExcludeRules_DoesNotOutputSkipLabels()
788+
{
789+
await WriteMinimalConfig();
790+
var service = CreateService();
791+
var args = DefaultArgs();
792+
793+
var result = await service.EvaluatePr(Collector, args, CancellationToken.None);
794+
795+
result.Should().BeTrue();
796+
VerifyOutputSet("status", "proceed");
797+
A.CallTo(() => _mockCore.SetOutputAsync("skip-labels", A<string>._)).MustNotHaveHappened();
798+
}
799+
800+
[Fact]
801+
public async Task EvaluatePr_HappyPath_WithExcludeRules_DoesNotOutputSkipLabels()
802+
{
803+
await WriteMinimalConfig(content: ConfigWithExcludeRules);
804+
var service = CreateService();
805+
var args = DefaultArgs(prLabels: ["type:feature"]);
806+
807+
var result = await service.EvaluatePr(Collector, args, CancellationToken.None);
808+
809+
result.Should().BeTrue();
810+
VerifyOutputSet("status", "proceed");
811+
A.CallTo(() => _mockCore.SetOutputAsync("skip-labels", A<string>._)).MustNotHaveHappened();
812+
}
641813
}

0 commit comments

Comments
 (0)