Skip to content

Commit 8fa0c04

Browse files
lcawlcotti
andauthored
Add --repo --owner to changelog init (#3042)
Co-authored-by: Felipe Cotti <felipe.cotti@elastic.co>
1 parent 55de3f9 commit 8fa0c04

11 files changed

Lines changed: 665 additions & 5 deletions

File tree

config/changelog.example.yml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,16 @@ bundle:
212212
output_directory: docs/releases
213213
# Whether to resolve (copy contents) by default
214214
resolve: true
215+
# changelog-init-bundle-seed
215216
# PR/issue link allowlist: when set (including []), only links to these owner/repo pairs are kept
216217
# in bundle output; others are rewritten to '# PRIVATE:' sentinels (requires resolve: true).
217-
# When omitted, no link filtering is applied.
218-
# Add your repository and any others whose PR/issue links should appear in published docs.
218+
# There is no implicit allow: you must list every repo whose links should appear, including your
219+
# own (bundle.repo as owner/repo). When omitted entirely, no link filtering is applied.
220+
# Run `changelog init` in a GitHub clone to pre-fill owner, repo, and link_allow_repos, or set
221+
# bundle.owner, bundle.repo, and link_allow_repos manually. Example — allow only this repo:
219222
# link_allow_repos:
220-
# - elastic/elasticsearch
221-
# - elastic/kibana
223+
# - elastic/kibana
224+
# To allow cross-repo links, add more owner/repo entries (for example elastic/elasticsearch).
222225
# Optional: default GitHub repo name applied to all profiles that do not specify their own.
223226
# Used by the {changelog} directive to generate correct PR/issue links when the product ID
224227
# differs from the GitHub repository name. Can be overridden per profile.

docs/cli/changelog/init.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ If no docs folder exists, the command creates `{path}/docs` and places `changelo
1313
The command creates a `changelog.yml` configuration file (from the built-in template) and `changelog` and `releases` subdirectories in the `docs` folder.
1414
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).
1515

16+
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.
17+
1618
## Usage
1719

1820
```sh
@@ -33,6 +35,12 @@ docs-builder changelog init [options...] [-h|--help]
3335
: Optional: Path to the bundles output directory.
3436
: Defaults to `{docsFolder}/releases`.
3537

38+
`--owner <string?>`
39+
: 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`.
40+
41+
`--repo <string?>`
42+
: 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`.
43+
3644
## Examples
3745

3846
Initialize changelog (creates or uses docs folder, places `changelog.yml` there, plus `changelog` and `releases` subdirectories):
@@ -55,3 +63,9 @@ docs-builder changelog init \
5563
--changelog-dir ./my-changelogs \
5664
--bundles-dir ./my-releases
5765
```
66+
67+
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`:
68+
69+
```sh
70+
docs-builder changelog init --owner elastic --repo kibana
71+
```
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
namespace Elastic.Documentation.Configuration;
6+
7+
/// <summary>
8+
/// Applies <c>bundle.owner</c>, <c>bundle.repo</c>, and <c>bundle.link_allow_repos</c> seeding
9+
/// to the changelog template placeholder. Pure string transformation with no I/O.
10+
/// </summary>
11+
public static class ChangelogTemplateSeeder
12+
{
13+
internal const string Placeholder = " # changelog-init-bundle-seed";
14+
15+
/// <summary>
16+
/// Replaces or removes the <c># changelog-init-bundle-seed</c> placeholder in template content.
17+
/// CLI values take precedence over git-inferred values. When only repo is known, owner defaults to <c>elastic</c>.
18+
/// </summary>
19+
public static string ApplyBundleRepoSeed(string content, string? ownerCli, string? repoCli, string? gitOwner, string? gitRepo)
20+
{
21+
var gitMatched = gitOwner is not null && gitRepo is not null;
22+
23+
var resolvedRepo = string.IsNullOrWhiteSpace(repoCli) ? gitRepo : repoCli.Trim();
24+
var resolvedOwner = string.IsNullOrWhiteSpace(ownerCli) ? gitOwner : ownerCli.Trim();
25+
if (!string.IsNullOrWhiteSpace(resolvedRepo) && string.IsNullOrWhiteSpace(resolvedOwner))
26+
resolvedOwner = "elastic";
27+
28+
var shouldSeed = !string.IsNullOrWhiteSpace(resolvedOwner) && !string.IsNullOrWhiteSpace(resolvedRepo)
29+
&& (!string.IsNullOrWhiteSpace(ownerCli) || !string.IsNullOrWhiteSpace(repoCli) || gitMatched);
30+
31+
var eol = content.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
32+
33+
var block = shouldSeed
34+
? $" owner: {QuoteForYaml(resolvedOwner!)}{eol} repo: {QuoteForYaml(resolvedRepo!)}{eol} link_allow_repos:{eol} - {QuoteForYaml($"{resolvedOwner}/{resolvedRepo}")}{eol}"
35+
: "";
36+
37+
var placeholderWithEol = Placeholder + eol;
38+
if (content.Contains(placeholderWithEol, StringComparison.Ordinal))
39+
return content.Replace(placeholderWithEol, block, StringComparison.Ordinal);
40+
41+
return content.Replace(
42+
Placeholder,
43+
shouldSeed ? block.TrimEnd('\r', '\n') : string.Empty,
44+
StringComparison.Ordinal
45+
);
46+
}
47+
48+
internal static string QuoteForYaml(string value) =>
49+
value.Contains(':') || value.Contains(' ') || value.Contains('#') || value.Contains('"')
50+
|| value.Contains('\\') || value.Contains('\n') || value.Contains('\r') || value.Contains('\t')
51+
? $"\"{value
52+
.Replace("\\", "\\\\")
53+
.Replace("\"", "\\\"")
54+
.Replace("\r", "\\r")
55+
.Replace("\n", "\\n")
56+
.Replace("\t", "\\t")}\""
57+
: value;
58+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Diagnostics.CodeAnalysis;
6+
7+
namespace Elastic.Documentation.Configuration;
8+
9+
/// <summary>
10+
/// Reads <c>remote "origin"</c> URL entries from Git <c>config</c> file text.
11+
/// </summary>
12+
public static class GitConfigOriginParser
13+
{
14+
/// <summary>
15+
/// Returns the first <c>url</c> value under <c>[remote "origin"]</c>.
16+
/// </summary>
17+
public static bool TryGetRemoteOriginUrl(string configContent, [NotNullWhen(true)] out string? url)
18+
{
19+
url = null;
20+
if (string.IsNullOrEmpty(configContent))
21+
return false;
22+
23+
var inOrigin = false;
24+
foreach (var rawLine in configContent.Split(['\r', '\n'], StringSplitOptions.None))
25+
{
26+
var line = rawLine.Trim();
27+
if (line.StartsWith('['))
28+
{
29+
inOrigin = line.Equals("[remote \"origin\"]", StringComparison.Ordinal);
30+
continue;
31+
}
32+
33+
if (!inOrigin)
34+
continue;
35+
36+
if (!line.StartsWith("url", StringComparison.OrdinalIgnoreCase))
37+
continue;
38+
39+
var eq = line.IndexOf('=');
40+
if (eq < 0 || eq >= line.Length - 1)
41+
continue;
42+
43+
var value = line[(eq + 1)..].Trim();
44+
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
45+
value = value[1..^1];
46+
47+
if (string.IsNullOrEmpty(value))
48+
continue;
49+
50+
url = value;
51+
return true;
52+
}
53+
54+
return false;
55+
}
56+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Diagnostics.CodeAnalysis;
6+
7+
namespace Elastic.Documentation.Configuration;
8+
9+
/// <summary>
10+
/// Parses GitHub.com remote URLs into owner and repository name (public API for changelog tooling).
11+
/// </summary>
12+
public static class GitHubRemoteParser
13+
{
14+
/// <summary>
15+
/// Parses an HTTPS or SSH URL for github.com into <paramref name="owner"/> and <paramref name="repo"/>.
16+
/// Other hosts are rejected.
17+
/// </summary>
18+
public static bool TryParseGitHubComOwnerRepo(string? url, [NotNullWhen(true)] out string? owner, [NotNullWhen(true)] out string? repo)
19+
{
20+
owner = null;
21+
repo = null;
22+
if (string.IsNullOrWhiteSpace(url))
23+
return false;
24+
25+
var trimmed = url.Trim();
26+
27+
if (trimmed.StartsWith("git@github.com:", StringComparison.OrdinalIgnoreCase))
28+
{
29+
var rest = trimmed["git@github.com:".Length..];
30+
return TrySplitOwnerRepoPath(rest, out owner, out repo);
31+
}
32+
33+
if (trimmed.StartsWith("ssh://git@github.com/", StringComparison.OrdinalIgnoreCase))
34+
{
35+
var rest = trimmed["ssh://git@github.com/".Length..];
36+
return TrySplitOwnerRepoPath(rest, out owner, out repo);
37+
}
38+
39+
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
40+
return false;
41+
42+
if (!uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase))
43+
return false;
44+
45+
var path = uri.AbsolutePath.Trim('/');
46+
return TrySplitOwnerRepoPath(path, out owner, out repo);
47+
}
48+
49+
private static bool TrySplitOwnerRepoPath(string path, [NotNullWhen(true)] out string? owner, [NotNullWhen(true)] out string? repo)
50+
{
51+
owner = null;
52+
repo = null;
53+
if (string.IsNullOrWhiteSpace(path))
54+
return false;
55+
56+
path = path.TrimEnd('/', ' ');
57+
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
58+
path = path[..^4];
59+
60+
var slash = path.IndexOf('/');
61+
if (slash <= 0 || slash >= path.Length - 1)
62+
return false;
63+
64+
var o = path[..slash];
65+
var r = path[(slash + 1)..];
66+
if (string.IsNullOrEmpty(o) || string.IsNullOrEmpty(r) || r.Contains('/'))
67+
return false;
68+
69+
owner = o;
70+
repo = r;
71+
return true;
72+
}
73+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Diagnostics.CodeAnalysis;
6+
using System.IO.Abstractions;
7+
8+
namespace Elastic.Documentation.Configuration;
9+
10+
/// <summary>
11+
/// Reads <c>remote.origin.url</c> from a local Git checkout using the file system (no subprocess).
12+
/// </summary>
13+
public static class GitRemoteConfigurationReader
14+
{
15+
/// <summary>
16+
/// Reads <c>.git/config</c>, or the <c>config</c> file referenced by a <c>.git</c> worktree pointer file.
17+
/// </summary>
18+
public static bool TryReadOriginUrl(IFileSystem fileSystem, string repositoryRoot, [NotNullWhen(true)] out string? url)
19+
{
20+
url = null;
21+
try
22+
{
23+
var gitPath = fileSystem.Path.Combine(repositoryRoot, ".git");
24+
if (fileSystem.Directory.Exists(gitPath))
25+
{
26+
var configPath = fileSystem.Path.Combine(gitPath, "config");
27+
return TryReadOriginUrlFromConfigPath(fileSystem, configPath, out url);
28+
}
29+
30+
if (!fileSystem.File.Exists(gitPath))
31+
return false;
32+
33+
var gitFileText = fileSystem.File.ReadAllText(gitPath);
34+
var firstLineBreak = gitFileText.IndexOfAny(['\r', '\n']);
35+
var firstLine = firstLineBreak >= 0 ? gitFileText[..firstLineBreak] : gitFileText;
36+
firstLine = firstLine.Trim();
37+
if (!firstLine.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase))
38+
return false;
39+
40+
var gitDir = firstLine["gitdir:".Length..].Trim();
41+
if (string.IsNullOrEmpty(gitDir))
42+
return false;
43+
44+
var resolvedGitDir = fileSystem.Path.IsPathFullyQualified(gitDir)
45+
? gitDir
46+
: fileSystem.Path.GetFullPath(fileSystem.Path.Combine(repositoryRoot, gitDir));
47+
48+
var commonDirFile = fileSystem.Path.Combine(resolvedGitDir, "commondir");
49+
if (!fileSystem.File.Exists(commonDirFile))
50+
return false;
51+
52+
var commonDirRelative = fileSystem.File.ReadAllText(commonDirFile).Trim();
53+
var commonDir = fileSystem.Path.IsPathFullyQualified(commonDirRelative)
54+
? commonDirRelative
55+
: fileSystem.Path.GetFullPath(fileSystem.Path.Combine(resolvedGitDir, commonDirRelative));
56+
57+
var worktreeConfigPath = fileSystem.Path.Combine(commonDir, "config");
58+
return TryReadOriginUrlFromConfigPath(fileSystem, worktreeConfigPath, out url);
59+
}
60+
catch (IOException)
61+
{
62+
url = null;
63+
return false;
64+
}
65+
catch (UnauthorizedAccessException)
66+
{
67+
url = null;
68+
return false;
69+
}
70+
}
71+
72+
private static bool TryReadOriginUrlFromConfigPath(IFileSystem fileSystem, string configPath, [NotNullWhen(true)] out string? url)
73+
{
74+
url = null;
75+
try
76+
{
77+
if (!fileSystem.File.Exists(configPath))
78+
return false;
79+
80+
var content = fileSystem.File.ReadAllText(configPath);
81+
return GitConfigOriginParser.TryGetRemoteOriginUrl(content, out url);
82+
}
83+
catch (IOException)
84+
{
85+
url = null;
86+
return false;
87+
}
88+
catch (UnauthorizedAccessException)
89+
{
90+
url = null;
91+
return false;
92+
}
93+
}
94+
}

src/tooling/docs-builder/Commands/ChangelogCommand.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,20 @@ public Task<int> Default()
5555
/// <summary>
5656
/// 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.
5757
/// When changelog.yml already exists and --changelog-dir or --bundles-dir is specified, updates the bundle.directory and/or bundle.output_directory fields accordingly.
58+
/// 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.
5859
/// </summary>
5960
/// <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>
6061
/// <param name="changelogDir">Optional: Path to changelog directory. Defaults to {docsFolder}/changelog.</param>
6162
/// <param name="bundlesDir">Optional: Path to bundles output directory. Defaults to {docsFolder}/releases.</param>
63+
/// <param name="owner">Optional: GitHub owner for bundle defaults and link_allow_repos seeding. Overrides the owner inferred from git remote origin.</param>
64+
/// <param name="repo">Optional: GitHub repository name for bundle defaults and link_allow_repos seeding. Overrides the repo inferred from git remote origin.</param>
6265
[Command("init")]
6366
public Task<int> Init(
6467
string? path = null,
6568
string? changelogDir = null,
66-
string? bundlesDir = null
69+
string? bundlesDir = null,
70+
string? owner = null,
71+
string? repo = null
6772
)
6873
{
6974
var rootPath = NormalizePath(path ?? ".");
@@ -139,6 +144,8 @@ public Task<int> Init(
139144
content = content.Replace("output_directory: docs/releases", $"output_directory: {outputValue}");
140145
}
141146

147+
content = ApplyChangelogInitBundleRepoSeed(content, owner, repo, repoRoot);
148+
142149
try
143150
{
144151
_fileSystem.File.WriteAllBytes(configPath, Encoding.UTF8.GetBytes(content));
@@ -1323,6 +1330,16 @@ private static string GetPathForConfig(string repoPath, string targetPath)
13231330
return pathForConfig;
13241331
}
13251332

1333+
private string ApplyChangelogInitBundleRepoSeed(string content, string? ownerCli, string? repoCli, string repoRoot)
1334+
{
1335+
string? gitOwner = null;
1336+
string? gitRepo = null;
1337+
if (GitRemoteConfigurationReader.TryReadOriginUrl(_fileSystem, repoRoot, out var originUrl))
1338+
_ = GitHubRemoteParser.TryParseGitHubComOwnerRepo(originUrl, out gitOwner, out gitRepo);
1339+
1340+
return ChangelogTemplateSeeder.ApplyBundleRepoSeed(content, ownerCli, repoCli, gitOwner, gitRepo);
1341+
}
1342+
13261343
/// <summary>
13271344
/// Upload changelog or bundle artifacts to S3 or Elasticsearch.
13281345
/// Uses content-hash–based incremental upload: only files whose content has changed are transferred.

0 commit comments

Comments
 (0)