Skip to content
11 changes: 7 additions & 4 deletions config/changelog.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
lcawl marked this conversation as resolved.
# 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.
Expand Down
14 changes: 14 additions & 0 deletions docs/cli/changelog/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +35,12 @@ docs-builder changelog init [options...] [-h|--help]
: Optional: Path to the bundles output directory.
: Defaults to `{docsFolder}/releases`.

`--owner <string?>`
: 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 <string?>`
: 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):
Expand All @@ -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
```
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Applies <c>bundle.owner</c>, <c>bundle.repo</c>, and <c>bundle.link_allow_repos</c> seeding
/// to the changelog template placeholder. Pure string transformation with no I/O.
/// </summary>
public static class ChangelogTemplateSeeder
{
internal const string Placeholder = " # changelog-init-bundle-seed";

/// <summary>
/// Replaces or removes the <c># changelog-init-bundle-seed</c> placeholder in template content.
/// CLI values take precedence over git-inferred values. When only repo is known, owner defaults to <c>elastic</c>.
/// </summary>
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Reads <c>remote "origin"</c> URL entries from Git <c>config</c> file text.
/// </summary>
public static class GitConfigOriginParser
{
/// <summary>
/// Returns the first <c>url</c> value under <c>[remote "origin"]</c>.
/// </summary>
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;
}
}
73 changes: 73 additions & 0 deletions src/Elastic.Documentation.Configuration/GitHubRemoteParser.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Parses GitHub.com remote URLs into owner and repository name (public API for changelog tooling).
/// </summary>
public static class GitHubRemoteParser
{
/// <summary>
/// Parses an HTTPS or SSH URL for github.com into <paramref name="owner"/> and <paramref name="repo"/>.
/// Other hosts are rejected.
/// </summary>
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;
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Reads <c>remote.origin.url</c> from a local Git checkout using the file system (no subprocess).
/// </summary>
public static class GitRemoteConfigurationReader
{
/// <summary>
/// Reads <c>.git/config</c>, or the <c>config</c> file referenced by a <c>.git</c> worktree pointer file.
/// </summary>
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;
}
}
}
19 changes: 18 additions & 1 deletion src/tooling/docs-builder/Commands/ChangelogCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,20 @@ public Task<int> Default()
/// <summary>
/// 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.
/// </summary>
/// <param name="path">Optional: Repository root path. Defaults to the output of pwd (current directory). Docs folder is {path}/docs, created if it does not exist.</param>
/// <param name="changelogDir">Optional: Path to changelog directory. Defaults to {docsFolder}/changelog.</param>
/// <param name="bundlesDir">Optional: Path to bundles output directory. Defaults to {docsFolder}/releases.</param>
/// <param name="owner">Optional: GitHub owner for bundle defaults and link_allow_repos seeding. Overrides the owner inferred from git remote origin.</param>
/// <param name="repo">Optional: GitHub repository name for bundle defaults and link_allow_repos seeding. Overrides the repo inferred from git remote origin.</param>
[Command("init")]
public Task<int> Init(
string? path = null,
string? changelogDir = null,
string? bundlesDir = null
string? bundlesDir = null,
string? owner = null,
string? repo = null
)
{
var rootPath = NormalizePath(path ?? ".");
Expand Down Expand Up @@ -139,6 +144,8 @@ public Task<int> 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));
Expand Down Expand Up @@ -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);
}

/// <summary>
/// Upload changelog or bundle artifacts to S3 or Elasticsearch.
/// Uses content-hash–based incremental upload: only files whose content has changed are transferred.
Expand Down
Loading
Loading