From f49f7a166a9876d38548751169e1abfa4377a2a1 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 31 Aug 2026 18:47:27 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Drop=20mode:=20gh-release=20from=20changelo?= =?UTF-8?q?g-publish=20=E2=80=94=20now=20auto-detected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bundle-create resolves gh-release vs bundle from changelog.yml via bundle --plan; no profiles → gh-release, profiles present → bundle. The explicit mode input has been removed from changelog-bundle.yml. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/changelog-publish.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/changelog-publish.yml b/.github/workflows/changelog-publish.yml index c4ec45a09e..d004553fcf 100644 --- a/.github/workflows/changelog-publish.yml +++ b/.github/workflows/changelog-publish.yml @@ -30,7 +30,6 @@ jobs: id-token: write uses: elastic/docs-actions/.github/workflows/changelog-bundle.yml@v1 with: - mode: gh-release repo: docs-builder owner: elastic version: ${{ github.event.release.tag_name || inputs.version || 'latest' }} From 52b9888cf2c6c551d5d58efb3d3ffc0af52e2474 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 1 Sep 2026 12:20:12 +0200 Subject: [PATCH 2/2] Add native sticky PR comment infrastructure for changelog validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames ChangelogArtifactMetadata → GithubDecisionMetadata (wire-safe, snake_case names unchanged), adds two hidden CLI commands (changelog github-decision / github-comment), a GitHubCommentService with paginated sticky-comment upsert that stays bidirectionally compatible with the existing JS comment-helper, and a ChangelogCommentRenderer porting all four JS body variants. validate-labels and evaluate-pr now write the decision metadata file when running on CI, so a downstream workflow_run job can post or update the comment without pull-requests: write on the triggering event. should-comment-success / should-comment-failure outputs are retained for elastic/cloud compatibility until PR 4. Co-Authored-By: Claude --- docs/cli-schema.json | 215 +++++++++++++++++- docs/cli/changelog/cmd-evaluate-pr.md | 2 + docs/cli/changelog/cmd-validate-labels.md | 11 +- .../ChangelogArtifactEvaluationService.cs | 7 +- .../Evaluation/ChangelogArtifactMetadata.cs | 36 --- .../Evaluation/ChangelogCommentRenderer.cs | 214 +++++++++++++++++ .../ChangelogGithubCommentService.cs | 173 ++++++++++++++ .../ChangelogGithubDecisionService.cs | 49 ++++ .../ChangelogLabelValidationService.cs | 55 ++++- .../ChangelogPrEvaluationService.cs | 80 +++++-- .../ChangelogPrepareArtifactService.cs | 4 +- .../Evaluation/ChangelogTableRenderers.cs | 33 +++ .../Evaluation/EvaluatePrArguments.cs | 6 + .../Evaluation/GithubDecisionMetadata.cs | 58 +++++ .../GithubDecisionMetadataWriter.cs | 76 +++++++ .../Evaluation/PrepareArtifactArguments.cs | 2 +- .../Evaluation/ValidateLabelsArguments.cs | 11 + .../GitHub/GitHubApiTransport.cs | 26 +++ .../GitHub/GitHubCommentService.cs | 185 +++++++++++++++ .../GitHub/IGitHubCommentService.cs | 21 ++ .../docs-builder/Commands/ChangelogCommand.cs | 120 +++++++++- ...ChangelogArtifactEvaluationServiceTests.cs | 6 +- .../ChangelogCommentRendererTests.cs | 148 ++++++++++++ .../ChangelogGithubCommentServiceTests.cs | 197 ++++++++++++++++ .../ChangelogGithubDecisionServiceTests.cs | 115 ++++++++++ .../ChangelogLabelValidationServiceTests.cs | 161 +++++++++++++ .../ChangelogPrepareArtifactServiceTests.cs | 6 +- .../Evaluation/GitHubCommentServiceTests.cs | 153 +++++++++++++ ...ests.cs => GithubDecisionMetadataTests.cs} | 62 ++++- 29 files changed, 2139 insertions(+), 93 deletions(-) delete mode 100644 src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs create mode 100644 src/services/Elastic.Changelog/Evaluation/ChangelogCommentRenderer.cs create mode 100644 src/services/Elastic.Changelog/Evaluation/ChangelogGithubCommentService.cs create mode 100644 src/services/Elastic.Changelog/Evaluation/ChangelogGithubDecisionService.cs create mode 100644 src/services/Elastic.Changelog/Evaluation/ChangelogTableRenderers.cs create mode 100644 src/services/Elastic.Changelog/Evaluation/GithubDecisionMetadata.cs create mode 100644 src/services/Elastic.Changelog/Evaluation/GithubDecisionMetadataWriter.cs create mode 100644 src/services/Elastic.Changelog/GitHub/GitHubCommentService.cs create mode 100644 src/services/Elastic.Changelog/GitHub/IGitHubCommentService.cs create mode 100644 tests/Elastic.Changelog.Tests/Evaluation/ChangelogCommentRendererTests.cs create mode 100644 tests/Elastic.Changelog.Tests/Evaluation/ChangelogGithubCommentServiceTests.cs create mode 100644 tests/Elastic.Changelog.Tests/Evaluation/ChangelogGithubDecisionServiceTests.cs create mode 100644 tests/Elastic.Changelog.Tests/Evaluation/ChangelogLabelValidationServiceTests.cs create mode 100644 tests/Elastic.Changelog.Tests/Evaluation/GitHubCommentServiceTests.cs rename tests/Elastic.Changelog.Tests/Evaluation/{ChangelogArtifactMetadataTests.cs => GithubDecisionMetadataTests.cs} (64%) diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 5ff7b2776b..caf4699b5d 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -3690,6 +3690,33 @@ "summary": "Bot login name for loop detection", "defaultValue": "github-actions[bot]" }, + { + "role": "flag", + "name": "is-fork", + "type": "boolean", + "required": false, + "defaultValue": "false" + }, + { + "role": "flag", + "name": "can-commit", + "type": "boolean", + "required": false, + "defaultValue": "false" + }, + { + "role": "flag", + "name": "maintainer-can-modify", + "type": "boolean", + "required": false, + "defaultValue": "false" + }, + { + "role": "flag", + "name": "head-repo", + "type": "string", + "required": false + }, { "role": "flag", "name": "log-level", @@ -3851,6 +3878,139 @@ } ] }, + { + "path": [ + "changelog" + ], + "name": "github-comment", + "summary": "(CI, hidden) Post or update the sticky changelog comment on the PR.", + "notes": "Reads the decision metadata and renders the appropriate body based on the validation status\nand commit outcome. Owner and repo are resolved from the GITHUB_REPOSITORY environment\nvariable (always set by GitHub Actions). This command is only meaningful under\nGITHUB_ACTIONS and must only be invoked from a job with pull-requests: write.", + "usage": "docs-builder changelog github-comment --metadata \u003Cstring\u003E", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "metadata", + "type": "string", + "required": true, + "summary": "Path to the decision metadata.json file" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "hidden": true + }, + { + "path": [ + "changelog" + ], + "name": "github-decision", + "summary": "(CI, hidden) Record commit outcome into the decision metadata file.", + "notes": "Reads metadata.json, amends it with the commit step result, and writes it back.\nRun by submit/apply after the git-push step so the downstream\nchangelog github-comment command knows which body to render.", + "usage": "docs-builder changelog github-decision --metadata \u003Cstring\u003E --commit-outcome \u003Cenum\u003E [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "metadata", + "type": "string", + "required": true, + "summary": "Path to the decision metadata.json file" + }, + { + "role": "flag", + "name": "commit-outcome", + "type": "enum", + "required": true, + "summary": "Outcome of the changelog commit step", + "enumValues": [ + "none", + "committed", + "failed" + ] + }, + { + "role": "flag", + "name": "committed-file", + "type": "string", + "required": false, + "summary": "Repo-relative path to the committed file (when commitOutcome is Committed)" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "hidden": true + }, { "path": [ "changelog" @@ -4898,7 +5058,7 @@ "name": "validate-labels", "summary": "(CI) Validate PR labels against the changelog config without writing any files or calling the GitHub API.", "notes": "A lightweight label-only gate intended for the pull_request event. Resolves\npivot.types, pivot.products, and rules.create skip labels against the PR\u0027s\nlabel set and exits non-zero on no-label. Does not perform title resolution, bot-loop\ndetection, or changelog-file lookup \u2014 use EvaluatePr when those are needed.\n\n\nOutputs: status (ok | no-label | skipped), type, products,\nlabel-table (shown on failure), product-label-table (shown on product failure),\nskip-labels.", - "usage": "docs-builder changelog validate-labels --config \u003Cfile\u003E --pr-labels \u003Cstring\u003E", + "usage": "docs-builder changelog validate-labels --config \u003Cfile\u003E --pr-labels \u003Cstring\u003E [options]", "examples": [], "parameters": [ { @@ -4924,6 +5084,59 @@ "required": true, "summary": "Comma-separated list of PR labels (use ${{ join(github.event.pull_request.labels.*.name, \u0027,\u0027) }} in actions)." }, + { + "role": "flag", + "name": "pr-number", + "type": "integer", + "required": false, + "summary": "PR number \u2014 required for decision metadata written when running on CI.", + "defaultValue": "0" + }, + { + "role": "flag", + "name": "head-ref", + "type": "string", + "required": false, + "summary": "PR head branch ref \u2014 written to decision metadata when on CI." + }, + { + "role": "flag", + "name": "head-sha", + "type": "string", + "required": false, + "summary": "PR head commit SHA \u2014 written to decision metadata when on CI." + }, + { + "role": "flag", + "name": "is-fork", + "type": "boolean", + "required": false, + "summary": "Whether the PR is from a fork.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "can-commit", + "type": "boolean", + "required": false, + "summary": "Whether the commit strategy allows committing.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "maintainer-can-modify", + "type": "boolean", + "required": false, + "summary": "Whether the fork PR allows maintainer edits.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "head-repo", + "type": "string", + "required": false, + "summary": "Fork repository full name (owner/repo)." + }, { "role": "flag", "name": "log-level", diff --git a/docs/cli/changelog/cmd-evaluate-pr.md b/docs/cli/changelog/cmd-evaluate-pr.md index 48fb5abab3..4e3f984648 100644 --- a/docs/cli/changelog/cmd-evaluate-pr.md +++ b/docs/cli/changelog/cmd-evaluate-pr.md @@ -6,6 +6,8 @@ This command is intended for CI automation. It is used internally by the changel Evaluate a pull request for changelog generation eligibility. Performs pre-flight checks (body-only edit, bot loop detection, manual edit detection), loads the changelog configuration, checks label-based creation rules, resolves the PR title and type, and sets GitHub Actions outputs for downstream steps. +When running under GitHub Actions (the `GITHUB_ACTIONS` environment variable is set), the command writes a decision metadata file to `.artifacts/changelog-decision/metadata.json`. This file is picked up by the downstream `changelog github-comment` command to post or update the sticky PR comment. Pass `--is-fork`, `--can-commit`, and `--maintainer-can-modify` so the comment command can select the correct comment body for fork and comment-only PR strategies. + ## GitHub Actions outputs | Output | Description | diff --git a/docs/cli/changelog/cmd-validate-labels.md b/docs/cli/changelog/cmd-validate-labels.md index c3d4ca85e0..52d822c502 100644 --- a/docs/cli/changelog/cmd-validate-labels.md +++ b/docs/cli/changelog/cmd-validate-labels.md @@ -8,6 +8,8 @@ Validate that a pull request's labels contain a recognised changelog type label, Exits non-zero when `status` is `no-label`. All other statuses (`ok`, `skipped`) exit zero. +When running under GitHub Actions (the `GITHUB_ACTIONS` environment variable is set) and `--pr-number` is provided, the command writes a decision metadata file to `.artifacts/changelog-decision/metadata.json`. This file is picked up by the downstream `changelog github-comment` command to post or update the sticky PR comment. + ## GitHub Actions outputs | Output | Description | @@ -19,10 +21,17 @@ Exits non-zero when `status` is `no-label`. All other statuses (`ok`, `skipped`) | `product-label-table` | Markdown table of configured label-to-product mappings (when `no-label` due to missing product) | | `skip-labels` | Comma-separated list of configured skip labels (from `rules.create` exclude rules) | +## Decision metadata + +When `--pr-number` is supplied and the command runs under GitHub Actions, it writes `.artifacts/changelog-decision/metadata.json` relative to the checkout root. The file contains the PR number, head ref/SHA, validation status, and label tables. A consumer workflow uploads this file as the `changelog-decision` artifact and a `workflow_run` job picks it up to call `changelog github-comment`. + ## Examples ```sh docs-builder changelog validate-labels \ --config docs/changelog.yml \ - --pr-labels "enhancement,Team:Core" + --pr-labels "enhancement,Team:Core" \ + --pr-number 42 \ + --head-ref feature-branch \ + --head-sha abc123 ``` diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs index c3a9a69716..d79fb491bc 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs @@ -29,14 +29,11 @@ IRunnerTempFileSystem fileSystem public async Task EvaluateArtifact(IDiagnosticsCollector collector, EvaluateArtifactArguments input, Cancel ctx) { - ChangelogArtifactMetadata? metadata; + GithubDecisionMetadata? metadata; try { var artifactMetadataJson = await _fileSystem.File.ReadAllTextAsync(input.MetadataPath, ctx); - metadata = JsonSerializer.Deserialize( - artifactMetadataJson, - ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata - ); + metadata = JsonSerializer.Deserialize(artifactMetadataJson, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata); } catch (FileNotFoundException) { diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs deleted file mode 100644 index 2440bea4d3..0000000000 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs +++ /dev/null @@ -1,36 +0,0 @@ -// 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; -using Elastic.Documentation.Configuration.Changelog; -using Elastic.Documentation.ReleaseNotes; - -namespace Elastic.Changelog.Evaluation; - -/// Artifact metadata transferred between the generate and commit CI workflows. -public record ChangelogArtifactMetadata -{ - public required int PrNumber { get; init; } - public required string HeadRef { get; init; } - public required string HeadSha { get; init; } - public required string Status { get; init; } - public required bool IsFork { get; init; } - public required bool CanCommit { get; init; } - public required bool MaintainerCanModify { get; init; } - public string? HeadRepo { get; init; } - public string? LabelTable { get; init; } - public string? ProductLabelTable { get; init; } - public string? SkipLabels { get; init; } - public string? ConfigFile { get; init; } - public string? ChangelogDir { get; init; } - public string? ChangelogFilename { get; init; } - public CreateRules? CreateRules { get; init; } -} - -[JsonSourceGenerationOptions(WriteIndented = true, UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] -[JsonSerializable(typeof(ChangelogArtifactMetadata))] -[JsonSerializable(typeof(CreateRules))] -[JsonSerializable(typeof(FieldMode))] -[JsonSerializable(typeof(MatchMode))] -public sealed partial class ChangelogArtifactMetadataJsonContext : JsonSerializerContext; diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogCommentRenderer.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogCommentRenderer.cs new file mode 100644 index 0000000000..8299c7655a --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogCommentRenderer.cs @@ -0,0 +1,214 @@ +// 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; + +namespace Elastic.Changelog.Evaluation; + +/// +/// Renders the three changelog PR comment bodies. +/// +/// Ported from the predecessor JS scripts in changelog/submit/apply/scripts/: +/// +/// post-success-comment.js +/// post-comment-only.js +/// post-failure-comment.js +/// +/// +/// +/// Injection hardening: computes the longest backtick run in the +/// content and opens with max(3, run + 1) backticks; pads when +/// a value starts or ends with a backtick. All dynamic values are embedded via ordinary string +/// interpolation, never injected into a GitHub Actions expression. +/// +/// +internal static class ChangelogCommentRenderer +{ + /// + /// Title prefix shared with the legacy JS comment-helper. Must remain the first line of every + /// body so GitHubCommentService's legacy-prefix fallback keeps working during rollout. + /// + internal const string Title = "### 📋 Changelog"; + + /// GitHub comment body character limit. + private const int MaxBodyLength = 65536; + + // ────────────────────────────────────────────────────────────────────────────────────────── + // Public render methods + // ────────────────────────────────────────────────────────────────────────────────────────── + + /// + /// Renders the "entry committed" body: blob and edit links for the committed changelog file. + /// + internal static string RenderEntryCommitted(string owner, string repo, string headRef, string committedFile) + { + var safeBranch = Uri.EscapeDataString(headRef); + var safePath = string.Join("/", committedFile.Split('/').Select(Uri.EscapeDataString)); + var viewUrl = $"https://github.com/{owner}/{repo}/blob/{safeBranch}/{safePath}"; + var editUrl = $"https://github.com/{owner}/{repo}/edit/{safeBranch}/{safePath}"; + + return Truncate( + string.Join( + "\n", + Title, + "", + $"📝 Changelog entry committed: [{WrapInlineCode(committedFile)}]({viewUrl})", + "", + $"✏️ [Edit this changelog]({editUrl})" + ) + ); + } + + /// + /// Renders the "comment-only" body: the generated YAML in a fenced block, with one of three + /// guidance variants based on the commit context. + /// + internal static string RenderCommentOnly( + string? changelogDir, + string? yamlContent, + string? yamlFilename, + bool isFork, + bool commitFailed + ) + { + var parts = new List { Title, "" }; + + if (!string.IsNullOrWhiteSpace(yamlContent) && !string.IsNullOrWhiteSpace(yamlFilename)) + { + var guidance = commitFailed + ? "The workflow could not commit this generated changelog change to the PR branch, " + + "so the merge-time S3 upload will not include it. Resolve the commit failure or add " + + "the generated entry to the PR branch before merging." + : isFork + ? "This comment is informational — editing it does not change what gets uploaded. " + + "On merge, the entry is regenerated from the live PR record (title, labels) and uploaded to S3. " + + "To change the preview, edit the PR title or labels and let the changelog workflow re-run." + : "This comment is informational — editing it does not change what gets uploaded. " + + "Comment-only mode did not commit this generated changelog change to the PR branch, " + + "and same-repository PRs are not regenerated by the merge-time S3 upload. " + + "Add the generated entry to the PR branch before merging if it should be published."; + + var targetPath = string.IsNullOrWhiteSpace(changelogDir) ? yamlFilename : changelogDir + "/" + yamlFilename; + parts.Add($"Generated changelog entry for {WrapInlineCode(targetPath)}:"); + parts.Add(""); + parts.Add(WrapCodeFence(yamlContent.Trim(), "yaml")); + parts.Add(""); + parts.Add(guidance); + } + else + { + parts.Add("⚠️ Changelog entry was generated but the file content could not be read."); + } + + return Truncate(string.Join("\n", parts)); + } + + /// + /// Renders the "cannot generate" body: the error headline plus optional label tables and skip-label + /// guidance, ported from post-failure-comment.js. + /// + internal static string RenderCannotGenerate(string? labelTable, string? productLabelTable, string? skipLabels, string? configFile) + { + var configFileCode = WrapInlineCode(string.IsNullOrWhiteSpace(configFile) ? "docs/changelog.yml" : configFile); + + var hasTypeIssue = !string.IsNullOrWhiteSpace(labelTable); + var hasProductIssue = !string.IsNullOrWhiteSpace(productLabelTable); + + var headline = hasTypeIssue && hasProductIssue + ? "⚠️ **Cannot generate changelog:** required type and product labels are missing on this PR." + : hasProductIssue + ? "⚠️ **Cannot generate changelog:** no matching product label found on this PR." + : "⚠️ **Cannot generate changelog:** no matching type label found on this PR."; + + var sections = new List(); + + if (hasTypeIssue) + sections.Add(string.Join("\n", "", "🔖 Add one of these **type** labels to your PR:", "", labelTable)); + else if (!hasProductIssue) + sections.Add($"\nAdd a type label that matches your {WrapInlineCode("pivot.types")} configuration in {configFileCode}."); + + if (hasProductIssue) + sections.Add(string.Join("\n", "", "📦 Add one or more **product** labels to your PR:", "", productLabelTable)); + + string skipSection; + if (!string.IsNullOrWhiteSpace(skipLabels)) + { + var formatted = skipLabels.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select( + WrapInlineCode + ); + skipSection = $"\n⏭️ To skip changelog generation, add one of these labels: {string.Join(", ", formatted)}"; + } + else + { + skipSection = $"\n⏭️ No skip labels are configured. To allow skipping changelog generation, " + + $"add a label to {WrapInlineCode("rules.create.exclude")} in {configFileCode}."; + } + + var allParts = new List { Title, "", headline }; + allParts.AddRange(sections); + allParts.Add(skipSection); + allParts.Add(""); + allParts.Add($"📄 See {configFileCode} for the full changelog configuration."); + + return Truncate(string.Join("\n", allParts)); + } + + /// + /// Renders the "resolved" body posted when a previously-failing PR is now valid, to avoid leaving + /// a stale failure comment. + /// + internal static string RenderResolved() => string.Join("\n", Title, "", "✅ Changelog labels validated successfully."); + + // ────────────────────────────────────────────────────────────────────────────────────────── + // Injection-hardening helpers (ported from comment-helper.js) + // ────────────────────────────────────────────────────────────────────────────────────────── + + /// + /// Wraps in a fenced code block, using enough backticks to ensure + /// embedded fence markers cannot escape the block. + /// + internal static string WrapCodeFence(string content, string language = "") + { + var fence = new string('`', Math.Max(3, LongestBacktickRun(content) + 1)); + return $"{fence}{language}\n{content}\n{fence}"; + } + + /// + /// Wraps as inline code, using enough backticks to accommodate embedded + /// backticks, and padding when the value starts or ends with a backtick. + /// + internal static string WrapInlineCode(string value) + { + var delimiter = new string('`', LongestBacktickRun(value) + 1); + var padded = value.StartsWith('`') || value.EndsWith('`') ? $" {value} " : value; + return $"{delimiter}{padded}{delimiter}"; + } + + private static int LongestBacktickRun(string value) + { + var longest = 0; + var current = 0; + foreach (var ch in value) + { + if (ch == '`') + current++; + else + { + if (current > longest) + longest = current; + current = 0; + } + } + return Math.Max(longest, current); + } + + private static string Truncate(string body) + { + if (body.Length <= MaxBodyLength) + return body; + + const string suffix = "\n\n…*(truncated — body exceeds GitHub's 65 536-character limit)*"; + return body[..(MaxBodyLength - suffix.Length)] + suffix; + } +} diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogGithubCommentService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogGithubCommentService.cs new file mode 100644 index 0000000000..7476006fb0 --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogGithubCommentService.cs @@ -0,0 +1,173 @@ +// 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 Elastic.Changelog.GitHub; +using Elastic.Documentation.FileSystems; +using Elastic.Documentation.Services; +using Microsoft.Extensions.Logging; + +namespace Elastic.Changelog.Evaluation; + +/// +/// Service implementing the hidden changelog github-comment command. +/// Reads the decision metadata and renders + posts the appropriate sticky PR comment. +/// +/// Body selection: +/// +/// CommitOutcome == CommittedEntry-committed body with blob + edit links. +/// CommitOutcome == FailedComment-only body, commit-failed variant. +/// Status success and !CanCommitComment-only body (fork / comment-only strategy). +/// Status no-labelCannot-generate body with label tables. +/// Status success, no staged fileResolved body (clears a stale failure comment). +/// +/// +public class ChangelogGithubCommentService( + ILoggerFactory logFactory, + IGitHubCommentService commentService, + IRunnerTempFileSystem fileSystem +) : IService +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly GithubDecisionMetadataWriter _reader = new(logFactory, fileSystem); + + /// + /// Reads metadata, selects the appropriate body, and upserts the sticky comment. + /// A comment failure logs a warning but returns true so the exit code reflects the + /// verdict, not a transient API error. + /// + public async Task PostComment(GithubCommentArguments input, Cancel ctx) + { + var metadata = await _reader.ReadAsync(input.MetadataPath, ctx); + if (metadata is null) + { + _logger.LogWarning("Decision metadata not found at {Path} — skipping comment", input.MetadataPath); + return true; + } + + // Resolve owner/repo from the GITHUB_REPOSITORY env var injected by the command layer. + var owner = input.Owner; + var repo = input.Repo; + + var body = SelectBody(metadata, input.MetadataDir); + if (body is null) + { + _logger.LogInformation("No comment body selected for PR #{PrNumber} — nothing to post", metadata.PrNumber); + return true; + } + + var posted = await commentService.UpsertStickyCommentAsync(owner, repo, metadata.PrNumber, body, ctx); + if (!posted) + _logger.LogWarning("Comment post did not succeed for PR #{PrNumber} — continuing", metadata.PrNumber); + + return true; + } + + private string? SelectBody(GithubDecisionMetadata metadata, string metadataDir) + { + // Committed: entry-committed body with blob + edit links. + if (metadata.CommitOutcome == CommitOutcome.Committed && !string.IsNullOrWhiteSpace(metadata.CommittedFile)) + { + _logger.LogInformation("Rendering entry-committed body for PR #{PrNumber}", metadata.PrNumber); + return ChangelogCommentRenderer.RenderEntryCommitted( + metadata.HeadRepo ?? "", + metadata.HeadRepo ?? "", + metadata.HeadRef, + metadata.CommittedFile + ); + } + + // Commit failed: comment-only body, commit-failed variant. + if (metadata.CommitOutcome == CommitOutcome.Failed) + { + _logger.LogInformation("Rendering comment-only/commit-failed body for PR #{PrNumber}", metadata.PrNumber); + var (yamlContent, yamlFilename) = ReadStagedYaml(metadataDir); + return ChangelogCommentRenderer.RenderCommentOnly( + metadata.ChangelogDir, + yamlContent, + yamlFilename, + metadata.IsFork, + commitFailed: true + ); + } + + var isSuccess = IsSuccess(metadata.Status); + var isNoLabel = IsNoLabel(metadata.Status); + + // Success but cannot commit (fork or comment-only strategy): comment-only informational body. + if (isSuccess && !metadata.CanCommit) + { + _logger.LogInformation("Rendering comment-only/no-commit body for PR #{PrNumber}", metadata.PrNumber); + var (yamlContent, yamlFilename) = ReadStagedYaml(metadataDir); + return ChangelogCommentRenderer.RenderCommentOnly( + metadata.ChangelogDir, + yamlContent, + yamlFilename, + metadata.IsFork, + commitFailed: false + ); + } + + // No label: cannot-generate body with label tables. + if (isNoLabel) + { + _logger.LogInformation("Rendering cannot-generate body for PR #{PrNumber}", metadata.PrNumber); + return ChangelogCommentRenderer.RenderCannotGenerate( + metadata.LabelTable, + metadata.ProductLabelTable, + metadata.SkipLabels, + metadata.ConfigFile + ); + } + + // Success with no staged file: post resolved body to clear a stale failure comment. + if (isSuccess) + { + _logger.LogInformation("Rendering resolved body for PR #{PrNumber}", metadata.PrNumber); + return ChangelogCommentRenderer.RenderResolved(); + } + + return null; + } + + /// + /// Reads the first .yaml sibling of metadata.json in the artifact directory. + /// Returns (null, null) when none is found. + /// + private (string? content, string? filename) ReadStagedYaml(string metadataDir) + { + try + { + var yamlFiles = fileSystem.Directory.GetFiles(metadataDir, "*.yaml"); + + if (yamlFiles.Length == 0) + return (null, null); + + var first = yamlFiles[0]; + var content = fileSystem.File.ReadAllText(first); + return (content.Trim(), fileSystem.Path.GetFileName(first)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Could not read staged YAML from {Dir}", metadataDir); + return (null, null); + } + } + + private static bool IsSuccess(string status) => + string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "proceed", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "ok", StringComparison.OrdinalIgnoreCase); + + private static bool IsNoLabel(string status) => string.Equals(status, "no-label", StringComparison.OrdinalIgnoreCase); +} + +/// Arguments for the hidden changelog github-comment command. +public record GithubCommentArguments +{ + public required string MetadataPath { get; init; } + /// Directory containing the metadata file (used to locate the staged YAML sibling). + public required string MetadataDir { get; init; } + public required string Owner { get; init; } + public required string Repo { get; init; } +} diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogGithubDecisionService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogGithubDecisionService.cs new file mode 100644 index 0000000000..261a76e125 --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogGithubDecisionService.cs @@ -0,0 +1,49 @@ +// 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 Elastic.Documentation.FileSystems; +using Elastic.Documentation.Services; +using Microsoft.Extensions.Logging; + +namespace Elastic.Changelog.Evaluation; + +/// +/// Service implementing the hidden changelog github-decision command. +/// Reads the decision metadata file, amends it with the commit-step outcome, and writes it back. +/// Deliberately trivial — all steering logic lives in . +/// +public class ChangelogGithubDecisionService(ILoggerFactory logFactory, IRunnerTempFileSystem fileSystem) : IService +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly GithubDecisionMetadataWriter _writer = new(logFactory, fileSystem); + + /// + /// Reads the metadata at ., + /// sets and + /// , then writes it back. + /// + public async Task RecordDecision(GithubDecisionArguments input, Cancel ctx) + { + var existing = await _writer.ReadAsync(input.MetadataPath, ctx); + if (existing is null) + { + _logger.LogWarning("Decision metadata not found at {Path} — nothing to amend", input.MetadataPath); + return true; + } + + var updated = existing with { CommitOutcome = input.CommitOutcome, CommittedFile = input.CommittedFile }; + + await _writer.WriteAsync(updated, ctx); + _logger.LogInformation("Recorded commit outcome {Outcome} for PR #{PrNumber}", input.CommitOutcome, updated.PrNumber); + return true; + } +} + +/// Arguments for the hidden changelog github-decision command. +public record GithubDecisionArguments +{ + public required string MetadataPath { get; init; } + public required CommitOutcome CommitOutcome { get; init; } + public string? CommittedFile { get; init; } +} diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogLabelValidationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogLabelValidationService.cs index 7bdbf4a0db..870fdaec5d 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogLabelValidationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogLabelValidationService.cs @@ -5,6 +5,7 @@ using Actions.Core.Services; using Elastic.Changelog.Creation; using Elastic.Changelog.Utilities; +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; @@ -23,15 +24,19 @@ public class ChangelogLabelValidationService( ILoggerFactory logFactory, IConfigurationContext configurationContext, ICoreService coreService, - IRunnerTempFileSystem fileSystem + IRunnerTempFileSystem fileSystem, + IEnvironmentVariables? env = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); + private readonly GithubDecisionMetadataWriter _metadataWriter = new(logFactory, fileSystem); /// /// Validates that the PR's labels contain a recognised type label, optionally with product labels. /// Exits non-zero only on no-label; all other paths (skipped, ok) return zero. + /// When running on CI (GITHUB_ACTIONS is set), writes a + /// file for the downstream changelog github-comment command to pick up. /// public async Task ValidateLabels(IDiagnosticsCollector collector, ValidateLabelsArguments input, Cancel ctx) { @@ -42,7 +47,8 @@ public async Task ValidateLabels(IDiagnosticsCollector collector, Validate if (PrInfoProcessor.AreAllProductsBlocked(input.PrLabels, config.Rules?.Create)) { _logger.LogInformation("All products blocked by label rules; skipping"); - return await SetOutputs("skipped", skipLabels: skipLabels); + await Finish("skipped", skipLabels: skipLabels); + return true; } // Resolve type @@ -77,12 +83,8 @@ public async Task ValidateLabels(IDiagnosticsCollector collector, Validate string.Empty, "No matching changelog type label found on this PR. Add a label from your changelog.yml pivot.types, or a skip label." ); - _ = await SetOutputs( - "no-label", - labelTable: ChangelogPrEvaluationService.BuildLabelTable(config.LabelToType), - productLabelTable: productLabelTable, - skipLabels: skipLabels - ); + var labelTable = ChangelogPrEvaluationService.BuildLabelTable(config.LabelToType); + await Finish("no-label", labelTable: labelTable, productLabelTable: productLabelTable, skipLabels: skipLabels); return false; } @@ -93,12 +95,45 @@ public async Task ValidateLabels(IDiagnosticsCollector collector, Validate string.Empty, "No matching product label found on this PR. Add a label from your changelog.yml pivot.products." ); - _ = await SetOutputs("no-label", productLabelTable: productLabelTable, skipLabels: skipLabels); + await Finish("no-label", productLabelTable: productLabelTable, skipLabels: skipLabels); return false; } _logger.LogInformation("Label validation complete: type={Type}, products={Products}", resolvedType, resolvedProducts); - return await SetOutputs("ok", type: resolvedType, products: resolvedProducts, skipLabels: skipLabels); + await Finish("ok", type: resolvedType, products: resolvedProducts, skipLabels: skipLabels); + return true; + + async Task Finish( + string status, + string? type = null, + string? products = null, + string? labelTable = null, + string? productLabelTable = null, + string? skipLabels = null + ) + { + _ = await SetOutputs(status, type, products, labelTable, productLabelTable, skipLabels); + + if (env?.IsRunningOnCI == true && input.PrNumber > 0) + { + var metadata = new GithubDecisionMetadata + { + PrNumber = input.PrNumber, + HeadRef = input.HeadRef, + HeadSha = input.HeadSha, + Status = status, + IsFork = input.IsFork, + CanCommit = input.CanCommit, + MaintainerCanModify = input.MaintainerCanModify, + HeadRepo = input.HeadRepo, + LabelTable = labelTable, + ProductLabelTable = productLabelTable, + SkipLabels = skipLabels, + ConfigFile = input.ConfigFile + }; + await _metadataWriter.WriteAsync(metadata, ctx); + } + } } private async Task SetOutputs( diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs index a5e54ad412..146d6f01c3 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs @@ -8,6 +8,7 @@ using Elastic.Changelog.Creation; using Elastic.Changelog.GitHub; using Elastic.Changelog.Utilities; +using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; @@ -24,12 +25,14 @@ public class ChangelogPrEvaluationService( IConfigurationContext configurationContext, IGitHubPrService gitHubPrService, ICoreService coreService, - IRunnerTempFileSystem fileSystem + IRunnerTempFileSystem fileSystem, + IEnvironmentVariables? env = null ) : IService { private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IRunnerTempFileSystem _fileSystem = fileSystem; private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); + private readonly GithubDecisionMetadataWriter _metadataWriter = new(logFactory, fileSystem); public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrArguments input, Cancel ctx) { @@ -147,14 +150,23 @@ public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr string.Empty, "No matching changelog type label found on this PR. Add a label from your changelog.yml pivot.types, or a skip label." ); + var noTypeLabelTable = BuildLabelTable(config.LabelToType); _ = await SetOutputs( PrEvaluationResult.NoLabel, title, resolvedDescription: description, - labelTable: BuildLabelTable(config.LabelToType), + labelTable: noTypeLabelTable, productLabelTable: productLabelTable, skipLabels: skipLabels ); + await WriteDecisionMetadataAsync( + input, + "no-label", + labelTable: noTypeLabelTable, + productLabelTable: productLabelTable, + skipLabels: skipLabels, + ctx: ctx + ); return false; } @@ -176,6 +188,7 @@ public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr productLabelTable: productLabelTable, skipLabels: skipLabels ); + await WriteDecisionMetadataAsync(input, "no-label", productLabelTable: productLabelTable, skipLabels: skipLabels, ctx: ctx); return false; } @@ -200,6 +213,14 @@ public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr resolvedProducts, existingFilename ); + await WriteDecisionMetadataAsync( + input, + ProceedStatus, + changelogDir: changelogDir, + changelogFilename: existingFilename, + skipLabels: skipLabels, + ctx: ctx + ); return await SetOutputs( PrEvaluationResult.Success, title, @@ -215,6 +236,44 @@ public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr /// The evaluate-pr output value when evaluation succeeds and generation should proceed. internal const string ProceedStatus = "proceed"; + /// + /// Writes when running on CI. + /// No-ops when GITHUB_ACTIONS is unset or PrNumber is zero (local runs). + /// Failures are logged as warnings; they never affect the command exit code. + /// + private async Task WriteDecisionMetadataAsync( + EvaluatePrArguments input, + string status, + Cancel ctx, + string? labelTable = null, + string? productLabelTable = null, + string? skipLabels = null, + string? changelogDir = null, + string? changelogFilename = null + ) + { + if (env?.IsRunningOnCI != true || input.PrNumber <= 0) + return; + + var metadata = new GithubDecisionMetadata + { + PrNumber = input.PrNumber, + HeadRef = input.HeadRef, + HeadSha = input.HeadSha, + Status = status, + IsFork = input.IsFork, + CanCommit = input.CanCommit, + MaintainerCanModify = input.MaintainerCanModify, + HeadRepo = input.HeadRepo, + LabelTable = labelTable, + ProductLabelTable = productLabelTable, + SkipLabels = skipLabels, + ChangelogDir = changelogDir, + ChangelogFilename = changelogFilename + }; + await _metadataWriter.WriteAsync(metadata, ctx); + } + private async Task SetOutputs( PrEvaluationResult status, string? resolvedTitle = null, @@ -340,20 +399,11 @@ internal static bool ContentReferencesPr(string content, string prNumber) => || content.Contains($"- '{prNumber}'", StringComparison.Ordinal); internal static string BuildLabelTable(IReadOnlyDictionary? labelToType) => - BuildMappingTable(labelToType, "Label", "Type"); + ChangelogTableRenderers.BuildLabelTable(labelToType); internal static string BuildProductLabelTable(IReadOnlyDictionary? labelToProducts) => - BuildMappingTable(labelToProducts, "Label", "Product"); + ChangelogTableRenderers.BuildProductLabelTable(labelToProducts); - internal static string BuildMappingTable(IReadOnlyDictionary? mapping, string keyHeader, string valueHeader) - { - if (mapping is not { Count: > 0 }) - return ""; - - var lines = new List { $"| {keyHeader} | {valueHeader} |", "| --- | --- |" }; - foreach (var (key, value) in mapping) - lines.Add($"| `{key}` | {value} |"); - - return string.Join("\n", lines); - } + internal static string BuildMappingTable(IReadOnlyDictionary? mapping, string keyHeader, string valueHeader) => + ChangelogTableRenderers.BuildMappingTable(mapping, keyHeader, valueHeader); } diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs index da810fea6a..9e10c7b9c8 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs @@ -88,7 +88,7 @@ public async Task PrepareArtifact(IDiagnosticsCollector collector, Prepare // `bool` fields. Treating "unspecified" as `false` keeps downstream // consumers (apply step) failing closed: an unrecognised or omitted // CLI flag never grants commit permission. - var metadata = new ChangelogArtifactMetadata + var metadata = new GithubDecisionMetadata { PrNumber = input.PrNumber, HeadRef = input.HeadRef, @@ -108,7 +108,7 @@ public async Task PrepareArtifact(IDiagnosticsCollector collector, Prepare }; var metadataPath = _fileSystem.Path.Combine(input.OutputDir, "metadata.json"); - var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + var json = JsonSerializer.Serialize(metadata, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata); await _fileSystem.File.WriteAllTextAsync(metadataPath, json, ctx); _logger.LogInformation("Wrote artifact metadata to {Path}", metadataPath); diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogTableRenderers.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogTableRenderers.cs new file mode 100644 index 0000000000..8d1bcd4e05 --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogTableRenderers.cs @@ -0,0 +1,33 @@ +// 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 + +namespace Elastic.Changelog.Evaluation; + +/// +/// Shared GFM table renderers used by , +/// , and . +/// +internal static class ChangelogTableRenderers +{ + internal static string BuildLabelTable(IReadOnlyDictionary? labelToType) => + BuildMappingTable(labelToType, "Label", "Type"); + + internal static string BuildProductLabelTable(IReadOnlyDictionary? labelToProducts) => + BuildMappingTable(labelToProducts, "Label", "Product"); + + internal static string BuildMappingTable(IReadOnlyDictionary? mapping, string keyHeader, string valueHeader) + { + if (mapping is not { Count: > 0 }) + return ""; + + var lines = new List { $"| {keyHeader} | {valueHeader} |", "| --- | --- |" }; + foreach (var (key, value) in mapping) + lines.Add($"| `{EscapePipe(key)}` | {EscapePipe(value)} |"); + + return string.Join("\n", lines); + } + + /// Escapes pipe characters inside GFM table cells. + private static string EscapePipe(string value) => value.Replace("|", "\\|"); +} diff --git a/src/services/Elastic.Changelog/Evaluation/EvaluatePrArguments.cs b/src/services/Elastic.Changelog/Evaluation/EvaluatePrArguments.cs index c911cac569..d56cbaca54 100644 --- a/src/services/Elastic.Changelog/Evaluation/EvaluatePrArguments.cs +++ b/src/services/Elastic.Changelog/Evaluation/EvaluatePrArguments.cs @@ -29,4 +29,10 @@ public record EvaluatePrArguments /// without editing changelog.yml. /// public bool RequireChangelogFile { get; init; } + + // Additional PR context for GithubDecisionMetadata — optional, populated when known. + public bool IsFork { get; init; } + public bool CanCommit { get; init; } + public bool MaintainerCanModify { get; init; } + public string? HeadRepo { get; init; } } diff --git a/src/services/Elastic.Changelog/Evaluation/GithubDecisionMetadata.cs b/src/services/Elastic.Changelog/Evaluation/GithubDecisionMetadata.cs new file mode 100644 index 0000000000..4296981c08 --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/GithubDecisionMetadata.cs @@ -0,0 +1,58 @@ +// 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; +using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.ReleaseNotes; + +namespace Elastic.Changelog.Evaluation; + +/// +/// GitHub-steering data transferred between CI jobs: every field drives a GitHub Actions decision — +/// CanCommit → should-commit, HeadRepo/HeadRef/HeadSha → the checkout, +/// ChangelogDir/ChangelogFilename → the write target, and LabelTable/ +/// ProductLabelTable/SkipLabels/Status → the PR comment bodies. +/// This is an ephemeral upload that exists only for the duration of one check run. +/// +public record GithubDecisionMetadata +{ + public required int PrNumber { get; init; } + public required string HeadRef { get; init; } + public required string HeadSha { get; init; } + public required string Status { get; init; } + public required bool IsFork { get; init; } + public required bool CanCommit { get; init; } + public required bool MaintainerCanModify { get; init; } + public string? HeadRepo { get; init; } + public string? LabelTable { get; init; } + public string? ProductLabelTable { get; init; } + public string? SkipLabels { get; init; } + public string? ConfigFile { get; init; } + public string? ChangelogDir { get; init; } + public string? ChangelogFilename { get; init; } + public CreateRules? CreateRules { get; init; } + /// Outcome of the changelog commit step, written by changelog github-decision after apply. + public CommitOutcome? CommitOutcome { get; init; } + /// Repo-relative path to the committed changelog file, when is Committed. + public string? CommittedFile { get; init; } +} + +/// Outcome of the apply job's changelog commit step. +public enum CommitOutcome +{ + /// The commit step has not run or was not recorded. + None, + /// The changelog file was committed and pushed to the PR branch. + Committed, + /// The commit step ran but failed. + Failed, +} + +[JsonSourceGenerationOptions(WriteIndented = true, UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +[JsonSerializable(typeof(GithubDecisionMetadata))] +[JsonSerializable(typeof(CommitOutcome))] +[JsonSerializable(typeof(CreateRules))] +[JsonSerializable(typeof(FieldMode))] +[JsonSerializable(typeof(MatchMode))] +public sealed partial class GithubDecisionMetadataJsonContext : JsonSerializerContext; diff --git a/src/services/Elastic.Changelog/Evaluation/GithubDecisionMetadataWriter.cs b/src/services/Elastic.Changelog/Evaluation/GithubDecisionMetadataWriter.cs new file mode 100644 index 0000000000..89f85e5dff --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/GithubDecisionMetadataWriter.cs @@ -0,0 +1,76 @@ +// 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; +using Elastic.Documentation.FileSystems; +using Microsoft.Extensions.Logging; + +namespace Elastic.Changelog.Evaluation; + +/// +/// Writes to the conventional decision-artifact location +/// (.artifacts/changelog-decision/metadata.json) so a downstream workflow_run job can +/// pick it up for comment rendering. +/// +/// The path is relative to the working root managed by . +/// .artifacts is already allow-listed as a hidden folder by that file system, so no additional +/// scope configuration is needed. +/// +/// +internal class GithubDecisionMetadataWriter(ILoggerFactory logFactory, IRunnerTempFileSystem fileSystem) +{ + /// + /// Conventional artifact directory, relative to the checkout root. + /// Consumers (action.yml files, docs) should reference this constant rather than hard-coding the path. + /// + internal const string ArtifactDir = ".artifacts/changelog-decision"; + + /// The metadata filename within . + internal const string MetadataFilename = "metadata.json"; + + private readonly ILogger _logger = logFactory.CreateLogger(); + + /// + /// Serialises to .artifacts/changelog-decision/metadata.json + /// in the current working directory. + /// + internal async Task WriteAsync(GithubDecisionMetadata metadata, Cancel ctx) + { + var dir = fileSystem.Path.GetFullPath(ArtifactDir); + _ = fileSystem.Directory.CreateDirectory(dir); + + var path = fileSystem.Path.Combine(dir, MetadataFilename); + var json = JsonSerializer.Serialize(metadata, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata); + await fileSystem.File.WriteAllTextAsync(path, json, ctx); + _logger.LogInformation("Wrote decision metadata to {Path}", path); + } + + /// + /// Reads the metadata file from the conventional location and returns the deserialised record, + /// or null when the file is absent or cannot be parsed. + /// + internal async Task ReadAsync(string metadataPath, Cancel ctx) + { + try + { + var json = await fileSystem.File.ReadAllTextAsync(metadataPath, ctx); + return JsonSerializer.Deserialize(json, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata); + } + catch (FileNotFoundException) + { + _logger.LogInformation("Decision metadata not found at {Path}", metadataPath); + return null; + } + catch (DirectoryNotFoundException) + { + _logger.LogInformation("Decision metadata not found at {Path}", metadataPath); + return null; + } + catch (Exception ex) when (ex is IOException or JsonException) + { + _logger.LogWarning(ex, "Failed to read decision metadata from {Path}", metadataPath); + return null; + } + } +} diff --git a/src/services/Elastic.Changelog/Evaluation/PrepareArtifactArguments.cs b/src/services/Elastic.Changelog/Evaluation/PrepareArtifactArguments.cs index 83739ee128..50793e5986 100644 --- a/src/services/Elastic.Changelog/Evaluation/PrepareArtifactArguments.cs +++ b/src/services/Elastic.Changelog/Evaluation/PrepareArtifactArguments.cs @@ -15,7 +15,7 @@ public record PrepareArtifactArguments public required string HeadRef { get; init; } public required string HeadSha { get; init; } // Nullable bool mirrors the CLI surface: null = "flag not specified", - // normalized to false when serialized into ChangelogArtifactMetadata. + // normalized to false when serialized into GithubDecisionMetadata. // Using non-nullable bool here would force every call site to choose // between true/false and lose the "not specified" signal, which is what // allowed --can-commit "false" to silently set CanCommit = true at the diff --git a/src/services/Elastic.Changelog/Evaluation/ValidateLabelsArguments.cs b/src/services/Elastic.Changelog/Evaluation/ValidateLabelsArguments.cs index d4acbbf2b7..6b91eb5182 100644 --- a/src/services/Elastic.Changelog/Evaluation/ValidateLabelsArguments.cs +++ b/src/services/Elastic.Changelog/Evaluation/ValidateLabelsArguments.cs @@ -9,4 +9,15 @@ public record ValidateLabelsArguments { public required string Config { get; init; } public required string[] PrLabels { get; init; } + + // PR context — passed when running under GitHub Actions so a GithubDecisionMetadata file can be + // written for the downstream github-comment command to pick up. + public int PrNumber { get; init; } + public string HeadRef { get; init; } = ""; + public string HeadSha { get; init; } = ""; + public bool IsFork { get; init; } + public bool CanCommit { get; init; } + public bool MaintainerCanModify { get; init; } + public string? HeadRepo { get; init; } + public string? ConfigFile { get; init; } } diff --git a/src/services/Elastic.Changelog/GitHub/GitHubApiTransport.cs b/src/services/Elastic.Changelog/GitHub/GitHubApiTransport.cs index 8c9de024a3..10b43a0e58 100644 --- a/src/services/Elastic.Changelog/GitHub/GitHubApiTransport.cs +++ b/src/services/Elastic.Changelog/GitHub/GitHubApiTransport.cs @@ -77,6 +77,32 @@ public async Task GetAsync(string url, Cancel ctx = default return await _httpClient.SendAsync(request, ctx).ConfigureAwait(false); } + /// + /// Issues an authenticated POST against a GitHub REST API endpoint with a JSON body. + /// The caller owns the response and its status-code policy. + /// + public async Task PostAsync(string url, string jsonBody, Cancel ctx = default) + { + using var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + AttachAuthorization(request); + return await _httpClient.SendAsync(request, ctx).ConfigureAwait(false); + } + + /// + /// Issues an authenticated PATCH against a GitHub REST API endpoint with a JSON body. + /// The caller owns the response and its status-code policy. + /// + public async Task PatchAsync(string url, string jsonBody, Cancel ctx = default) + { + using var request = new HttpRequestMessage(HttpMethod.Patch, url); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + AttachAuthorization(request); + return await _httpClient.SendAsync(request, ctx).ConfigureAwait(false); + } + /// /// Posts a JSON body to the GitHub GraphQL endpoint. The GraphQL API rejects anonymous /// requests, so callers should verify before building queries. diff --git a/src/services/Elastic.Changelog/GitHub/GitHubCommentService.cs b/src/services/Elastic.Changelog/GitHub/GitHubCommentService.cs new file mode 100644 index 0000000000..714a452fab --- /dev/null +++ b/src/services/Elastic.Changelog/GitHub/GitHubCommentService.cs @@ -0,0 +1,185 @@ +// 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; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace Elastic.Changelog.GitHub; + +/// +/// Posts or updates the sticky changelog PR comment via the GitHub REST API. +/// +/// Identity strategy: matches on the embedded HTML marker +/// <!-- docs-builder:changelog --> (present in all comments written by this service), +/// falling back to the legacy ### 📋 Changelog title prefix used by the predecessor JS +/// scripts (comment-helper.js in changelog/submit/apply/scripts/). The fallback +/// ensures in-flight PRs whose comment was posted by the JS path get edited rather than duplicated +/// when this service takes over. +/// +/// +/// Pagination: GitHub returns at most 100 comments per page; this service fetches all pages before +/// deciding whether to create or update, avoiding the duplicate-comment bug present in +/// docs-preview-local.yml:452-476 and docs-deploy.yml. +/// +/// +/// Failure policy: a transient error (rate-limit, 403, network blip) logs a warning and returns +/// false without calling EmitError. Never let a comment failure flip the verdict. +/// +/// +public partial class GitHubCommentService(ILoggerFactory loggerFactory, GitHubApiTransport? transport = null) : IGitHubCommentService +{ + /// + /// HTML marker embedded at the end of every comment body written by this service. + /// Used as the primary identity key when searching for an existing sticky comment. + /// + private const string HtmlMarker = ""; + + /// + /// Legacy title prefix used by the predecessor JS scripts in comment-helper.js. + /// Matched as a fallback so in-flight PRs whose comment was posted by JS get updated, + /// not duplicated. + /// + internal const string LegacyTitlePrefix = "### 📋 Changelog"; + + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private readonly GitHubApiTransport _transport = transport ?? new GitHubApiTransport(); + + /// + public async Task UpsertStickyCommentAsync(string owner, string repo, int prNumber, string body, Cancel ctx = default) + { + var markedBody = body.TrimEnd() + "\n" + HtmlMarker; + + try + { + var existingId = await FindExistingCommentIdAsync(owner, repo, prNumber, ctx); + + if (existingId.HasValue) + { + var updateUrl = $"https://api.github.com/repos/{owner}/{repo}/issues/comments/{existingId.Value}"; + var updatePayload = JsonSerializer.Serialize(new CommentBody { Body = markedBody }, CommentJsonContext.Default.CommentBody); + using var updateResponse = await _transport.PatchAsync(updateUrl, updatePayload, ctx); + if (!updateResponse.IsSuccessStatusCode) + { + _logger.LogWarning( + "Failed to update comment {CommentId} on PR #{PrNumber}: {Status}", + existingId.Value, + prNumber, + (int)updateResponse.StatusCode + ); + return false; + } + + _logger.LogInformation("Updated changelog comment {CommentId} on PR #{PrNumber}", existingId.Value, prNumber); + return true; + } + + var createUrl = $"https://api.github.com/repos/{owner}/{repo}/issues/{prNumber}/comments"; + var createPayload = JsonSerializer.Serialize(new CommentBody { Body = markedBody }, CommentJsonContext.Default.CommentBody); + using var createResponse = await _transport.PostAsync(createUrl, createPayload, ctx); + if (!createResponse.IsSuccessStatusCode) + { + _logger.LogWarning( + "Failed to create changelog comment on PR #{PrNumber}: {Status}", + prNumber, + (int)createResponse.StatusCode + ); + return false; + } + + _logger.LogInformation("Created changelog comment on PR #{PrNumber}", prNumber); + return true; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "HTTP error posting changelog comment on PR #{PrNumber}", prNumber); + return false; + } + catch (TaskCanceledException) + { + _logger.LogWarning("Timeout posting changelog comment on PR #{PrNumber}", prNumber); + return false; + } + catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or ThreadAbortException)) + { + _logger.LogWarning(ex, "Unexpected error posting changelog comment on PR #{PrNumber}", prNumber); + return false; + } + } + + /// + /// Paginates through all PR comments and returns the ID of the first comment that matches + /// either the HTML marker or the legacy title prefix; null when none is found. + /// + private async Task FindExistingCommentIdAsync(string owner, string repo, int prNumber, Cancel ctx) + { + var page = 1; + const int perPage = 100; + + while (true) + { + var url = $"https://api.github.com/repos/{owner}/{repo}/issues/{prNumber}/comments?per_page={perPage}&page={page}"; + using var response = await _transport.GetAsync(url, ctx); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Failed to list comments for PR #{PrNumber} (page {Page}): {Status}", + prNumber, + page, + (int)response.StatusCode + ); + return null; + } + + var json = await response.Content.ReadAsStringAsync(ctx); + var comments = JsonSerializer.Deserialize(json, CommentJsonContext.Default.ListCommentItem); + + if (comments is null or { Count: 0 }) + return null; + + foreach (var comment in comments) + { + if (comment.User?.Login != "github-actions[bot]") + continue; + + var commentBody = comment.Body ?? string.Empty; + if ( + commentBody.Contains(HtmlMarker, StringComparison.Ordinal) + || commentBody.StartsWith(LegacyTitlePrefix, StringComparison.Ordinal) + ) + return comment.Id; + } + + // GitHub omits the Link header or returns fewer than perPage items on the last page. + if (comments.Count < perPage) + return null; + + page++; + } + } + + private sealed class CommentItem + { + public long Id { get; set; } + public string? Body { get; set; } + public CommentUser? User { get; set; } + } + + private sealed class CommentUser + { + public string? Login { get; set; } + } + + private sealed class CommentBody + { + public string Body { get; set; } = string.Empty; + } + + [JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] + [JsonSerializable(typeof(CommentItem))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(CommentBody))] + private sealed partial class CommentJsonContext : JsonSerializerContext; +} diff --git a/src/services/Elastic.Changelog/GitHub/IGitHubCommentService.cs b/src/services/Elastic.Changelog/GitHub/IGitHubCommentService.cs new file mode 100644 index 0000000000..65f2f172d0 --- /dev/null +++ b/src/services/Elastic.Changelog/GitHub/IGitHubCommentService.cs @@ -0,0 +1,21 @@ +// 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 + +namespace Elastic.Changelog.GitHub; + +/// Posts or updates a sticky changelog comment on a GitHub pull request. +public interface IGitHubCommentService +{ + /// + /// Creates or updates the sticky changelog comment on the given pull request. + /// If an existing comment is found (by the legacy ### 📋 Changelog prefix or the + /// embedded <!-- docs-builder:changelog --> marker), it is edited in-place; + /// otherwise a new comment is created. + /// + /// + /// true on success or when the API responds with a transient non-fatal error; + /// false only when the operation is definitively known to have failed. + /// + Task UpsertStickyCommentAsync(string owner, string repo, int prNumber, string body, Cancel ctx = default); +} diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 9742d84fdd..44f4162744 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -1729,6 +1729,10 @@ public async Task EvaluatePr( bool stripTitlePrefix = false, bool requireChangelogFile = false, string botName = "github-actions[bot]", + bool isFork = false, + bool canCommit = false, + bool maintainerCanModify = false, + string? headRepo = null, CancellationToken ct = default ) { @@ -1737,7 +1741,14 @@ public async Task EvaluatePr( var fileSystem = RunnerTempFileSystem.ForEvaluatePr(environmentVariables); IGitHubPrService prService = new GitHubPrService(logFactory); - var service = new ChangelogPrEvaluationService(logFactory, configurationContext, prService, githubActionsService, fileSystem); + var service = new ChangelogPrEvaluationService( + logFactory, + configurationContext, + prService, + githubActionsService, + fileSystem, + environmentVariables + ); var prBodyFile = environmentVariables.GetEnvironmentVariable("PR_BODY_FILE"); var prBody = !string.IsNullOrWhiteSpace(prBodyFile) @@ -1760,7 +1771,11 @@ public async Task EvaluatePr( BodyChanged = bodyChanged, StripTitlePrefix = stripTitlePrefix, RequireChangelogFile = requireChangelogFile, - BotName = botName + BotName = botName, + IsFork = isFork, + CanCommit = canCommit, + MaintainerCanModify = maintainerCanModify, + HeadRepo = headRepo }; serviceInvoker.AddCommand(service, args, static async (s, collector, state, ctx) => await s.EvaluatePr(collector, state, ctx)); @@ -1783,11 +1798,25 @@ public async Task EvaluatePr( /// /// Path to the changelog.yml configuration file. /// Comma-separated list of PR labels (use ${{ join(github.event.pull_request.labels.*.name, ',') }} in actions). + /// PR number — required for decision metadata written when running on CI. + /// PR head branch ref — written to decision metadata when on CI. + /// PR head commit SHA — written to decision metadata when on CI. + /// Whether the PR is from a fork. + /// Whether the commit strategy allows committing. + /// Whether the fork PR allows maintainer edits. + /// Fork repository full name (owner/repo). /// Cancellation token [NoOptionsInjection] public async Task ValidateLabels( [FileExtensions(Extensions = "yml,yaml")] FileInfo config, string prLabels, + int prNumber = 0, + string headRef = "", + string headSha = "", + bool isFork = false, + bool canCommit = false, + bool maintainerCanModify = false, + string? headRepo = null, CancellationToken ct = default ) { @@ -1795,12 +1824,26 @@ public async Task ValidateLabels( await using var serviceInvoker = new ServiceInvoker(collector); var fileSystem = RunnerTempFileSystem.ForEvaluatePr(environmentVariables); - var service = new ChangelogLabelValidationService(logFactory, configurationContext, githubActionsService, fileSystem); + var service = new ChangelogLabelValidationService( + logFactory, + configurationContext, + githubActionsService, + fileSystem, + environmentVariables + ); var args = new ValidateLabelsArguments { Config = config.FullName, - PrLabels = prLabels.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + PrLabels = prLabels.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + PrNumber = prNumber, + HeadRef = headRef, + HeadSha = headSha, + IsFork = isFork, + CanCommit = canCommit, + MaintainerCanModify = maintainerCanModify, + HeadRepo = headRepo, + ConfigFile = config.FullName }; serviceInvoker.AddCommand(service, args, static async (s, collector, state, ctx) => await s.ValidateLabels(collector, state, ctx)); @@ -1920,6 +1963,75 @@ static async (s, collector, state, ctx) => await s.EvaluateArtifact(collector, s return await serviceInvoker.InvokeAsync(ctx); } + /// (CI, hidden) Record commit outcome into the decision metadata file. + /// + /// Reads metadata.json, amends it with the commit step result, and writes it back. + /// Run by submit/apply after the git-push step so the downstream + /// changelog github-comment command knows which body to render. + /// + /// Path to the decision metadata.json file + /// Outcome of the changelog commit step + /// Repo-relative path to the committed file (when is Committed) + [Hidden] + [NoOptionsInjection] + public async Task GithubDecision( + string metadata, + CommitOutcome commitOutcome, + string? committedFile = null, + CancellationToken ct = default + ) + { + var ctx = ct; + await using var serviceInvoker = new ServiceInvoker(collector); + + var fs = RunnerTempFileSystem.ForEvaluateArtifact(metadata); + var service = new ChangelogGithubDecisionService(logFactory, fs); + + var args = new GithubDecisionArguments { MetadataPath = metadata, CommitOutcome = commitOutcome, CommittedFile = committedFile }; + + serviceInvoker.AddCommand(service, args, static async (s, _, state, ctx) => await s.RecordDecision(state, ctx)); + + return await serviceInvoker.InvokeAsync(ctx); + } + + /// (CI, hidden) Post or update the sticky changelog comment on the PR. + /// + /// Reads the decision metadata and renders the appropriate body based on the validation status + /// and commit outcome. Owner and repo are resolved from the GITHUB_REPOSITORY environment + /// variable (always set by GitHub Actions). This command is only meaningful under + /// GITHUB_ACTIONS and must only be invoked from a job with pull-requests: write. + /// + /// Path to the decision metadata.json file + [Hidden] + [NoOptionsInjection] + public async Task GithubComment(string metadata, CancellationToken ct = default) + { + var ctx = ct; + await using var serviceInvoker = new ServiceInvoker(collector); + + // Parse owner/repo from GITHUB_REPOSITORY ("owner/repo"). + var githubRepository = environmentVariables.GetEnvironmentVariable("GITHUB_REPOSITORY") ?? ""; + var repoParts = githubRepository.Split('/', 2); + var owner = repoParts.Length == 2 ? repoParts[0] : githubRepository; + var repo = repoParts.Length == 2 ? repoParts[1] : githubRepository; + + var fs = RunnerTempFileSystem.ForEvaluateArtifact(metadata); + IGitHubCommentService commentSvc = new GitHubCommentService(logFactory); + var service = new ChangelogGithubCommentService(logFactory, commentSvc, fs); + + var args = new GithubCommentArguments + { + MetadataPath = metadata, + MetadataDir = Path.GetDirectoryName(metadata) ?? ".", + Owner = owner, + Repo = repo + }; + + serviceInvoker.AddCommand(service, args, static async (s, _, state, ctx) => await s.PostComment(state, ctx)); + + return await serviceInvoker.InvokeAsync(ctx); + } + private static List ExpandCommaSeparated(string[]? values) { if (values is not { Length: > 0 }) diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs index 032da7e341..7d0f9bbe95 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs @@ -28,16 +28,16 @@ public class ChangelogArtifactEvaluationServiceTests(ITestOutputHelper output) : private static EvaluateArtifactArguments DefaultArgs() => new() { MetadataPath = MetadataFilePath, Owner = "elastic", Repo = "test-repo" }; - private async Task WriteMetadata(ChangelogArtifactMetadata metadata, string? path = null) + private async Task WriteMetadata(GithubDecisionMetadata metadata, string? path = null) { path ??= MetadataFilePath; var dir = FileSystem.Path.GetDirectoryName(path)!; FileSystem.Directory.CreateDirectory(dir); - var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + var json = JsonSerializer.Serialize(metadata, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata); await FileSystem.File.WriteAllTextAsync(path, json); } - private static ChangelogArtifactMetadata DefaultMetadata( + private static GithubDecisionMetadata DefaultMetadata( string status = "success", string? changelogFilename = "42.yaml", bool canCommit = true diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogCommentRendererTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogCommentRendererTests.cs new file mode 100644 index 0000000000..6513f2b26a --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogCommentRendererTests.cs @@ -0,0 +1,148 @@ +// 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 AwesomeAssertions; +using Elastic.Changelog.Evaluation; + +namespace Elastic.Changelog.Tests.Evaluation; + +public class ChangelogCommentRendererTests +{ + [Fact] + public void RenderEntryCommitted_EscapesUrlComponents() + { + var body = ChangelogCommentRenderer.RenderEntryCommitted("elastic", "test repo", "feature/my branch", "docs/changelog/42 fix.yaml"); + + body.Should().Contain("feature%2Fmy%20branch"); + body.Should().Contain("42%20fix.yaml"); + } + + [Fact] + public void RenderCommentOnly_WithYaml_ContainsCodeFence() + { + var body = ChangelogCommentRenderer.RenderCommentOnly("docs/changelog", "type: feature\ntitle: Test", "42.yaml", false, false); + + body.Should().Contain("```yaml"); + body.Should().Contain("type: feature"); + } + + [Fact] + public void RenderCommentOnly_ForkVariant_ContainsInformationalGuidance() + { + var body = ChangelogCommentRenderer.RenderCommentOnly( + "docs/changelog", + "type: feature", + "42.yaml", + isFork: true, + commitFailed: false + ); + + body.Should().Contain("regenerated from the live PR record"); + } + + [Fact] + public void RenderCommentOnly_CommitFailedVariant_ContainsFailureGuidance() + { + var body = ChangelogCommentRenderer.RenderCommentOnly( + "docs/changelog", + "type: feature", + "42.yaml", + isFork: false, + commitFailed: true + ); + + body.Should().Contain("could not commit"); + } + + [Fact] + public void RenderCommentOnly_NoYaml_ContainsWarning() + { + var body = ChangelogCommentRenderer.RenderCommentOnly("docs/changelog", null, null, false, false); + + body.Should().Contain("could not be read"); + } + + [Fact] + public void RenderCannotGenerate_TypeMissing_ContainsTypeLabelHeadline() + { + var body = ChangelogCommentRenderer.RenderCannotGenerate("| type:feature | feature |", null, null, null); + + body.Should().Contain("no matching type label"); + body.Should().Contain("type:feature"); + } + + [Fact] + public void RenderCannotGenerate_ProductMissing_ContainsProductLabelHeadline() + { + var body = ChangelogCommentRenderer.RenderCannotGenerate(null, "| @Product:ECH | cloud |", null, null); + + body.Should().Contain("no matching product label"); + body.Should().Contain("@Product:ECH"); + } + + [Fact] + public void RenderCannotGenerate_BothMissing_ContainsBothTables() + { + var body = ChangelogCommentRenderer.RenderCannotGenerate("| type:feature | feature |", "| @Product:ECH | cloud |", null, null); + + body.Should().Contain("type and product labels are missing"); + body.Should().Contain("type:feature"); + body.Should().Contain("@Product:ECH"); + } + + [Fact] + public void RenderResolved_ContainsTitleAndCheckmark() + { + var body = ChangelogCommentRenderer.RenderResolved(); + + body.Should().Contain(ChangelogCommentRenderer.Title); + body.Should().Contain("✅"); + } + + [Fact] + public void WrapCodeFence_ContentWithThreeBacktickRun_UsesFourBackticks() + { + var content = "prefix ``` suffix"; + var fenced = ChangelogCommentRenderer.WrapCodeFence(content); + + fenced.Should().StartWith("````"); + } + + [Fact] + public void WrapInlineCode_ValueStartsWithBacktick_AddsPadding() + { + var result = ChangelogCommentRenderer.WrapInlineCode("`starts-with-tick"); + + result.Should().Contain(" `starts-with-tick "); + } + + [Theory] + [InlineData("entry-committed")] + [InlineData("comment-only")] + [InlineData("cannot-generate")] + [InlineData("resolved")] + public void AllBodies_StartWithTitle(string variant) + { + var body = variant switch + { + "entry-committed" => ChangelogCommentRenderer.RenderEntryCommitted("owner", "repo", "main", "file.yaml"), + "comment-only" => ChangelogCommentRenderer.RenderCommentOnly(null, "type: feature", "42.yaml", false, false), + "cannot-generate" => ChangelogCommentRenderer.RenderCannotGenerate("| label | type |", null, null, null), + "resolved" => ChangelogCommentRenderer.RenderResolved(), + _ => throw new InvalidOperationException() + }; + + body.Should().StartWith(ChangelogCommentRenderer.Title); + } + + [Fact] + public void RenderCommentOnly_LongBody_TruncatesAt65536() + { + var longYaml = new string('x', 70_000); + var body = ChangelogCommentRenderer.RenderCommentOnly(null, longYaml, "42.yaml", false, false); + + body.Length.Should().BeLessThanOrEqualTo(65_536); + body.Should().Contain("truncated"); + } +} diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogGithubCommentServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogGithubCommentServiceTests.cs new file mode 100644 index 0000000000..25b465f6e9 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogGithubCommentServiceTests.cs @@ -0,0 +1,197 @@ +// 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 AwesomeAssertions; +using Elastic.Changelog.Evaluation; +using Elastic.Changelog.GitHub; +using Elastic.Changelog.Tests.Changelogs; +using Elastic.Documentation.Configuration; +using FakeItEasy; + +namespace Elastic.Changelog.Tests.Evaluation; + +public class ChangelogGithubCommentServiceTests(ITestOutputHelper output) : ChangelogTestBase(output) +{ + private static readonly string Root = Paths.WorkingDirectoryRoot.FullName; + + private string MetadataPath => Path.Join(Root, GithubDecisionMetadataWriter.ArtifactDir, GithubDecisionMetadataWriter.MetadataFilename); + private string MetadataDir => Path.Join(Root, GithubDecisionMetadataWriter.ArtifactDir); + + private async Task WriteMetadata(GithubDecisionMetadata metadata) + { + var writer = new GithubDecisionMetadataWriter(LoggerFactory, RunnerTempFileSystem); + await writer.WriteAsync(metadata, CancellationToken.None); + } + + private void WriteYaml(string filename, string content = "type: feature\ntitle: Test") + { + var path = RunnerTempFileSystem.Path.Join(MetadataDir, filename); + RunnerTempFileSystem.File.WriteAllText(path, content); + } + + private ChangelogGithubCommentService CreateService(IGitHubCommentService commentSvc) => + new(LoggerFactory, commentSvc, RunnerTempFileSystem); + + private GithubCommentArguments DefaultArgs() => + new() { MetadataPath = MetadataPath, MetadataDir = MetadataDir, Owner = "elastic", Repo = "test-repo" }; + + private static GithubDecisionMetadata BaseMetadata(string status = "proceed", bool canCommit = true, bool isFork = false) => + new() + { + PrNumber = 42, + HeadRef = "feature/test", + HeadSha = "abc123", + Status = status, + IsFork = isFork, + CanCommit = canCommit, + MaintainerCanModify = false, + HeadRepo = "elastic/test-repo" + }; + + [Fact] + public async Task PostComment_CommittedOutcome_RendersEntryCommittedBody() + { + await WriteMetadata(BaseMetadata() with { CommitOutcome = CommitOutcome.Committed, CommittedFile = "docs/changelog/42.yaml" }); + var commentSvc = A.Fake(); + A.CallTo( + () => commentSvc.UpsertStickyCommentAsync(A._, A._, A._, A._, A._) + ).Returns(true); + + var result = await CreateService(commentSvc).PostComment(DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + A.CallTo( + () => commentSvc.UpsertStickyCommentAsync( + "elastic", + "test-repo", + 42, + A.That.Contains("docs/changelog/42.yaml"), + A._ + ) + ).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task PostComment_CommitFailed_RendersCommentOnlyWithFailureGuidance() + { + await WriteMetadata(BaseMetadata() with { CommitOutcome = CommitOutcome.Failed }); + WriteYaml("42.yaml"); + var commentSvc = A.Fake(); + A.CallTo( + () => commentSvc.UpsertStickyCommentAsync(A._, A._, A._, A._, A._) + ).Returns(true); + + await CreateService(commentSvc).PostComment(DefaultArgs(), CancellationToken.None); + + A.CallTo( + () => commentSvc.UpsertStickyCommentAsync( + A._, + A._, + A._, + A.That.Contains("could not commit"), + A._ + ) + ).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task PostComment_SuccessNotCanCommit_RendersCommentOnlyInformational() + { + await WriteMetadata(BaseMetadata(status: "proceed", canCommit: false)); + WriteYaml("42.yaml"); + var commentSvc = A.Fake(); + A.CallTo( + () => commentSvc.UpsertStickyCommentAsync(A._, A._, A._, A._, A._) + ).Returns(true); + + await CreateService(commentSvc).PostComment(DefaultArgs(), CancellationToken.None); + + A.CallTo( + () => commentSvc.UpsertStickyCommentAsync( + A._, + A._, + A._, + A.That.Contains("informational"), + A._ + ) + ).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task PostComment_NoLabel_RendersCannotGenerateBody() + { + await WriteMetadata(BaseMetadata(status: "no-label") with + { + LabelTable = "| type:feature | feature |", + SkipLabels = "changelog:skip" + }); + var commentSvc = A.Fake(); + A.CallTo( + () => commentSvc.UpsertStickyCommentAsync(A._, A._, A._, A._, A._) + ).Returns(true); + + await CreateService(commentSvc).PostComment(DefaultArgs(), CancellationToken.None); + + A.CallTo( + () => commentSvc.UpsertStickyCommentAsync( + A._, + A._, + A._, + A.That.Contains("Cannot generate changelog"), + A._ + ) + ).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task PostComment_SuccessWithNoYaml_RendersResolvedBody() + { + await WriteMetadata(BaseMetadata(status: "proceed", canCommit: true)); + // no yaml file in MetadataDir + var commentSvc = A.Fake(); + A.CallTo( + () => commentSvc.UpsertStickyCommentAsync(A._, A._, A._, A._, A._) + ).Returns(true); + + await CreateService(commentSvc).PostComment(DefaultArgs(), CancellationToken.None); + + A.CallTo( + () => commentSvc.UpsertStickyCommentAsync( + A._, + A._, + A._, + A.That.Contains("✅"), + A._ + ) + ).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task PostComment_MetadataNotFound_ReturnsTrueWithoutCalling() + { + var commentSvc = A.Fake(); + var args = DefaultArgs() with { MetadataPath = Path.Join(Root, "missing", "metadata.json") }; + + var result = await CreateService(commentSvc).PostComment(args, CancellationToken.None); + + result.Should().BeTrue(); + A.CallTo( + () => commentSvc.UpsertStickyCommentAsync(A._, A._, A._, A._, A._) + ).MustNotHaveHappened(); + } + + [Fact] + public async Task PostComment_CommentServiceFails_ReturnsTrueAnyway() + { + await WriteMetadata(BaseMetadata(status: "no-label") with { LabelTable = "| label | type |" }); + var commentSvc = A.Fake(); + A.CallTo( + () => commentSvc.UpsertStickyCommentAsync(A._, A._, A._, A._, A._) + ).Returns(false); + + var result = await CreateService(commentSvc).PostComment(DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + } +} diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogGithubDecisionServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogGithubDecisionServiceTests.cs new file mode 100644 index 0000000000..dd06c962eb --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogGithubDecisionServiceTests.cs @@ -0,0 +1,115 @@ +// 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; +using AwesomeAssertions; +using Elastic.Changelog.Evaluation; +using Elastic.Changelog.Tests.Changelogs; +using Elastic.Documentation.Configuration; + +namespace Elastic.Changelog.Tests.Evaluation; + +public class ChangelogGithubDecisionServiceTests(ITestOutputHelper output) : ChangelogTestBase(output) +{ + private static readonly string Root = Paths.WorkingDirectoryRoot.FullName; + + private string MetadataPath => Path.Join(Root, GithubDecisionMetadataWriter.ArtifactDir, GithubDecisionMetadataWriter.MetadataFilename); + + private ChangelogGithubDecisionService CreateService() => new(LoggerFactory, RunnerTempFileSystem); + + private async Task WriteMetadata(GithubDecisionMetadata metadata) + { + var writer = new GithubDecisionMetadataWriter(LoggerFactory, RunnerTempFileSystem); + await writer.WriteAsync(metadata, CancellationToken.None); + } + + private async Task ReadMetadata() + { + var writer = new GithubDecisionMetadataWriter(LoggerFactory, RunnerTempFileSystem); + return await writer.ReadAsync(MetadataPath, CancellationToken.None); + } + + private static GithubDecisionMetadata BaseMetadata(int prNumber = 42) => + new() + { + PrNumber = prNumber, + HeadRef = "feature/test", + HeadSha = "abc123", + Status = "proceed", + IsFork = false, + CanCommit = true, + MaintainerCanModify = false + }; + + [Fact] + public async Task RecordDecision_MetadataExists_UpdatesCommitOutcomeAndFile() + { + await WriteMetadata(BaseMetadata()); + var service = CreateService(); + var args = new GithubDecisionArguments + { + MetadataPath = MetadataPath, + CommitOutcome = CommitOutcome.Committed, + CommittedFile = "docs/changelog/42.yaml" + }; + + var result = await service.RecordDecision(args, CancellationToken.None); + + result.Should().BeTrue(); + var updated = await ReadMetadata(); + updated.Should().NotBeNull(); + updated!.CommitOutcome.Should().Be(CommitOutcome.Committed); + updated.CommittedFile.Should().Be("docs/changelog/42.yaml"); + } + + [Fact] + public async Task RecordDecision_MetadataNotFound_ReturnsTrueWithoutCrashing() + { + var service = CreateService(); + var args = new GithubDecisionArguments + { + MetadataPath = Path.Join(Root, "nonexistent", "metadata.json"), + CommitOutcome = CommitOutcome.Failed + }; + + var result = await service.RecordDecision(args, CancellationToken.None); + + result.Should().BeTrue(); + } + + [Fact] + public async Task RecordDecision_Roundtrip_PreservesAllOtherFields() + { + var original = BaseMetadata(prNumber: 99) with + { + HeadRef = "my-branch", + HeadSha = "def456", + IsFork = true, + CanCommit = false, + LabelTable = "| label | type |", + SkipLabels = "changelog:skip" + }; + await WriteMetadata(original); + + var service = CreateService(); + await service.RecordDecision( + new GithubDecisionArguments + { + MetadataPath = MetadataPath, + CommitOutcome = CommitOutcome.Committed, + CommittedFile = "docs/changelog/99.yaml" + }, + CancellationToken.None + ); + + var updated = await ReadMetadata(); + updated!.PrNumber.Should().Be(99); + updated.HeadRef.Should().Be("my-branch"); + updated.HeadSha.Should().Be("def456"); + updated.IsFork.Should().BeTrue(); + updated.CanCommit.Should().BeFalse(); + updated.LabelTable.Should().Be("| label | type |"); + updated.SkipLabels.Should().Be("changelog:skip"); + } +} diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogLabelValidationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogLabelValidationServiceTests.cs new file mode 100644 index 0000000000..61f0b6538c --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogLabelValidationServiceTests.cs @@ -0,0 +1,161 @@ +// 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 Actions.Core.Services; +using AwesomeAssertions; +using Elastic.Changelog.Evaluation; +using Elastic.Changelog.Tests.Changelogs; +using Elastic.Documentation; +using Elastic.Documentation.Configuration; +using FakeItEasy; + +namespace Elastic.Changelog.Tests.Evaluation; + +public class ChangelogLabelValidationServiceTests(ITestOutputHelper output) : ChangelogTestBase(output) +{ + private static readonly string Root = Paths.WorkingDirectoryRoot.FullName; + private readonly ICoreService _mockCore = A.Fake(); + + // All three required types must be present or the config loader emits an error and falls back to Default. + private const string MinimalConfig = + """ + pivot: + types: + feature: "type:feature" + bug-fix: + breaking-change: + """; + + private const string ConfigWithExcludeRule = + """ + pivot: + types: + feature: "type:feature" + bug-fix: + breaking-change: + rules: + create: + exclude: "changelog:skip" + """; + + private string ConfigPath => Path.Join(Root, "changelog.yml"); + + private string MetadataPath => Path.Join(Root, GithubDecisionMetadataWriter.ArtifactDir, GithubDecisionMetadataWriter.MetadataFilename); + + private async Task WriteConfig(string content) + { + FileSystem.Directory.CreateDirectory(Root); + await FileSystem.File.WriteAllTextAsync(ConfigPath, content); + } + + private async Task ReadMetadata() + { + var reader = new GithubDecisionMetadataWriter(LoggerFactory, RunnerTempFileSystem); + return await reader.ReadAsync(MetadataPath, CancellationToken.None); + } + + private ChangelogLabelValidationService CreateService(IEnvironmentVariables? env = null) => + new(LoggerFactory, ConfigurationContext, _mockCore, RunnerTempFileSystem, env); + + private ValidateLabelsArguments DefaultArgs(string[]? labels = null, int prNumber = 0) => + new() + { + Config = ConfigPath, + PrLabels = labels ?? ["type:feature"], + PrNumber = prNumber, + HeadRef = "feature/test", + HeadSha = "abc123", + CanCommit = true + }; + + private void VerifyOutputSet(string name, string value) => A.CallTo(() => _mockCore.SetOutputAsync(name, value)).MustHaveHappened(); + + [Fact] + public async Task ValidateLabels_MatchingTypeLabel_ReturnsTrue() + { + await WriteConfig(MinimalConfig); + + var result = await CreateService().ValidateLabels(Collector, DefaultArgs(["type:feature"]), CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("status", "ok"); + } + + [Fact] + public async Task ValidateLabels_NoMatchingLabel_ReturnsFalse() + { + await WriteConfig(MinimalConfig); + + var result = await CreateService().ValidateLabels(Collector, DefaultArgs([]), CancellationToken.None); + + result.Should().BeFalse(); + VerifyOutputSet("status", "no-label"); + } + + [Fact] + public async Task ValidateLabels_SkipLabelPresent_ReturnsSkipped() + { + await WriteConfig(ConfigWithExcludeRule); + + var result = await CreateService().ValidateLabels(Collector, DefaultArgs(["changelog:skip"]), CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("status", "skipped"); + } + + [Fact] + public async Task ValidateLabels_OnCI_WithPrNumber_WritesMetadataFile() + { + await WriteConfig(MinimalConfig); + var env = A.Fake(); + A.CallTo(() => env.IsRunningOnCI).Returns(true); + + await CreateService(env).ValidateLabels(Collector, DefaultArgs(["type:feature"], prNumber: 42), CancellationToken.None); + + var metadata = await ReadMetadata(); + metadata.Should().NotBeNull(); + metadata!.PrNumber.Should().Be(42); + metadata.HeadRef.Should().Be("feature/test"); + metadata.Status.Should().Be("ok"); + } + + [Fact] + public async Task ValidateLabels_NotOnCI_DoesNotWriteMetadataFile() + { + await WriteConfig(MinimalConfig); + var env = A.Fake(); + A.CallTo(() => env.IsRunningOnCI).Returns(false); + + await CreateService(env).ValidateLabels(Collector, DefaultArgs(["type:feature"], prNumber: 42), CancellationToken.None); + + RunnerTempFileSystem.File.Exists(MetadataPath).Should().BeFalse(); + } + + [Fact] + public async Task ValidateLabels_OnCI_NoPrNumber_DoesNotWriteMetadataFile() + { + await WriteConfig(MinimalConfig); + var env = A.Fake(); + A.CallTo(() => env.IsRunningOnCI).Returns(true); + + await CreateService(env).ValidateLabels(Collector, DefaultArgs(["type:feature"], prNumber: 0), CancellationToken.None); + + RunnerTempFileSystem.File.Exists(MetadataPath).Should().BeFalse(); + } + + [Fact] + public async Task ValidateLabels_NoLabel_OnCI_WritesMetadataWithNoLabelStatus() + { + await WriteConfig(MinimalConfig); + var env = A.Fake(); + A.CallTo(() => env.IsRunningOnCI).Returns(true); + + await CreateService(env).ValidateLabels(Collector, DefaultArgs([], prNumber: 42), CancellationToken.None); + + var metadata = await ReadMetadata(); + metadata.Should().NotBeNull(); + metadata!.PrNumber.Should().Be(42); + metadata.Status.Should().Be("no-label"); + } +} diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs index b10c364101..d46032d332 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs @@ -78,10 +78,10 @@ private async Task SetupConfig(string? configPath = null) await FileSystem.File.WriteAllTextAsync(configPath, MinimalConfig); } - private ChangelogArtifactMetadata ReadMetadata() + private GithubDecisionMetadata ReadMetadata() { var json = FileSystem.File.ReadAllText(Path.Join(OutputDir, "metadata.json")); - return JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata)!; + return JsonSerializer.Deserialize(json, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata)!; } [Fact] @@ -127,7 +127,7 @@ public async Task PrepareArtifact_DotArtifactsPaths_CopiesYamlAndWritesMetadata( result.Should().BeTrue(); RunnerTempFileSystem.File.Exists(Path.Join(artifactsOutput, "42.yaml")).Should().BeTrue(); var json = RunnerTempFileSystem.File.ReadAllText(Path.Join(artifactsOutput, "metadata.json")); - var metadata = JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata)!; + var metadata = JsonSerializer.Deserialize(json, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata)!; metadata.Status.Should().Be("success"); metadata.ChangelogFilename.Should().Be("42.yaml"); } diff --git a/tests/Elastic.Changelog.Tests/Evaluation/GitHubCommentServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/GitHubCommentServiceTests.cs new file mode 100644 index 0000000000..e5070c8ca1 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Evaluation/GitHubCommentServiceTests.cs @@ -0,0 +1,153 @@ +// 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.Net; +using System.Text; +using System.Text.Json; +using AwesomeAssertions; +using Elastic.Changelog.GitHub; + +namespace Elastic.Changelog.Tests.Evaluation; + +public class GitHubCommentServiceTests(ITestOutputHelper output) +{ + private const string Owner = "elastic"; + private const string Repo = "test"; + private const int PrNumber = 42; + + private static HttpResponseMessage Json(string body) => + new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + private static HttpResponseMessage Created() => new(HttpStatusCode.Created); + private static HttpResponseMessage Ok() => new(HttpStatusCode.OK); + + private static string CommentListJson(long id, string body, string login = "github-actions[bot]") => + $"[{{\"id\":{id},\"body\":{JsonSerializer.Serialize(body)},\"user\":{{\"login\":\"{login}\"}}}}]"; + + private GitHubCommentService Service(StubHandler handler) => + new(new TestLoggerFactory(output), new GitHubApiTransport(handler, "test-token")); + + [Fact] + public async Task UpsertStickyComment_NoExistingComment_CreatesNewComment() + { + var requests = new List<(string method, string path)>(); + var handler = new StubHandler(req => + { + requests.Add((req.Method.Method, req.RequestUri!.AbsolutePath)); + return req.Method.Method == "GET" ? Json("[]") : Created(); + }); + + var result = await Service(handler).UpsertStickyCommentAsync(Owner, Repo, PrNumber, "### 📋 Changelog\n\nHello"); + + result.Should().BeTrue(); + requests.Should().ContainSingle(r => r.method == "POST" && r.path.Contains($"/issues/{PrNumber}/comments")); + } + + [Fact] + public async Task UpsertStickyComment_ExistingCommentWithMarker_UpdatesExisting() + { + const long existingId = 99; + var body = "### 📋 Changelog\nOld content\n"; + var requests = new List<(string method, string path)>(); + var handler = new StubHandler(req => + { + requests.Add((req.Method.Method, req.RequestUri!.AbsolutePath)); + if (req.Method.Method == "GET") + return Json(CommentListJson(existingId, body)); + return Ok(); + }); + + var result = await Service(handler).UpsertStickyCommentAsync(Owner, Repo, PrNumber, "### 📋 Changelog\nNew"); + + result.Should().BeTrue(); + requests.Should().ContainSingle(r => r.method == "PATCH" && r.path.Contains($"/issues/comments/{existingId}")); + requests.Should().NotContain(r => r.method == "POST"); + } + + [Fact] + public async Task UpsertStickyComment_ExistingCommentWithLegacyPrefix_UpdatesExisting() + { + const long existingId = 77; + var legacyBody = GitHubCommentService.LegacyTitlePrefix + "\nOld JS comment, no marker"; + var requests = new List<(string method, string path)>(); + var handler = new StubHandler(req => + { + requests.Add((req.Method.Method, req.RequestUri!.AbsolutePath)); + if (req.Method.Method == "GET") + return Json(CommentListJson(existingId, legacyBody)); + return Ok(); + }); + + var result = await Service(handler).UpsertStickyCommentAsync(Owner, Repo, PrNumber, "### 📋 Changelog\nNew"); + + result.Should().BeTrue(); + requests.Should().ContainSingle(r => r.method == "PATCH" && r.path.Contains($"/issues/comments/{existingId}")); + } + + [Fact] + public async Task UpsertStickyComment_ExistingCommentOnPage2_UpdatesExisting() + { + const long existingId = 55; + var page2Body = "### 📋 Changelog\nPage 2 comment\n"; + + // Build 100 non-matching comments for page 1 + static string NonMatchingComment(int i) => $"{{\"id\":{i},\"body\":\"some other comment\",\"user\":{{\"login\":\"some-user\"}}}}"; + + var page1 = "[" + string.Join(",", Enumerable.Range(1000, 100).Select(NonMatchingComment)) + "]"; + + var page2 = CommentListJson(existingId, page2Body); + var requests = new List<(string method, string path, string? query)>(); + var handler = new StubHandler(req => + { + var query = req.RequestUri!.Query; + requests.Add((req.Method.Method, req.RequestUri.AbsolutePath, query)); + if (req.Method.Method == "GET") + return Json(query.Contains("page=2") ? page2 : page1); + return Ok(); + }); + + var result = await Service(handler).UpsertStickyCommentAsync(Owner, Repo, PrNumber, "### 📋 Changelog\nNew"); + + result.Should().BeTrue(); + requests.Should().ContainSingle(r => r.method == "PATCH" && r.path.Contains($"/issues/comments/{existingId}")); + } + + [Fact] + public async Task UpsertStickyComment_ApiReturns403_ReturnsFalseDoesNotThrow() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.Forbidden)); + + var result = await Service(handler).UpsertStickyCommentAsync(Owner, Repo, PrNumber, "body"); + + result.Should().BeFalse(); + } + + [Fact] + public async Task UpsertStickyComment_RenderedBody_StartsWithTitle() + { + string? postedBody = null; + var handler = new StubHandler(req => + { + if (req.Method.Method == "GET") + return Json("[]"); + postedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + return Created(); + }); + + await Service(handler).UpsertStickyCommentAsync(Owner, Repo, PrNumber, "### 📋 Changelog\nContent"); + + postedBody.Should().NotBeNull(); + // The posted body is JSON; System.Text.Json encodes the emoji as Unicode escapes, + // so assert on the surrounding ASCII text instead of the emoji character itself. + postedBody!.Should().Contain("Changelog").And.Contain("Content"); + } + + private sealed class StubHandler(Func responder) : HttpMessageHandler + { + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) => responder(request); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(Send(request, cancellationToken)); + } +} diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/GithubDecisionMetadataTests.cs similarity index 64% rename from tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs rename to tests/Elastic.Changelog.Tests/Evaluation/GithubDecisionMetadataTests.cs index cbeffdec12..a3d01a9aad 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/GithubDecisionMetadataTests.cs @@ -10,12 +10,12 @@ namespace Elastic.Changelog.Tests.Evaluation; -public class ChangelogArtifactMetadataTests +public class GithubDecisionMetadataTests { [Fact] public void SerializationRoundTrip_WithAllFields_PreservesValues() { - var metadata = new ChangelogArtifactMetadata + var metadata = new GithubDecisionMetadata { PrNumber = 42, HeadRef = "feature/test", @@ -30,6 +30,8 @@ public void SerializationRoundTrip_WithAllFields_PreservesValues() SkipLabels = "changelog:skip,skip-ci", ConfigFile = "changelog.yml", ChangelogDir = "changelogs", + CommitOutcome = CommitOutcome.Committed, + CommittedFile = "changelogs/1234.yaml", CreateRules = new CreateRules { Labels = ["changelog:skip", "no-changelog"], @@ -42,8 +44,8 @@ public void SerializationRoundTrip_WithAllFields_PreservesValues() } }; - var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); - var deserialized = JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + var json = JsonSerializer.Serialize(metadata, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata); + var deserialized = JsonSerializer.Deserialize(json, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata); deserialized.Should().NotBeNull(); deserialized.PrNumber.Should().Be(42); @@ -59,6 +61,8 @@ public void SerializationRoundTrip_WithAllFields_PreservesValues() deserialized.SkipLabels.Should().Be("changelog:skip,skip-ci"); deserialized.ConfigFile.Should().Be("changelog.yml"); deserialized.ChangelogDir.Should().Be("changelogs"); + deserialized.CommitOutcome.Should().Be(CommitOutcome.Committed); + deserialized.CommittedFile.Should().Be("changelogs/1234.yaml"); deserialized.CreateRules.Should().NotBeNull(); deserialized.CreateRules.Labels.Should().BeEquivalentTo(["changelog:skip", "no-changelog"]); deserialized.CreateRules.Mode.Should().Be(FieldMode.Exclude); @@ -69,7 +73,7 @@ public void SerializationRoundTrip_WithAllFields_PreservesValues() [Fact] public void SerializationRoundTrip_WithNullOptionalFields_PreservesNulls() { - var metadata = new ChangelogArtifactMetadata + var metadata = new GithubDecisionMetadata { PrNumber = 1, HeadRef = "main", @@ -80,8 +84,8 @@ public void SerializationRoundTrip_WithNullOptionalFields_PreservesNulls() MaintainerCanModify = false }; - var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); - var deserialized = JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + var json = JsonSerializer.Serialize(metadata, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata); + var deserialized = JsonSerializer.Deserialize(json, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata); deserialized.Should().NotBeNull(); deserialized.PrNumber.Should().Be(1); @@ -93,13 +97,15 @@ public void SerializationRoundTrip_WithNullOptionalFields_PreservesNulls() deserialized.SkipLabels.Should().BeNull(); deserialized.ConfigFile.Should().BeNull(); deserialized.ChangelogDir.Should().BeNull(); + deserialized.CommitOutcome.Should().BeNull(); + deserialized.CommittedFile.Should().BeNull(); deserialized.CreateRules.Should().BeNull(); } [Fact] public void Serialization_UsesSnakeCasePropertyNames() { - var metadata = new ChangelogArtifactMetadata + var metadata = new GithubDecisionMetadata { PrNumber = 99, HeadRef = "fix/bug", @@ -109,10 +115,12 @@ public void Serialization_UsesSnakeCasePropertyNames() CanCommit = false, MaintainerCanModify = true, HeadRepo = "user/repo", - ChangelogDir = "changelogs" + ChangelogDir = "changelogs", + CommitOutcome = CommitOutcome.Failed, + CommittedFile = "changelogs/99.yaml" }; - var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + var json = JsonSerializer.Serialize(metadata, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata); json.Should().Contain("\"pr_number\""); json.Should().Contain("\"head_ref\""); @@ -122,6 +130,8 @@ public void Serialization_UsesSnakeCasePropertyNames() json.Should().Contain("\"maintainer_can_modify\""); json.Should().Contain("\"head_repo\""); json.Should().Contain("\"changelog_dir\""); + json.Should().Contain("\"commit_outcome\""); + json.Should().Contain("\"committed_file\""); json.Should().NotContain("\"PrNumber\""); json.Should().NotContain("\"IsFork\""); json.Should().NotContain("\"CanCommit\""); @@ -130,7 +140,7 @@ public void Serialization_UsesSnakeCasePropertyNames() [Fact] public void Serialization_EnumsUseStringValues() { - var metadata = new ChangelogArtifactMetadata + var metadata = new GithubDecisionMetadata { PrNumber = 1, HeadRef = "main", @@ -139,12 +149,40 @@ public void Serialization_EnumsUseStringValues() IsFork = false, CanCommit = true, MaintainerCanModify = false, + CommitOutcome = CommitOutcome.Committed, CreateRules = new CreateRules { Labels = ["skip"], Mode = FieldMode.Include, Match = MatchMode.All } }; - var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + var json = JsonSerializer.Serialize(metadata, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata); json.Should().Contain("\"Include\""); json.Should().Contain("\"All\""); + json.Should().Contain("\"Committed\""); + } + + [Fact] + public void Deserialization_OldMetadataWithoutCommitOutcomeFields_Succeeds() + { + // Verify wire-safety: a metadata.json written before the CommitOutcome/CommittedFile + // fields were added still deserializes cleanly (fields default to null). + const string legacyJson = + """ + { + "pr_number": 7, + "head_ref": "fix/typo", + "head_sha": "cafebabe", + "status": "proceed", + "is_fork": false, + "can_commit": true, + "maintainer_can_modify": false + } + """; + + var deserialized = JsonSerializer.Deserialize(legacyJson, GithubDecisionMetadataJsonContext.Default.GithubDecisionMetadata); + + deserialized.Should().NotBeNull(); + deserialized.PrNumber.Should().Be(7); + deserialized.CommitOutcome.Should().BeNull(); + deserialized.CommittedFile.Should().BeNull(); } }