From 5c6eb5203dc39126080105d531a2b126b5a6eed5 Mon Sep 17 00:00:00 2001 From: lcawl Date: Mon, 6 Apr 2026 09:04:04 -0700 Subject: [PATCH 1/6] Add --repo --owner to changelog init --- config/changelog.example.yml | 13 +++- docs/cli/changelog/init.md | 14 ++++ .../GitConfigOriginParser.cs | 56 ++++++++++++++ .../GitHubRemoteParser.cs | 73 +++++++++++++++++++ .../GitRemoteConfigurationReader.cs | 59 +++++++++++++++ .../docs-builder/Commands/ChangelogCommand.cs | 46 +++++++++++- .../GitConfigOriginParserTests.cs | 55 ++++++++++++++ .../GitHubRemoteParserTests.cs | 43 +++++++++++ .../GitRemoteConfigurationReaderTests.cs | 48 ++++++++++++ 9 files changed, 403 insertions(+), 4 deletions(-) create mode 100644 src/Elastic.Documentation.Configuration/GitConfigOriginParser.cs create mode 100644 src/Elastic.Documentation.Configuration/GitHubRemoteParser.cs create mode 100644 src/Elastic.Documentation.Configuration/GitRemoteConfigurationReader.cs create mode 100644 tests/Elastic.Documentation.Configuration.Tests/GitConfigOriginParserTests.cs create mode 100644 tests/Elastic.Documentation.Configuration.Tests/GitHubRemoteParserTests.cs create mode 100644 tests/Elastic.Documentation.Configuration.Tests/GitRemoteConfigurationReaderTests.cs diff --git a/config/changelog.example.yml b/config/changelog.example.yml index 5c9266b506..018d0f481f 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -212,9 +212,16 @@ bundle: output_directory: docs/releases # Whether to resolve (copy contents) by default resolve: true - # When true, PR/issue links to repos marked private in assembler.yml are rewritten in bundle output - # (requires resolve: true). Option-based: --sanitize-private-links / --no-sanitize-private-links. - sanitize_private_links: false + # changelog-init-bundle-seed + # PR/issue link allowlist: when set (including []), only links to these owner/repo pairs are kept + # in bundle output; others are rewritten to '# PRIVATE:' sentinels (requires resolve: true). + # There is no implicit allow: you must list every repo whose links should appear, including your + # own (bundle.repo as owner/repo). When omitted entirely, no link filtering is applied. + # Run `changelog init` in a GitHub clone to pre-fill owner, repo, and link_allow_repos, or set + # bundle.owner, bundle.repo, and link_allow_repos manually. Example — allow only this repo: + # link_allow_repos: + # - elastic/kibana + # To allow cross-repo links, add more owner/repo entries (for example elastic/elasticsearch). # Optional: default GitHub repo name applied to all profiles that do not specify their own. # Used by the {changelog} directive to generate correct PR/issue links when the product ID # differs from the GitHub repository name. Can be overridden per profile. diff --git a/docs/cli/changelog/init.md b/docs/cli/changelog/init.md index 1d98dd05b7..f4db0efd18 100644 --- a/docs/cli/changelog/init.md +++ b/docs/cli/changelog/init.md @@ -13,6 +13,8 @@ If no docs folder exists, the command creates `{path}/docs` and places `changelo The command creates a `changelog.yml` configuration file (from the built-in template) and `changelog` and `releases` subdirectories in the `docs` folder. When `--changelog-dir` or `--bundles-dir` is specified, the corresponding `bundle.directory` and `bundle.output_directory` values in `changelog.yml` are set or updated (whether creating a new file or the file already exists). +When the template is written for the first time, the command can **seed** `bundle.owner`, `bundle.repo`, and `bundle.link_allow_repos` so PR and issue links resolve under the explicit link allowlist in [changelog.example.yml](https://github.com/elastic/docs-builder/blob/main/config/changelog.example.yml) (there is no implicit allow for your own repository). Seeding runs when `git` remote `origin` points at **github.com** and/or when you pass `--owner` and/or `--repo`. CLI values override values inferred from `git`. If you pass `--repo` without `--owner` and `git` does not supply an owner, the owner defaults to `elastic`. If neither `git` nor CLI provides enough information, the placeholder line is removed from the template and you can set bundle fields manually. + ## Usage ```sh @@ -33,6 +35,12 @@ docs-builder changelog init [options...] [-h|--help] : Optional: Path to the bundles output directory. : Defaults to `{docsFolder}/releases`. +`--owner ` +: Optional: GitHub organization or user for `bundle.owner` and for seeding `bundle.link_allow_repos` when creating `changelog.yml`. Overrides the owner parsed from `git` remote `origin`. + +`--repo ` +: Optional: GitHub repository name for `bundle.repo` and for seeding `bundle.link_allow_repos` when creating `changelog.yml`. Overrides the repository name parsed from `git` remote `origin`. + ## Examples Initialize changelog (creates or uses docs folder, places `changelog.yml` there, plus `changelog` and `releases` subdirectories): @@ -55,3 +63,9 @@ docs-builder changelog init \ --changelog-dir ./my-changelogs \ --bundles-dir ./my-releases ``` + +Initialize without relying on `git` (for example in a clean checkout or CI), setting the GitHub owner and repository used to seed bundle defaults and `link_allow_repos`: + +```sh +docs-builder changelog init --owner elastic --repo kibana +``` diff --git a/src/Elastic.Documentation.Configuration/GitConfigOriginParser.cs b/src/Elastic.Documentation.Configuration/GitConfigOriginParser.cs new file mode 100644 index 0000000000..1c15aa04f4 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/GitConfigOriginParser.cs @@ -0,0 +1,56 @@ +// 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.Diagnostics.CodeAnalysis; + +namespace Elastic.Documentation.Configuration; + +/// +/// Reads remote "origin" URL entries from Git config file text. +/// +public static class GitConfigOriginParser +{ + /// + /// Returns the first url value under [remote "origin"]. + /// + public static bool TryGetRemoteOriginUrl(string configContent, [NotNullWhen(true)] out string? url) + { + url = null; + if (string.IsNullOrEmpty(configContent)) + return false; + + var inOrigin = false; + foreach (var rawLine in configContent.Split(['\r', '\n'], StringSplitOptions.None)) + { + var line = rawLine.Trim(); + if (line.StartsWith('[')) + { + inOrigin = line.Equals("[remote \"origin\"]", StringComparison.Ordinal); + continue; + } + + if (!inOrigin) + continue; + + if (!line.StartsWith("url", StringComparison.OrdinalIgnoreCase)) + continue; + + var eq = line.IndexOf('='); + if (eq < 0 || eq >= line.Length - 1) + continue; + + var value = line[(eq + 1)..].Trim(); + if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') + value = value[1..^1]; + + if (string.IsNullOrEmpty(value)) + continue; + + url = value; + return true; + } + + return false; + } +} diff --git a/src/Elastic.Documentation.Configuration/GitHubRemoteParser.cs b/src/Elastic.Documentation.Configuration/GitHubRemoteParser.cs new file mode 100644 index 0000000000..7ea9dece58 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/GitHubRemoteParser.cs @@ -0,0 +1,73 @@ +// 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.Diagnostics.CodeAnalysis; + +namespace Elastic.Documentation.Configuration; + +/// +/// Parses GitHub.com remote URLs into owner and repository name (public API for changelog tooling). +/// +public static class GitHubRemoteParser +{ + /// + /// Parses an HTTPS or SSH URL for github.com into and . + /// Other hosts are rejected. + /// + public static bool TryParseGitHubComOwnerRepo(string? url, [NotNullWhen(true)] out string? owner, [NotNullWhen(true)] out string? repo) + { + owner = null; + repo = null; + if (string.IsNullOrWhiteSpace(url)) + return false; + + var trimmed = url.Trim(); + + if (trimmed.StartsWith("git@github.com:", StringComparison.OrdinalIgnoreCase)) + { + var rest = trimmed["git@github.com:".Length..]; + return TrySplitOwnerRepoPath(rest, out owner, out repo); + } + + if (trimmed.StartsWith("ssh://git@github.com/", StringComparison.OrdinalIgnoreCase)) + { + var rest = trimmed["ssh://git@github.com/".Length..]; + return TrySplitOwnerRepoPath(rest, out owner, out repo); + } + + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + return false; + + if (!uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase)) + return false; + + var path = uri.AbsolutePath.Trim('/'); + return TrySplitOwnerRepoPath(path, out owner, out repo); + } + + private static bool TrySplitOwnerRepoPath(string path, [NotNullWhen(true)] out string? owner, [NotNullWhen(true)] out string? repo) + { + owner = null; + repo = null; + if (string.IsNullOrWhiteSpace(path)) + return false; + + path = path.TrimEnd('/', ' '); + if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + path = path[..^4]; + + var slash = path.IndexOf('/'); + if (slash <= 0 || slash >= path.Length - 1) + return false; + + var o = path[..slash]; + var r = path[(slash + 1)..]; + if (string.IsNullOrEmpty(o) || string.IsNullOrEmpty(r) || r.Contains('/')) + return false; + + owner = o; + repo = r; + return true; + } +} diff --git a/src/Elastic.Documentation.Configuration/GitRemoteConfigurationReader.cs b/src/Elastic.Documentation.Configuration/GitRemoteConfigurationReader.cs new file mode 100644 index 0000000000..6809647097 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/GitRemoteConfigurationReader.cs @@ -0,0 +1,59 @@ +// 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.Diagnostics.CodeAnalysis; +using System.IO.Abstractions; + +namespace Elastic.Documentation.Configuration; + +/// +/// Reads remote.origin.url from a local Git checkout using the file system (no subprocess). +/// +public static class GitRemoteConfigurationReader +{ + /// + /// Reads .git/config, or the config file referenced by a .git worktree pointer file. + /// + public static bool TryReadOriginUrl(IFileSystem fileSystem, string repositoryRoot, [NotNullWhen(true)] out string? url) + { + url = null; + var gitPath = fileSystem.Path.Combine(repositoryRoot, ".git"); + if (fileSystem.Directory.Exists(gitPath)) + { + var configPath = fileSystem.Path.Combine(gitPath, "config"); + return TryReadOriginUrlFromConfigPath(fileSystem, configPath, out url); + } + + if (!fileSystem.File.Exists(gitPath)) + return false; + + var gitFileText = fileSystem.File.ReadAllText(gitPath); + var firstLineBreak = gitFileText.IndexOfAny(['\r', '\n']); + var firstLine = firstLineBreak >= 0 ? gitFileText[..firstLineBreak] : gitFileText; + firstLine = firstLine.Trim(); + if (!firstLine.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase)) + return false; + + var gitDir = firstLine["gitdir:".Length..].Trim(); + if (string.IsNullOrEmpty(gitDir)) + return false; + + var resolvedGitDir = fileSystem.Path.IsPathFullyQualified(gitDir) + ? gitDir + : fileSystem.Path.GetFullPath(fileSystem.Path.Combine(repositoryRoot, gitDir)); + + var worktreeConfigPath = fileSystem.Path.Combine(resolvedGitDir, "config"); + return TryReadOriginUrlFromConfigPath(fileSystem, worktreeConfigPath, out url); + } + + private static bool TryReadOriginUrlFromConfigPath(IFileSystem fileSystem, string configPath, [NotNullWhen(true)] out string? url) + { + url = null; + if (!fileSystem.File.Exists(configPath)) + return false; + + var content = fileSystem.File.ReadAllText(configPath); + return GitConfigOriginParser.TryGetRemoteOriginUrl(content, out url); + } +} diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 28b52241af..8d95e5442e 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -54,15 +54,20 @@ public Task Default() /// /// Initialize changelog configuration and folder structure. Creates changelog.yml from the example template in the docs folder (discovered via docset.yml when present, or at {path}/docs which is created if needed), and creates changelog and releases subdirectories if they do not exist. /// When changelog.yml already exists and --changelog-dir or --bundles-dir is specified, updates the bundle.directory and/or bundle.output_directory fields accordingly. + /// When creating a new changelog.yml, seeds bundle.owner, bundle.repo, and bundle.link_allow_repos from git remote origin (github.com only) and/or --owner / --repo. /// /// Optional: Repository root path. Defaults to the output of pwd (current directory). Docs folder is {path}/docs, created if it does not exist. /// Optional: Path to changelog directory. Defaults to {docsFolder}/changelog. /// Optional: Path to bundles output directory. Defaults to {docsFolder}/releases. + /// Optional: GitHub owner for bundle defaults and link_allow_repos seeding. Overrides the owner inferred from git remote origin. + /// Optional: GitHub repository name for bundle defaults and link_allow_repos seeding. Overrides the repo inferred from git remote origin. [Command("init")] public Task Init( string? path = null, string? changelogDir = null, - string? bundlesDir = null + string? bundlesDir = null, + string? owner = null, + string? repo = null ) { var rootPath = NormalizePath(path ?? "."); @@ -138,6 +143,8 @@ public Task Init( content = content.Replace("output_directory: docs/releases", $"output_directory: {outputValue}"); } + content = ApplyChangelogInitBundleRepoSeed(content, owner, repo, repoRoot); + try { _fileSystem.File.WriteAllBytes(configPath, Encoding.UTF8.GetBytes(content)); @@ -1306,6 +1313,43 @@ private static string GetPathForConfig(string repoPath, string targetPath) return pathForConfig; } + /// + /// Replaces or removes the changelog-init-bundle-seed placeholder line in the changelog template. + /// CLI owner/repo take precedence over values from git remote origin. + /// + private string ApplyChangelogInitBundleRepoSeed(string content, string? ownerCli, string? repoCli, string repoRoot) + { + const string placeholder = " # changelog-init-bundle-seed"; + + var gitMatched = false; + string? gitOwner = null; + string? gitRepo = null; + if (GitRemoteConfigurationReader.TryReadOriginUrl(_fileSystem, repoRoot, out var originUrl) + && GitHubRemoteParser.TryParseGitHubComOwnerRepo(originUrl, out var go, out var gr)) + { + gitOwner = go; + gitRepo = gr; + gitMatched = true; + } + + var resolvedRepo = repoCli ?? gitRepo; + var resolvedOwner = ownerCli ?? gitOwner; + if (resolvedRepo != null && resolvedOwner == null) + resolvedOwner = "elastic"; + + var shouldSeed = resolvedOwner != null && resolvedRepo != null + && (ownerCli != null || repoCli != null || gitMatched); + + var block = shouldSeed + ? $" owner: {resolvedOwner}\n repo: {resolvedRepo}\n link_allow_repos:\n - {resolvedOwner}/{resolvedRepo}\n" + : ""; + + if (content.Contains(placeholder + "\r\n", StringComparison.Ordinal)) + return content.Replace(placeholder + "\r\n", block, StringComparison.Ordinal); + + return content.Replace(placeholder + "\n", block, StringComparison.Ordinal); + } + /// /// Normalizes a file path by expanding tilde (~) to the user's home directory /// and converting relative paths to absolute paths. diff --git a/tests/Elastic.Documentation.Configuration.Tests/GitConfigOriginParserTests.cs b/tests/Elastic.Documentation.Configuration.Tests/GitConfigOriginParserTests.cs new file mode 100644 index 0000000000..ee61af2a17 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/GitConfigOriginParserTests.cs @@ -0,0 +1,55 @@ +// 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; + +namespace Elastic.Documentation.Configuration.Tests; + +public class GitConfigOriginParserTests +{ + [Fact] + public void TryGetRemoteOriginUrl_StandardConfig_ReturnsUrl() + { + var yaml = """ + [core] + repositoryformatversion = 0 + [remote "origin"] + url = https://github.com/elastic/kibana.git + fetch = +refs/heads/*:refs/remotes/origin/* + """; + + var ok = GitConfigOriginParser.TryGetRemoteOriginUrl(yaml, out var url); + + ok.Should().BeTrue(); + url.Should().Be("https://github.com/elastic/kibana.git"); + } + + [Fact] + public void TryGetRemoteOriginUrl_QuotedUrl_ReturnsUnquoted() + { + var yaml = """ + [remote "origin"] + url = "https://github.com/elastic/kibana.git" + """; + + var ok = GitConfigOriginParser.TryGetRemoteOriginUrl(yaml, out var url); + + ok.Should().BeTrue(); + url.Should().Be("https://github.com/elastic/kibana.git"); + } + + [Fact] + public void TryGetRemoteOriginUrl_NoOrigin_ReturnsFalse() + { + var yaml = """ + [remote "upstream"] + url = https://github.com/elastic/kibana.git + """; + + var ok = GitConfigOriginParser.TryGetRemoteOriginUrl(yaml, out var url); + + ok.Should().BeFalse(); + url.Should().BeNull(); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/GitHubRemoteParserTests.cs b/tests/Elastic.Documentation.Configuration.Tests/GitHubRemoteParserTests.cs new file mode 100644 index 0000000000..8c44be80a0 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/GitHubRemoteParserTests.cs @@ -0,0 +1,43 @@ +// 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; + +namespace Elastic.Documentation.Configuration.Tests; + +public class GitHubRemoteParserTests +{ + [Theory] + [InlineData("https://github.com/elastic/kibana.git", "elastic", "kibana")] + [InlineData("https://github.com/elastic/kibana", "elastic", "kibana")] + [InlineData("http://github.com/elastic/kibana", "elastic", "kibana")] + [InlineData("https://github.com/elastic/some.repo.git", "elastic", "some.repo")] + [InlineData("git@github.com:elastic/kibana.git", "elastic", "kibana")] + [InlineData("ssh://git@github.com/elastic/kibana.git", "elastic", "kibana")] + public void TryParseGitHubComOwnerRepo_ValidGitHubUrls_ReturnsOwnerRepo(string url, string expectedOwner, string expectedRepo) + { + var ok = GitHubRemoteParser.TryParseGitHubComOwnerRepo(url, out var owner, out var repo); + + ok.Should().BeTrue(); + owner.Should().Be(expectedOwner); + repo.Should().Be(expectedRepo); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("https://gitlab.com/elastic/kibana")] + [InlineData("https://github.com/elastic")] + [InlineData("https://github.com/")] + [InlineData("not-a-url")] + public void TryParseGitHubComOwnerRepo_Invalid_ReturnsFalse(string? url) + { + var ok = GitHubRemoteParser.TryParseGitHubComOwnerRepo(url, out var owner, out var repo); + + ok.Should().BeFalse(); + owner.Should().BeNull(); + repo.Should().BeNull(); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/GitRemoteConfigurationReaderTests.cs b/tests/Elastic.Documentation.Configuration.Tests/GitRemoteConfigurationReaderTests.cs new file mode 100644 index 0000000000..395b449288 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/GitRemoteConfigurationReaderTests.cs @@ -0,0 +1,48 @@ +// 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.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; + +namespace Elastic.Documentation.Configuration.Tests; + +public class GitRemoteConfigurationReaderTests +{ + [Fact] + public void TryReadOriginUrl_DotGitDirectory_ReadsConfig() + { + var fs = new MockFileSystem(); + fs.AddFile( + "/repo/.git/config", + new(""" + [remote "origin"] + url = git@github.com:elastic/kibana.git + """)); + + var ok = GitRemoteConfigurationReader.TryReadOriginUrl(fs, "/repo", out var url); + + ok.Should().BeTrue(); + url.Should().Be("git@github.com:elastic/kibana.git"); + } + + [Fact] + public void TryReadOriginUrl_GitWorktreeFile_ResolvesGitDir() + { + var fs = new MockFileSystem(); + fs.AddFile( + "/wt/.git", + new("gitdir: /main/.git/worktrees/wt\n")); + fs.AddFile( + "/main/.git/worktrees/wt/config", + new(""" + [remote "origin"] + url = https://github.com/elastic/kibana.git + """)); + + var ok = GitRemoteConfigurationReader.TryReadOriginUrl(fs, "/wt", out var url); + + ok.Should().BeTrue(); + url.Should().Be("https://github.com/elastic/kibana.git"); + } +} From 62d2ef5cdefa9b92ba815aea1c5dfda299c8cc8b Mon Sep 17 00:00:00 2001 From: lcawl Date: Mon, 6 Apr 2026 12:39:06 -0700 Subject: [PATCH 2/6] Address CodeRabbit feedback --- .../GitRemoteConfigurationReader.cs | 76 +++++++++++++------ .../docs-builder/Commands/ChangelogCommand.cs | 19 +++-- 2 files changed, 64 insertions(+), 31 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/GitRemoteConfigurationReader.cs b/src/Elastic.Documentation.Configuration/GitRemoteConfigurationReader.cs index 6809647097..80e1cb5e60 100644 --- a/src/Elastic.Documentation.Configuration/GitRemoteConfigurationReader.cs +++ b/src/Elastic.Documentation.Configuration/GitRemoteConfigurationReader.cs @@ -18,42 +18,68 @@ public static class GitRemoteConfigurationReader public static bool TryReadOriginUrl(IFileSystem fileSystem, string repositoryRoot, [NotNullWhen(true)] out string? url) { url = null; - var gitPath = fileSystem.Path.Combine(repositoryRoot, ".git"); - if (fileSystem.Directory.Exists(gitPath)) + try { - var configPath = fileSystem.Path.Combine(gitPath, "config"); - return TryReadOriginUrlFromConfigPath(fileSystem, configPath, out url); - } + var gitPath = fileSystem.Path.Combine(repositoryRoot, ".git"); + if (fileSystem.Directory.Exists(gitPath)) + { + var configPath = fileSystem.Path.Combine(gitPath, "config"); + return TryReadOriginUrlFromConfigPath(fileSystem, configPath, out url); + } - if (!fileSystem.File.Exists(gitPath)) - return false; + if (!fileSystem.File.Exists(gitPath)) + return false; - var gitFileText = fileSystem.File.ReadAllText(gitPath); - var firstLineBreak = gitFileText.IndexOfAny(['\r', '\n']); - var firstLine = firstLineBreak >= 0 ? gitFileText[..firstLineBreak] : gitFileText; - firstLine = firstLine.Trim(); - if (!firstLine.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase)) - return false; + var gitFileText = fileSystem.File.ReadAllText(gitPath); + var firstLineBreak = gitFileText.IndexOfAny(['\r', '\n']); + var firstLine = firstLineBreak >= 0 ? gitFileText[..firstLineBreak] : gitFileText; + firstLine = firstLine.Trim(); + if (!firstLine.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase)) + return false; - var gitDir = firstLine["gitdir:".Length..].Trim(); - if (string.IsNullOrEmpty(gitDir)) - return false; + var gitDir = firstLine["gitdir:".Length..].Trim(); + if (string.IsNullOrEmpty(gitDir)) + return false; - var resolvedGitDir = fileSystem.Path.IsPathFullyQualified(gitDir) - ? gitDir - : fileSystem.Path.GetFullPath(fileSystem.Path.Combine(repositoryRoot, gitDir)); + var resolvedGitDir = fileSystem.Path.IsPathFullyQualified(gitDir) + ? gitDir + : fileSystem.Path.GetFullPath(fileSystem.Path.Combine(repositoryRoot, gitDir)); - var worktreeConfigPath = fileSystem.Path.Combine(resolvedGitDir, "config"); - return TryReadOriginUrlFromConfigPath(fileSystem, worktreeConfigPath, out url); + var worktreeConfigPath = fileSystem.Path.Combine(resolvedGitDir, "config"); + return TryReadOriginUrlFromConfigPath(fileSystem, worktreeConfigPath, out url); + } + catch (IOException) + { + url = null; + return false; + } + catch (UnauthorizedAccessException) + { + url = null; + return false; + } } private static bool TryReadOriginUrlFromConfigPath(IFileSystem fileSystem, string configPath, [NotNullWhen(true)] out string? url) { url = null; - if (!fileSystem.File.Exists(configPath)) - return false; + try + { + if (!fileSystem.File.Exists(configPath)) + return false; - var content = fileSystem.File.ReadAllText(configPath); - return GitConfigOriginParser.TryGetRemoteOriginUrl(content, out url); + var content = fileSystem.File.ReadAllText(configPath); + return GitConfigOriginParser.TryGetRemoteOriginUrl(content, out url); + } + catch (IOException) + { + url = null; + return false; + } + catch (UnauthorizedAccessException) + { + url = null; + return false; + } } } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 8d95e5442e..74014cbaf7 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -1332,16 +1332,23 @@ private string ApplyChangelogInitBundleRepoSeed(string content, string? ownerCli gitMatched = true; } - var resolvedRepo = repoCli ?? gitRepo; - var resolvedOwner = ownerCli ?? gitOwner; - if (resolvedRepo != null && resolvedOwner == null) + // Simple normalization - treat whitespace as absent + var resolvedRepo = string.IsNullOrWhiteSpace(repoCli) ? gitRepo : repoCli.Trim(); + var resolvedOwner = string.IsNullOrWhiteSpace(ownerCli) ? gitOwner : ownerCli.Trim(); + if (!string.IsNullOrWhiteSpace(resolvedRepo) && string.IsNullOrWhiteSpace(resolvedOwner)) resolvedOwner = "elastic"; - var shouldSeed = resolvedOwner != null && resolvedRepo != null - && (ownerCli != null || repoCli != null || gitMatched); + var shouldSeed = !string.IsNullOrWhiteSpace(resolvedOwner) && !string.IsNullOrWhiteSpace(resolvedRepo) + && (!string.IsNullOrWhiteSpace(ownerCli) || !string.IsNullOrWhiteSpace(repoCli) || gitMatched); + + // Reuse existing YAML quoting pattern from GetPathForConfig + static string QuoteForYaml(string value) => + value.Contains(':') || value.Contains(' ') || value.Contains('#') || value.Contains('"') + ? $"\"{value.Replace("\"", "\\\"")}\"" + : value; var block = shouldSeed - ? $" owner: {resolvedOwner}\n repo: {resolvedRepo}\n link_allow_repos:\n - {resolvedOwner}/{resolvedRepo}\n" + ? $" owner: {QuoteForYaml(resolvedOwner!)}\n repo: {QuoteForYaml(resolvedRepo!)}\n link_allow_repos:\n - {QuoteForYaml($"{resolvedOwner}/{resolvedRepo}")}\n" : ""; if (content.Contains(placeholder + "\r\n", StringComparison.Ordinal)) From c91bab073f3e20c634387ad438cd44f1b8f7fcf6 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 6 Apr 2026 17:45:35 -0300 Subject: [PATCH 3/6] Fix worktree path inference --- .../GitRemoteConfigurationReader.cs | 11 ++++++++++- .../GitRemoteConfigurationReaderTests.cs | 19 ++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/GitRemoteConfigurationReader.cs b/src/Elastic.Documentation.Configuration/GitRemoteConfigurationReader.cs index 80e1cb5e60..a8939ed159 100644 --- a/src/Elastic.Documentation.Configuration/GitRemoteConfigurationReader.cs +++ b/src/Elastic.Documentation.Configuration/GitRemoteConfigurationReader.cs @@ -45,7 +45,16 @@ public static bool TryReadOriginUrl(IFileSystem fileSystem, string repositoryRoo ? gitDir : fileSystem.Path.GetFullPath(fileSystem.Path.Combine(repositoryRoot, gitDir)); - var worktreeConfigPath = fileSystem.Path.Combine(resolvedGitDir, "config"); + var commonDirFile = fileSystem.Path.Combine(resolvedGitDir, "commondir"); + if (!fileSystem.File.Exists(commonDirFile)) + return false; + + var commonDirRelative = fileSystem.File.ReadAllText(commonDirFile).Trim(); + var commonDir = fileSystem.Path.IsPathFullyQualified(commonDirRelative) + ? commonDirRelative + : fileSystem.Path.GetFullPath(fileSystem.Path.Combine(resolvedGitDir, commonDirRelative)); + + var worktreeConfigPath = fileSystem.Path.Combine(commonDir, "config"); return TryReadOriginUrlFromConfigPath(fileSystem, worktreeConfigPath, out url); } catch (IOException) diff --git a/tests/Elastic.Documentation.Configuration.Tests/GitRemoteConfigurationReaderTests.cs b/tests/Elastic.Documentation.Configuration.Tests/GitRemoteConfigurationReaderTests.cs index 395b449288..a29db1e223 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/GitRemoteConfigurationReaderTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/GitRemoteConfigurationReaderTests.cs @@ -34,7 +34,10 @@ public void TryReadOriginUrl_GitWorktreeFile_ResolvesGitDir() "/wt/.git", new("gitdir: /main/.git/worktrees/wt\n")); fs.AddFile( - "/main/.git/worktrees/wt/config", + "/main/.git/worktrees/wt/commondir", + new("../..\n")); + fs.AddFile( + "/main/.git/config", new(""" [remote "origin"] url = https://github.com/elastic/kibana.git @@ -45,4 +48,18 @@ public void TryReadOriginUrl_GitWorktreeFile_ResolvesGitDir() ok.Should().BeTrue(); url.Should().Be("https://github.com/elastic/kibana.git"); } + + [Fact] + public void TryReadOriginUrl_GitWorktreeFile_MissingCommondir_ReturnsFalse() + { + var fs = new MockFileSystem(); + fs.AddFile( + "/wt/.git", + new("gitdir: /main/.git/worktrees/wt\n")); + + var ok = GitRemoteConfigurationReader.TryReadOriginUrl(fs, "/wt", out var url); + + ok.Should().BeFalse(); + url.Should().BeNull(); + } } From e70a56e4e864f804ba1111c3dfaa2db646b7619b Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 6 Apr 2026 17:47:50 -0300 Subject: [PATCH 4/6] Maintain EOL consistency --- src/tooling/docs-builder/Commands/ChangelogCommand.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 74014cbaf7..b0f3b34070 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -1347,14 +1347,13 @@ static string QuoteForYaml(string value) => ? $"\"{value.Replace("\"", "\\\"")}\"" : value; + var eol = content.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var block = shouldSeed - ? $" owner: {QuoteForYaml(resolvedOwner!)}\n repo: {QuoteForYaml(resolvedRepo!)}\n link_allow_repos:\n - {QuoteForYaml($"{resolvedOwner}/{resolvedRepo}")}\n" + ? $" owner: {QuoteForYaml(resolvedOwner!)}{eol} repo: {QuoteForYaml(resolvedRepo!)}{eol} link_allow_repos:{eol} - {QuoteForYaml($"{resolvedOwner}/{resolvedRepo}")}{eol}" : ""; - if (content.Contains(placeholder + "\r\n", StringComparison.Ordinal)) - return content.Replace(placeholder + "\r\n", block, StringComparison.Ordinal); - - return content.Replace(placeholder + "\n", block, StringComparison.Ordinal); + return content.Replace(placeholder + eol, block, StringComparison.Ordinal); } /// From dbc074df1e655f9d307bd8373d06446c98d9177b Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 6 Apr 2026 17:54:10 -0300 Subject: [PATCH 5/6] Extract template seeding and add tests --- .../ChangelogTemplateSeeder.cs | 44 ++++++ .../docs-builder/Commands/ChangelogCommand.cs | 39 +---- .../ChangelogTemplateSeederTests.cs | 138 ++++++++++++++++++ 3 files changed, 185 insertions(+), 36 deletions(-) create mode 100644 src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.cs create mode 100644 tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs diff --git a/src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.cs b/src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.cs new file mode 100644 index 0000000000..8febd7ba42 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.cs @@ -0,0 +1,44 @@ +// 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.Documentation.Configuration; + +/// +/// Applies bundle.owner, bundle.repo, and bundle.link_allow_repos seeding +/// to the changelog template placeholder. Pure string transformation with no I/O. +/// +public static class ChangelogTemplateSeeder +{ + internal const string Placeholder = " # changelog-init-bundle-seed"; + + /// + /// Replaces or removes the # changelog-init-bundle-seed placeholder in template content. + /// CLI values take precedence over git-inferred values. When only repo is known, owner defaults to elastic. + /// + public static string ApplyBundleRepoSeed(string content, string? ownerCli, string? repoCli, string? gitOwner, string? gitRepo) + { + var gitMatched = gitOwner is not null && gitRepo is not null; + + var resolvedRepo = string.IsNullOrWhiteSpace(repoCli) ? gitRepo : repoCli.Trim(); + var resolvedOwner = string.IsNullOrWhiteSpace(ownerCli) ? gitOwner : ownerCli.Trim(); + if (!string.IsNullOrWhiteSpace(resolvedRepo) && string.IsNullOrWhiteSpace(resolvedOwner)) + resolvedOwner = "elastic"; + + var shouldSeed = !string.IsNullOrWhiteSpace(resolvedOwner) && !string.IsNullOrWhiteSpace(resolvedRepo) + && (!string.IsNullOrWhiteSpace(ownerCli) || !string.IsNullOrWhiteSpace(repoCli) || gitMatched); + + var eol = content.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + + var block = shouldSeed + ? $" owner: {QuoteForYaml(resolvedOwner!)}{eol} repo: {QuoteForYaml(resolvedRepo!)}{eol} link_allow_repos:{eol} - {QuoteForYaml($"{resolvedOwner}/{resolvedRepo}")}{eol}" + : ""; + + return content.Replace(Placeholder + eol, block, StringComparison.Ordinal); + } + + internal static string QuoteForYaml(string value) => + value.Contains(':') || value.Contains(' ') || value.Contains('#') || value.Contains('"') + ? $"\"{value.Replace("\"", "\\\"")}\"" + : value; +} diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index b0f3b34070..a8bce17993 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -1313,47 +1313,14 @@ private static string GetPathForConfig(string repoPath, string targetPath) return pathForConfig; } - /// - /// Replaces or removes the changelog-init-bundle-seed placeholder line in the changelog template. - /// CLI owner/repo take precedence over values from git remote origin. - /// private string ApplyChangelogInitBundleRepoSeed(string content, string? ownerCli, string? repoCli, string repoRoot) { - const string placeholder = " # changelog-init-bundle-seed"; - - var gitMatched = false; string? gitOwner = null; string? gitRepo = null; - if (GitRemoteConfigurationReader.TryReadOriginUrl(_fileSystem, repoRoot, out var originUrl) - && GitHubRemoteParser.TryParseGitHubComOwnerRepo(originUrl, out var go, out var gr)) - { - gitOwner = go; - gitRepo = gr; - gitMatched = true; - } - - // Simple normalization - treat whitespace as absent - var resolvedRepo = string.IsNullOrWhiteSpace(repoCli) ? gitRepo : repoCli.Trim(); - var resolvedOwner = string.IsNullOrWhiteSpace(ownerCli) ? gitOwner : ownerCli.Trim(); - if (!string.IsNullOrWhiteSpace(resolvedRepo) && string.IsNullOrWhiteSpace(resolvedOwner)) - resolvedOwner = "elastic"; - - var shouldSeed = !string.IsNullOrWhiteSpace(resolvedOwner) && !string.IsNullOrWhiteSpace(resolvedRepo) - && (!string.IsNullOrWhiteSpace(ownerCli) || !string.IsNullOrWhiteSpace(repoCli) || gitMatched); - - // Reuse existing YAML quoting pattern from GetPathForConfig - static string QuoteForYaml(string value) => - value.Contains(':') || value.Contains(' ') || value.Contains('#') || value.Contains('"') - ? $"\"{value.Replace("\"", "\\\"")}\"" - : value; - - var eol = content.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; - - var block = shouldSeed - ? $" owner: {QuoteForYaml(resolvedOwner!)}{eol} repo: {QuoteForYaml(resolvedRepo!)}{eol} link_allow_repos:{eol} - {QuoteForYaml($"{resolvedOwner}/{resolvedRepo}")}{eol}" - : ""; + if (GitRemoteConfigurationReader.TryReadOriginUrl(_fileSystem, repoRoot, out var originUrl)) + _ = GitHubRemoteParser.TryParseGitHubComOwnerRepo(originUrl, out gitOwner, out gitRepo); - return content.Replace(placeholder + eol, block, StringComparison.Ordinal); + return ChangelogTemplateSeeder.ApplyBundleRepoSeed(content, ownerCli, repoCli, gitOwner, gitRepo); } /// diff --git a/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs new file mode 100644 index 0000000000..b3844a6faf --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs @@ -0,0 +1,138 @@ +// 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; + +namespace Elastic.Documentation.Configuration.Tests; + +public class ChangelogTemplateSeederTests +{ + private const string Template = + "bundle:\n resolve: true\n # changelog-init-bundle-seed\n # some other comment\n"; + + private const string TemplateWindows = + "bundle:\r\n resolve: true\r\n # changelog-init-bundle-seed\r\n # some other comment\r\n"; + + [Fact] + public void ApplyBundleRepoSeed_GitOwnerAndRepo_SeedsTemplate() + { + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + Template, ownerCli: null, repoCli: null, gitOwner: "elastic", gitRepo: "kibana"); + + result.Should().Contain(" owner: elastic\n"); + result.Should().Contain(" repo: kibana\n"); + result.Should().Contain(" - elastic/kibana\n"); + result.Should().NotContain("changelog-init-bundle-seed"); + } + + [Fact] + public void ApplyBundleRepoSeed_CliOwnerAndRepo_SeedsTemplate() + { + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + Template, ownerCli: "myorg", repoCli: "myrepo", gitOwner: null, gitRepo: null); + + result.Should().Contain(" owner: myorg\n"); + result.Should().Contain(" repo: myrepo\n"); + result.Should().Contain(" - myorg/myrepo\n"); + } + + [Fact] + public void ApplyBundleRepoSeed_CliOverridesGit() + { + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + Template, ownerCli: "override-owner", repoCli: "override-repo", gitOwner: "elastic", gitRepo: "kibana"); + + result.Should().Contain(" owner: override-owner\n"); + result.Should().Contain(" repo: override-repo\n"); + result.Should().Contain(" - override-owner/override-repo\n"); + } + + [Fact] + public void ApplyBundleRepoSeed_CliRepoOnly_OwnerDefaultsToElastic() + { + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + Template, ownerCli: null, repoCli: "myrepo", gitOwner: null, gitRepo: null); + + result.Should().Contain(" owner: elastic\n"); + result.Should().Contain(" repo: myrepo\n"); + result.Should().Contain(" - elastic/myrepo\n"); + } + + [Fact] + public void ApplyBundleRepoSeed_CliOwnerOnly_NoRepo_RemovesPlaceholder() + { + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + Template, ownerCli: "myorg", repoCli: null, gitOwner: null, gitRepo: null); + + result.Should().NotContain("changelog-init-bundle-seed"); + result.Should().NotContain(" owner:"); + result.Should().NotContain(" repo:"); + } + + [Fact] + public void ApplyBundleRepoSeed_NeitherCliNorGit_RemovesPlaceholder() + { + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + Template, ownerCli: null, repoCli: null, gitOwner: null, gitRepo: null); + + result.Should().NotContain("changelog-init-bundle-seed"); + result.Should().NotContain(" owner:"); + result.Should().NotContain(" repo:"); + result.Should().Contain(" # some other comment\n"); + } + + [Fact] + public void ApplyBundleRepoSeed_WhitespaceCliValues_TreatedAsAbsent() + { + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + Template, ownerCli: " ", repoCli: " ", gitOwner: "elastic", gitRepo: "kibana"); + + result.Should().Contain(" owner: elastic\n"); + result.Should().Contain(" repo: kibana\n"); + } + + [Fact] + public void ApplyBundleRepoSeed_WindowsLineEndings_PreservesStyle() + { + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + TemplateWindows, ownerCli: null, repoCli: null, gitOwner: "elastic", gitRepo: "kibana"); + + result.Should().Contain(" owner: elastic\r\n"); + result.Should().Contain(" repo: kibana\r\n"); + result.Should().NotContain(" owner: elastic\n repo:"); + } + + [Fact] + public void ApplyBundleRepoSeed_MissingPlaceholder_ReturnsContentUnchanged() + { + var content = "bundle:\n resolve: true\n"; + + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + content, ownerCli: "elastic", repoCli: "kibana", gitOwner: null, gitRepo: null); + + result.Should().Be(content); + } + + [Fact] + public void ApplyBundleRepoSeed_ValuesNeedingYamlQuoting_AreQuoted() + { + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + Template, ownerCli: "my org", repoCli: "my:repo", gitOwner: null, gitRepo: null); + + result.Should().Contain(" owner: \"my org\"\n"); + result.Should().Contain(" repo: \"my:repo\"\n"); + result.Should().Contain(" - \"my org/my:repo\"\n"); + } + + [Fact] + public void ApplyBundleRepoSeed_CliRepoOverridesGitRepo_KeepsGitOwner() + { + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + Template, ownerCli: null, repoCli: "other-repo", gitOwner: "elastic", gitRepo: "kibana"); + + result.Should().Contain(" owner: elastic\n"); + result.Should().Contain(" repo: other-repo\n"); + result.Should().Contain(" - elastic/other-repo\n"); + } +} From 15f954aa6e36c99a0f3654df52c114222791477f Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 6 Apr 2026 18:08:33 -0300 Subject: [PATCH 6/6] Apply CR suggestions --- .../ChangelogTemplateSeeder.cs | 18 +++++++- .../ChangelogTemplateSeederTests.cs | 44 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.cs b/src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.cs index 8febd7ba42..d8a684e3b1 100644 --- a/src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.cs +++ b/src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.cs @@ -34,11 +34,25 @@ public static string ApplyBundleRepoSeed(string content, string? ownerCli, strin ? $" owner: {QuoteForYaml(resolvedOwner!)}{eol} repo: {QuoteForYaml(resolvedRepo!)}{eol} link_allow_repos:{eol} - {QuoteForYaml($"{resolvedOwner}/{resolvedRepo}")}{eol}" : ""; - return content.Replace(Placeholder + eol, block, StringComparison.Ordinal); + var placeholderWithEol = Placeholder + eol; + if (content.Contains(placeholderWithEol, StringComparison.Ordinal)) + return content.Replace(placeholderWithEol, block, StringComparison.Ordinal); + + return content.Replace( + Placeholder, + shouldSeed ? block.TrimEnd('\r', '\n') : string.Empty, + StringComparison.Ordinal + ); } internal static string QuoteForYaml(string value) => value.Contains(':') || value.Contains(' ') || value.Contains('#') || value.Contains('"') - ? $"\"{value.Replace("\"", "\\\"")}\"" + || value.Contains('\\') || value.Contains('\n') || value.Contains('\r') || value.Contains('\t') + ? $"\"{value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n") + .Replace("\t", "\\t")}\"" : value; } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs index b3844a6faf..796cbef932 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs @@ -135,4 +135,48 @@ public void ApplyBundleRepoSeed_CliRepoOverridesGitRepo_KeepsGitOwner() result.Should().Contain(" repo: other-repo\n"); result.Should().Contain(" - elastic/other-repo\n"); } + + [Fact] + public void ApplyBundleRepoSeed_PlaceholderAtEofWithoutNewline_Seeds() + { + var content = "bundle:\n resolve: true\n # changelog-init-bundle-seed"; + + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + content, ownerCli: null, repoCli: null, gitOwner: "elastic", gitRepo: "kibana"); + + result.Should().Contain(" owner: elastic"); + result.Should().Contain(" repo: kibana"); + result.Should().NotContain("changelog-init-bundle-seed"); + } + + [Fact] + public void ApplyBundleRepoSeed_PlaceholderAtEofWithoutNewline_RemovesWhenNoSeed() + { + var content = "bundle:\n resolve: true\n # changelog-init-bundle-seed"; + + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + content, ownerCli: null, repoCli: null, gitOwner: null, gitRepo: null); + + result.Should().Be("bundle:\n resolve: true\n"); + result.Should().NotContain("changelog-init-bundle-seed"); + } + + [Fact] + public void ApplyBundleRepoSeed_BackslashInValue_IsEscapedInYaml() + { + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + Template, ownerCli: @"path\org", repoCli: "repo", gitOwner: null, gitRepo: null); + + result.Should().Contain(@" owner: ""path\\org"""); + result.Should().Contain(" repo: repo\n"); + } + + [Fact] + public void ApplyBundleRepoSeed_ControlCharsInValue_AreEscapedInYaml() + { + var result = ChangelogTemplateSeeder.ApplyBundleRepoSeed( + Template, ownerCli: "org\tname", repoCli: "repo", gitOwner: null, gitRepo: null); + + result.Should().Contain(@" owner: ""org\tname"""); + } }