diff --git a/config/changelog.example.yml b/config/changelog.example.yml index b09d71b2e..cf7e0ca45 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -212,13 +212,16 @@ bundle: output_directory: docs/releases # Whether to resolve (copy contents) by default resolve: true + # 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). - # When omitted, no link filtering is applied. - # Add your repository and any others whose PR/issue links should appear in published docs. + # 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/elasticsearch - # - elastic/kibana + # - 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 1d98dd05b..f4db0efd1 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/ChangelogTemplateSeeder.cs b/src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.cs new file mode 100644 index 000000000..d8a684e3b --- /dev/null +++ b/src/Elastic.Documentation.Configuration/ChangelogTemplateSeeder.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 + +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}" + : ""; + + 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.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/src/Elastic.Documentation.Configuration/GitConfigOriginParser.cs b/src/Elastic.Documentation.Configuration/GitConfigOriginParser.cs new file mode 100644 index 000000000..1c15aa04f --- /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 000000000..7ea9dece5 --- /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 000000000..a8939ed15 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/GitRemoteConfigurationReader.cs @@ -0,0 +1,94 @@ +// 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; + try + { + 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 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) + { + 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; + try + { + if (!fileSystem.File.Exists(configPath)) + return false; + + 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 389b84922..aae2b44fc 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -55,15 +55,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 ?? "."); @@ -139,6 +144,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)); @@ -1323,6 +1330,16 @@ private static string GetPathForConfig(string repoPath, string targetPath) return pathForConfig; } + private string ApplyChangelogInitBundleRepoSeed(string content, string? ownerCli, string? repoCli, string repoRoot) + { + string? gitOwner = null; + string? gitRepo = null; + if (GitRemoteConfigurationReader.TryReadOriginUrl(_fileSystem, repoRoot, out var originUrl)) + _ = GitHubRemoteParser.TryParseGitHubComOwnerRepo(originUrl, out gitOwner, out gitRepo); + + return ChangelogTemplateSeeder.ApplyBundleRepoSeed(content, ownerCli, repoCli, gitOwner, gitRepo); + } + /// /// Upload changelog or bundle artifacts to S3 or Elasticsearch. /// Uses content-hash–based incremental upload: only files whose content has changed are transferred. diff --git a/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs new file mode 100644 index 000000000..796cbef93 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/ChangelogTemplateSeederTests.cs @@ -0,0 +1,182 @@ +// 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"); + } + + [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"""); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/GitConfigOriginParserTests.cs b/tests/Elastic.Documentation.Configuration.Tests/GitConfigOriginParserTests.cs new file mode 100644 index 000000000..ee61af2a1 --- /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 000000000..8c44be80a --- /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 000000000..a29db1e22 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/GitRemoteConfigurationReaderTests.cs @@ -0,0 +1,65 @@ +// 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/commondir", + new("../..\n")); + fs.AddFile( + "/main/.git/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"); + } + + [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(); + } +}