From 2a7791e34056f9cfcb3f64c578cf2ecdebbe4bc8 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 3 Jun 2025 09:32:51 -0300 Subject: [PATCH 01/11] WIP: Refactor location of reusable external command handler functionality --- .../Elastic.Documentation.Tooling.csproj | 1 + .../ExternalCommandExecutor.cs | 110 ++++++++++++++++++ .../ExternalCommands/NoopConsoleWriter.cs | 16 +++ .../docs-assembler/Sourcing/GitFacade.cs | 62 +--------- .../Sourcing/RepositorySourcesFetcher.cs | 9 -- .../Tracking/GitRepositoryTracker.cs | 14 +++ .../Tracking/IRepositoryTracker.cs | 10 ++ 7 files changed, 154 insertions(+), 68 deletions(-) create mode 100644 src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs create mode 100644 src/tooling/Elastic.Documentation.Tooling/ExternalCommands/NoopConsoleWriter.cs create mode 100644 src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs create mode 100644 src/tooling/docs-builder/Tracking/IRepositoryTracker.cs diff --git a/src/tooling/Elastic.Documentation.Tooling/Elastic.Documentation.Tooling.csproj b/src/tooling/Elastic.Documentation.Tooling/Elastic.Documentation.Tooling.csproj index 52cc0b08e..5a53168d4 100644 --- a/src/tooling/Elastic.Documentation.Tooling/Elastic.Documentation.Tooling.csproj +++ b/src/tooling/Elastic.Documentation.Tooling/Elastic.Documentation.Tooling.csproj @@ -13,6 +13,7 @@ + diff --git a/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs new file mode 100644 index 000000000..ca9804a9b --- /dev/null +++ b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs @@ -0,0 +1,110 @@ +// 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; +using Elastic.Documentation.Diagnostics; +using ProcNet; + +namespace Elastic.Documentation.Tooling.ExternalCommands; + +public abstract class ExternalCommandExecutor(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) +{ + protected IDirectoryInfo WorkingDirectory => workingDirectory; + protected void ExecIn(string binary, params string[] args) + { + var arguments = new ExecArguments(binary, args) + { + WorkingDirectory = workingDirectory.FullName, + Environment = new Dictionary + { + // Disable git editor prompts: + // There are cases where `git pull` would prompt for an editor to write a commit message. + // This env variable prevents that. + { "GIT_EDITOR", "true" } + }, + }; + var result = Proc.Exec(arguments); + if (result != 0) + collector.EmitError("", $"Exit code: {result} while executing {binary} {string.Join(" ", args)} in {workingDirectory}"); + } + + protected string[] CaptureMultiple(string binary, params string[] args) + { + // Try 10 times to capture the output of the command, if it fails, we'll throw an exception on the last try + Exception? e = null; + for (var i = 0; i <= 9; i++) + { + try + { + return CaptureOutput(); + } + catch (Exception ex) + { + if (ex is not null) + e = ex; + } + } + + if (e is not null) + collector.EmitError("", "failure capturing stdout", e); + + return []; + + string[] CaptureOutput() + { + var arguments = new StartArguments(binary, args) + { + WorkingDirectory = workingDirectory.FullName, + Timeout = TimeSpan.FromSeconds(3), + WaitForExit = TimeSpan.FromSeconds(3), + ConsoleOutWriter = NoopConsoleWriter.Instance + }; + var result = Proc.Start(arguments); + var output = result.ExitCode != 0 + ? throw new Exception($"Exit code is not 0. Received {result.ExitCode} from {binary}: {workingDirectory}") + : result.ConsoleOut.Select(x => x.Line).ToArray() ?? throw new Exception($"No output captured for {binary}: {workingDirectory}"); + return output; + } + } + + + protected string Capture(string binary, params string[] args) + { + // Try 10 times to capture the output of the command, if it fails, we'll throw an exception on the last try + Exception? e = null; + for (var i = 0; i <= 9; i++) + { + try + { + return CaptureOutput(); + } + catch (Exception ex) + { + if (ex is not null) + e = ex; + } + } + + if (e is not null) + collector.EmitError("", "failure capturing stdout", e); + + return string.Empty; + + string CaptureOutput() + { + var arguments = new StartArguments(binary, args) + { + WorkingDirectory = workingDirectory.FullName, + Timeout = TimeSpan.FromSeconds(3), + WaitForExit = TimeSpan.FromSeconds(3), + ConsoleOutWriter = NoopConsoleWriter.Instance + }; + var result = Proc.Start(arguments); + var line = result.ExitCode != 0 + ? throw new Exception($"Exit code is not 0. Received {result.ExitCode} from {binary}: {workingDirectory}") + : result.ConsoleOut.FirstOrDefault()?.Line ?? throw new Exception($"No output captured for {binary}: {workingDirectory}"); + return line; + } + } +} diff --git a/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/NoopConsoleWriter.cs b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/NoopConsoleWriter.cs new file mode 100644 index 000000000..19738a478 --- /dev/null +++ b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/NoopConsoleWriter.cs @@ -0,0 +1,16 @@ +// 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 ProcNet.Std; + +namespace Elastic.Documentation.Tooling.ExternalCommands; + +public class NoopConsoleWriter : IConsoleOutWriter +{ + public static readonly NoopConsoleWriter Instance = new(); + + public void Write(Exception e) { } + + public void Write(ConsoleOut consoleOut) { } +} diff --git a/src/tooling/docs-assembler/Sourcing/GitFacade.cs b/src/tooling/docs-assembler/Sourcing/GitFacade.cs index f64a38888..d7bda6287 100644 --- a/src/tooling/docs-assembler/Sourcing/GitFacade.cs +++ b/src/tooling/docs-assembler/Sourcing/GitFacade.cs @@ -4,7 +4,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Diagnostics; -using ProcNet; +using Elastic.Documentation.Tooling.ExternalCommands; namespace Documentation.Assembler.Sourcing; @@ -24,12 +24,12 @@ public interface IGitRepository // This git repository implementation is optimized for pull and fetching single commits. // It uses `git pull --depth 1` and `git fetch --depth 1` to minimize the amount of data transferred. -public class SingleCommitOptimizedGitRepository(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : IGitRepository +public class SingleCommitOptimizedGitRepository(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : ExternalCommandExecutor(collector, workingDirectory), IGitRepository { public string GetCurrentCommit() => Capture("git", "rev-parse", "HEAD"); public void Init() => ExecIn("git", "init"); - public bool IsInitialized() => Directory.Exists(Path.Combine(workingDirectory.FullName, ".git")); + public bool IsInitialized() => Directory.Exists(Path.Combine(WorkingDirectory.FullName, ".git")); public void Pull(string branch) => ExecIn("git", "pull", "--depth", "1", "--allow-unrelated-histories", "--no-ff", "origin", branch); public void Fetch(string reference) => ExecIn("git", "fetch", "--no-tags", "--prune", "--no-recurse-submodules", "--depth", "1", "origin", reference); public void EnableSparseCheckout(string folder) => ExecIn("git", "sparse-checkout", "set", folder); @@ -37,60 +37,4 @@ public class SingleCommitOptimizedGitRepository(DiagnosticsCollector collector, public void Checkout(string reference) => ExecIn("git", "checkout", "--force", reference); public void GitAddOrigin(string origin) => ExecIn("git", "remote", "add", "origin", origin); - - private void ExecIn(string binary, params string[] args) - { - var arguments = new ExecArguments(binary, args) - { - WorkingDirectory = workingDirectory.FullName, - Environment = new Dictionary - { - // Disable git editor prompts: - // There are cases where `git pull` would prompt for an editor to write a commit message. - // This env variable prevents that. - { "GIT_EDITOR", "true" } - }, - }; - var result = Proc.Exec(arguments); - if (result != 0) - collector.EmitError("", $"Exit code: {result} while executing {binary} {string.Join(" ", args)} in {workingDirectory}"); - } - private string Capture(string binary, params string[] args) - { - // Try 10 times to capture the output of the command, if it fails, we'll throw an exception on the last try - Exception? e = null; - for (var i = 0; i <= 9; i++) - { - try - { - return CaptureOutput(); - } - catch (Exception ex) - { - if (ex is not null) - e = ex; - } - } - - if (e is not null) - collector.EmitError("", "failure capturing stdout", e); - - return string.Empty; - - string CaptureOutput() - { - var arguments = new StartArguments(binary, args) - { - WorkingDirectory = workingDirectory.FullName, - Timeout = TimeSpan.FromSeconds(3), - WaitForExit = TimeSpan.FromSeconds(3), - ConsoleOutWriter = NoopConsoleWriter.Instance - }; - var result = Proc.Start(arguments); - var line = result.ExitCode != 0 - ? throw new Exception($"Exit code is not 0. Received {result.ExitCode} from {binary}: {workingDirectory}") - : result.ConsoleOut.FirstOrDefault()?.Line ?? throw new Exception($"No output captured for {binary}: {workingDirectory}"); - return line; - } - } } diff --git a/src/tooling/docs-assembler/Sourcing/RepositorySourcesFetcher.cs b/src/tooling/docs-assembler/Sourcing/RepositorySourcesFetcher.cs index 4b3c83a87..9fa3dcc81 100644 --- a/src/tooling/docs-assembler/Sourcing/RepositorySourcesFetcher.cs +++ b/src/tooling/docs-assembler/Sourcing/RepositorySourcesFetcher.cs @@ -238,15 +238,6 @@ private static void FetchAndCheckout(IGitRepository git, Repository repository, } } -public class NoopConsoleWriter : IConsoleOutWriter -{ - public static readonly NoopConsoleWriter Instance = new(); - - public void Write(Exception e) { } - - public void Write(ConsoleOut consoleOut) { } -} - public record CheckoutResult { public static string LinkRegistrySnapshotFileName => "link-index.snapshot.json"; diff --git a/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs b/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs new file mode 100644 index 000000000..89a3949fa --- /dev/null +++ b/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs @@ -0,0 +1,14 @@ +// 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; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Tooling.ExternalCommands; + +namespace Documentation.Builder.Tracking; + +public class GitRepositoryTracker(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : ExternalCommandExecutor(collector, workingDirectory), IRepositoryTracker +{ + public IEnumerable GetChangedFiles() => CaptureMultiple("git", "diff", "--name-status", "main...HEAD", "--", "\"./docs\""); +} diff --git a/src/tooling/docs-builder/Tracking/IRepositoryTracker.cs b/src/tooling/docs-builder/Tracking/IRepositoryTracker.cs new file mode 100644 index 000000000..37c20e36c --- /dev/null +++ b/src/tooling/docs-builder/Tracking/IRepositoryTracker.cs @@ -0,0 +1,10 @@ +// 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 Documentation.Builder.Tracking; + +public interface IRepositoryTracker +{ + IEnumerable GetChangedFiles(); +} From 8f6bddfdedb8da7b4d1715075453ad1d3ae7f24f Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 3 Jun 2025 13:17:49 -0300 Subject: [PATCH 02/11] Capture moved/deleted from docs --- .../ExternalCommands/ExternalCommandExecutor.cs | 4 ++-- .../docs-builder/Tracking/GitRepositoryTracker.cs | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs index ca9804a9b..f5e1ab574 100644 --- a/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs +++ b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Diagnostics; using ProcNet; +using ProcNet.Std; namespace Elastic.Documentation.Tooling.ExternalCommands; @@ -57,8 +58,7 @@ string[] CaptureOutput() { WorkingDirectory = workingDirectory.FullName, Timeout = TimeSpan.FromSeconds(3), - WaitForExit = TimeSpan.FromSeconds(3), - ConsoleOutWriter = NoopConsoleWriter.Instance + WaitForExit = TimeSpan.FromSeconds(3) }; var result = Proc.Start(arguments); var output = result.ExitCode != 0 diff --git a/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs b/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs index 89a3949fa..eb7dcbc97 100644 --- a/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs +++ b/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs @@ -10,5 +10,15 @@ namespace Documentation.Builder.Tracking; public class GitRepositoryTracker(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : ExternalCommandExecutor(collector, workingDirectory), IRepositoryTracker { - public IEnumerable GetChangedFiles() => CaptureMultiple("git", "diff", "--name-status", "main...HEAD", "--", "\"./docs\""); + public IEnumerable GetChangedFiles() + { + var output = CaptureMultiple("git", "diff", "--name-status", "main...HEAD", "--", "./docs"); + if (output.Length == 0) + return []; + + var movedOrDeleted = output + .Where(line => line.StartsWith('R') || line.StartsWith('D')) + .Select(line => line.Split('\t')[1]); + return movedOrDeleted; + } } From 70d291cf661b96db4f7ffcc4e9aa481e0e22bf4b Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 3 Jun 2025 18:46:52 -0300 Subject: [PATCH 03/11] Introduce CI Action for redirect health validation --- .github/workflows/preview-build.yml | 3 + actions/validate-redirects-health/action.yml | 10 +++ .../ExternalCommandExecutor.cs | 3 +- .../docs-builder/Cli/LinkHealthCommands.cs | 64 +++++++++++++++++++ src/tooling/docs-builder/Program.cs | 1 + .../Tracking/GitRepositoryTracker.cs | 9 ++- 6 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 actions/validate-redirects-health/action.yml create mode 100644 src/tooling/docs-builder/Cli/LinkHealthCommands.cs diff --git a/.github/workflows/preview-build.yml b/.github/workflows/preview-build.yml index e9ddc8497..70aee5adc 100644 --- a/.github/workflows/preview-build.yml +++ b/.github/workflows/preview-build.yml @@ -181,6 +181,9 @@ jobs: if: env.MATCH == 'true' && (github.repository == 'elastic/docs-builder' && steps.deployment.outputs.result) uses: elastic/docs-builder/.github/actions/bootstrap@main + - name: Validate Redirect Health + uses: elastic/docs-builder/actions/validate-redirects-health@main + # we run our artifact directly, please use the prebuild # elastic/docs-builder@main GitHub Action for all other repositories! - name: Build documentation diff --git a/actions/validate-redirects-health/action.yml b/actions/validate-redirects-health/action.yml new file mode 100644 index 000000000..b7212f7a7 --- /dev/null +++ b/actions/validate-redirects-health/action.yml @@ -0,0 +1,10 @@ +name: 'Validate Redirects Health' +description: 'Validates moved and deleted files received redirect rules' + +runs: + using: "composite" + steps: + - name: Validate Redirects + shell: bash + run: | + dotnet run --project src/tooling/docs-builder/docs-builder.csproj -- health validate-redirects \ No newline at end of file diff --git a/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs index f5e1ab574..c4be26aa3 100644 --- a/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs +++ b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs @@ -58,7 +58,8 @@ string[] CaptureOutput() { WorkingDirectory = workingDirectory.FullName, Timeout = TimeSpan.FromSeconds(3), - WaitForExit = TimeSpan.FromSeconds(3) + WaitForExit = TimeSpan.FromSeconds(3), + ConsoleOutWriter = NoopConsoleWriter.Instance }; var result = Proc.Start(arguments); var output = result.ExitCode != 0 diff --git a/src/tooling/docs-builder/Cli/LinkHealthCommands.cs b/src/tooling/docs-builder/Cli/LinkHealthCommands.cs new file mode 100644 index 000000000..e4d57d202 --- /dev/null +++ b/src/tooling/docs-builder/Cli/LinkHealthCommands.cs @@ -0,0 +1,64 @@ +// 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; +using Actions.Core.Services; +using ConsoleAppFramework; +using Documentation.Builder.Tracking; +using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.Tooling.Diagnostics.Console; +using Elastic.Documentation.Tooling.Filters; +using Elastic.Markdown; +using Elastic.Markdown.IO; +using Microsoft.Extensions.Logging; + +namespace Documentation.Builder.Cli; + +internal sealed class LinkHealthCommands(ILoggerFactory logger, ICoreService githubActionsService) +{ + /// + /// Validates redirect updates in the current branch using the redirects file against changes reported by git. + /// + /// + [SuppressMessage("Usage", "CA2254:Template should be a static expression")] + [Command("validate-redirects")] + [ConsoleAppFilter] + [ConsoleAppFilter] + public async Task ValidateRedirects(Cancel ctx = default) + { + var log = logger.CreateLogger(); + ConsoleApp.Log = msg => log.LogInformation(msg); + ConsoleApp.LogError = msg => log.LogError(msg); + + await using var collector = new ConsoleDiagnosticsCollector(logger, githubActionsService).StartAsync(ctx); + + var fs = new FileSystem(); + var root = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName); + + var buildContext = new BuildContext(collector, fs, fs, root.FullName, null); + var sourceFile = buildContext.ConfigurationPath; + var redirectFileName = sourceFile.Name.StartsWith('_') ? "_redirects.yml" : "redirects.yml"; + var redirectFileInfo = sourceFile.FileSystem.FileInfo.New(Path.Combine(sourceFile.Directory!.FullName, redirectFileName)); + + var redirectFileParser = new RedirectFile(redirectFileInfo, buildContext); + var redirects = redirectFileParser.Redirects; + + if (redirects is null) + { + collector.EmitError(redirectFileInfo, "It was not possible to parse the redirects file."); + await collector.StopAsync(ctx); + return collector.Errors; + } + + var tracker = new GitRepositoryTracker(collector, root); + var changed = tracker.GetChangedFiles(); + + foreach (var notFound in changed.Where(c => !redirects.ContainsKey(c))) + collector.EmitError(notFound, $"{notFound} has been moved or deleted without a redirect rule being set for it. Please add a redirect rule in this project's {redirectFileInfo.Name}."); + + await collector.StopAsync(ctx); + return collector.Errors; + } +} diff --git a/src/tooling/docs-builder/Program.cs b/src/tooling/docs-builder/Program.cs index a73494faa..1ef205c01 100644 --- a/src/tooling/docs-builder/Program.cs +++ b/src/tooling/docs-builder/Program.cs @@ -18,5 +18,6 @@ var app = ConsoleApp.Create(); app.Add(); app.Add("inbound-links"); +app.Add("health"); await app.RunAsync(args).ConfigureAwait(false); diff --git a/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs b/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs index eb7dcbc97..d153020d1 100644 --- a/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs +++ b/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs @@ -3,12 +3,13 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using System.Text.RegularExpressions; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Tooling.ExternalCommands; namespace Documentation.Builder.Tracking; -public class GitRepositoryTracker(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : ExternalCommandExecutor(collector, workingDirectory), IRepositoryTracker +public partial class GitRepositoryTracker(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : ExternalCommandExecutor(collector, workingDirectory), IRepositoryTracker { public IEnumerable GetChangedFiles() { @@ -18,7 +19,11 @@ public IEnumerable GetChangedFiles() var movedOrDeleted = output .Where(line => line.StartsWith('R') || line.StartsWith('D')) - .Select(line => line.Split('\t')[1]); + .Select(line => line.Split('\t')[1]) + .Select(line => DocsFolderRegex().Split(line)[1]); return movedOrDeleted; } + + [GeneratedRegex("docs/")] + private static partial Regex DocsFolderRegex(); } From ce274b8d801f3ddaa1fe0617ee4ff7f76fec3105 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Fri, 6 Jun 2025 14:14:18 -0300 Subject: [PATCH 04/11] Remove CI for this implementation stage --- .github/workflows/preview-build.yml | 3 --- actions/validate-redirects-health/action.yml | 10 ---------- 2 files changed, 13 deletions(-) delete mode 100644 actions/validate-redirects-health/action.yml diff --git a/.github/workflows/preview-build.yml b/.github/workflows/preview-build.yml index 70aee5adc..e9ddc8497 100644 --- a/.github/workflows/preview-build.yml +++ b/.github/workflows/preview-build.yml @@ -181,9 +181,6 @@ jobs: if: env.MATCH == 'true' && (github.repository == 'elastic/docs-builder' && steps.deployment.outputs.result) uses: elastic/docs-builder/.github/actions/bootstrap@main - - name: Validate Redirect Health - uses: elastic/docs-builder/actions/validate-redirects-health@main - # we run our artifact directly, please use the prebuild # elastic/docs-builder@main GitHub Action for all other repositories! - name: Build documentation diff --git a/actions/validate-redirects-health/action.yml b/actions/validate-redirects-health/action.yml deleted file mode 100644 index b7212f7a7..000000000 --- a/actions/validate-redirects-health/action.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: 'Validate Redirects Health' -description: 'Validates moved and deleted files received redirect rules' - -runs: - using: "composite" - steps: - - name: Validate Redirects - shell: bash - run: | - dotnet run --project src/tooling/docs-builder/docs-builder.csproj -- health validate-redirects \ No newline at end of file From 37a7183c9c7a5557f0ebab62b2ce75ffe95804fd Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Fri, 6 Jun 2025 14:26:48 -0300 Subject: [PATCH 05/11] Environment variables should be an argument as the context may differ between external execution interfaces. --- .../ExternalCommandExecutor.cs | 10 ++------- .../docs-assembler/Sourcing/GitFacade.cs | 22 +++++++++++++------ .../docs-builder/Cli/LinkHealthCommands.cs | 3 +-- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs index c4be26aa3..842f1942f 100644 --- a/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs +++ b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs @@ -12,18 +12,12 @@ namespace Elastic.Documentation.Tooling.ExternalCommands; public abstract class ExternalCommandExecutor(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) { protected IDirectoryInfo WorkingDirectory => workingDirectory; - protected void ExecIn(string binary, params string[] args) + protected void ExecIn(Dictionary environmentVars, string binary, params string[] args) { var arguments = new ExecArguments(binary, args) { WorkingDirectory = workingDirectory.FullName, - Environment = new Dictionary - { - // Disable git editor prompts: - // There are cases where `git pull` would prompt for an editor to write a commit message. - // This env variable prevents that. - { "GIT_EDITOR", "true" } - }, + Environment = environmentVars }; var result = Proc.Exec(arguments); if (result != 0) diff --git a/src/tooling/docs-assembler/Sourcing/GitFacade.cs b/src/tooling/docs-assembler/Sourcing/GitFacade.cs index d7bda6287..6a518c3db 100644 --- a/src/tooling/docs-assembler/Sourcing/GitFacade.cs +++ b/src/tooling/docs-assembler/Sourcing/GitFacade.cs @@ -26,15 +26,23 @@ public interface IGitRepository // It uses `git pull --depth 1` and `git fetch --depth 1` to minimize the amount of data transferred. public class SingleCommitOptimizedGitRepository(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : ExternalCommandExecutor(collector, workingDirectory), IGitRepository { + private static readonly Dictionary EnvironmentVars = new() + { + // Disable git editor prompts: + // There are cases where `git pull` would prompt for an editor to write a commit message. + // This env variable prevents that. + { "GIT_EDITOR", "true" } + }; + public string GetCurrentCommit() => Capture("git", "rev-parse", "HEAD"); - public void Init() => ExecIn("git", "init"); + public void Init() => ExecIn(EnvironmentVars, "git", "init"); public bool IsInitialized() => Directory.Exists(Path.Combine(WorkingDirectory.FullName, ".git")); - public void Pull(string branch) => ExecIn("git", "pull", "--depth", "1", "--allow-unrelated-histories", "--no-ff", "origin", branch); - public void Fetch(string reference) => ExecIn("git", "fetch", "--no-tags", "--prune", "--no-recurse-submodules", "--depth", "1", "origin", reference); - public void EnableSparseCheckout(string folder) => ExecIn("git", "sparse-checkout", "set", folder); - public void DisableSparseCheckout() => ExecIn("git", "sparse-checkout", "disable"); - public void Checkout(string reference) => ExecIn("git", "checkout", "--force", reference); + public void Pull(string branch) => ExecIn(EnvironmentVars, "git", "pull", "--depth", "1", "--allow-unrelated-histories", "--no-ff", "origin", branch); + public void Fetch(string reference) => ExecIn(EnvironmentVars, "git", "fetch", "--no-tags", "--prune", "--no-recurse-submodules", "--depth", "1", "origin", reference); + public void EnableSparseCheckout(string folder) => ExecIn(EnvironmentVars, "git", "sparse-checkout", "set", folder); + public void DisableSparseCheckout() => ExecIn(EnvironmentVars, "git", "sparse-checkout", "disable"); + public void Checkout(string reference) => ExecIn(EnvironmentVars, "git", "checkout", "--force", reference); - public void GitAddOrigin(string origin) => ExecIn("git", "remote", "add", "origin", origin); + public void GitAddOrigin(string origin) => ExecIn(EnvironmentVars, "git", "remote", "add", "origin", origin); } diff --git a/src/tooling/docs-builder/Cli/LinkHealthCommands.cs b/src/tooling/docs-builder/Cli/LinkHealthCommands.cs index e4d57d202..3544c2d6a 100644 --- a/src/tooling/docs-builder/Cli/LinkHealthCommands.cs +++ b/src/tooling/docs-builder/Cli/LinkHealthCommands.cs @@ -7,11 +7,10 @@ using Actions.Core.Services; using ConsoleAppFramework; using Documentation.Builder.Tracking; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; using Elastic.Documentation.Tooling.Diagnostics.Console; using Elastic.Documentation.Tooling.Filters; -using Elastic.Markdown; -using Elastic.Markdown.IO; using Microsoft.Extensions.Logging; namespace Documentation.Builder.Cli; From e12a95339209b45206031b7d8f7e4c55912d492d Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Fri, 6 Jun 2025 14:30:40 -0300 Subject: [PATCH 06/11] Adjust feature command --- .../Cli/{LinkHealthCommands.cs => ValidationCommands.cs} | 4 ++-- src/tooling/docs-builder/Program.cs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) rename src/tooling/docs-builder/Cli/{LinkHealthCommands.cs => ValidationCommands.cs} (96%) diff --git a/src/tooling/docs-builder/Cli/LinkHealthCommands.cs b/src/tooling/docs-builder/Cli/ValidationCommands.cs similarity index 96% rename from src/tooling/docs-builder/Cli/LinkHealthCommands.cs rename to src/tooling/docs-builder/Cli/ValidationCommands.cs index 3544c2d6a..278a6f176 100644 --- a/src/tooling/docs-builder/Cli/LinkHealthCommands.cs +++ b/src/tooling/docs-builder/Cli/ValidationCommands.cs @@ -15,14 +15,14 @@ namespace Documentation.Builder.Cli; -internal sealed class LinkHealthCommands(ILoggerFactory logger, ICoreService githubActionsService) +internal sealed class ValidationCommands(ILoggerFactory logger, ICoreService githubActionsService) { /// /// Validates redirect updates in the current branch using the redirects file against changes reported by git. /// /// [SuppressMessage("Usage", "CA2254:Template should be a static expression")] - [Command("validate-redirects")] + [Command("validate")] [ConsoleAppFilter] [ConsoleAppFilter] public async Task ValidateRedirects(Cancel ctx = default) diff --git a/src/tooling/docs-builder/Program.cs b/src/tooling/docs-builder/Program.cs index 1ef205c01..0dec3802b 100644 --- a/src/tooling/docs-builder/Program.cs +++ b/src/tooling/docs-builder/Program.cs @@ -6,7 +6,6 @@ using Documentation.Builder.Cli; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Tooling; -using Elastic.Markdown.Diagnostics; using Microsoft.Extensions.DependencyInjection; await using var serviceProvider = DocumentationTooling.CreateServiceProvider(ref args, services => services @@ -18,6 +17,6 @@ var app = ConsoleApp.Create(); app.Add(); app.Add("inbound-links"); -app.Add("health"); +app.Add("diff"); await app.RunAsync(args).ConfigureAwait(false); From cc5dd2b11abd97962cfed40327d7b12f108edf79 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 12 Jun 2025 06:54:10 -0300 Subject: [PATCH 07/11] Further adjustments --- README.md | 17 +++++++-- .../ExternalCommandExecutor.cs | 14 ++++--- ...{ValidationCommands.cs => DiffCommands.cs} | 13 ++++--- src/tooling/docs-builder/Program.cs | 2 +- .../Tracking/GitRepositoryTracker.cs | 29 --------------- .../Tracking/IRepositoryTracker.cs | 2 +- .../Tracking/LocalGitRepositoryTracker.cs | 37 +++++++++++++++++++ 7 files changed, 70 insertions(+), 44 deletions(-) rename src/tooling/docs-builder/Cli/{ValidationCommands.cs => DiffCommands.cs} (79%) delete mode 100644 src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs create mode 100644 src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs diff --git a/README.md b/README.md index 46d7a6464..76d189a1e 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,9 @@ Options: --force Force a full rebuild of the destination folder (Default: null) Commands: - generate Converts a source markdown folder or file to an output folder - serve Continuously serve a documentation folder at http://localhost:3000. + generate Converts a source markdown folder or file to an output folder + serve Continuously serve a documentation folder at http://localhost:3000. + diff validate Validates redirect rules have been applied to the current branch. File systems changes will be reflected without having to restart the server. ``` @@ -118,6 +119,16 @@ https://github.com/elastic/{your-repository}/settings/pages --- +## Validating redirection rules + +If documentation is moved, renamed or deleted, `docs-builder` can verify if changes in the working branch in relation to the default branch are reflected in the repository's `redirects.yml`. Verification in the local machine is currently supported. + +`docs-builder diff validate ` + +`` is an optional parameter to customize the documentation folder path. It defaults to `docs`. + +--- + ## Run without docker You can use the .NET CLI to publish a self-contained `docs-builder` native code @@ -140,7 +151,7 @@ existing surveyed tools # Local Development -## Preqrequisites +## Prerequisites - [.NET 9.0 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) - [Node.js 22.13.1 (LTS)](https://nodejs.org/en/blog/release/v22.13.1) diff --git a/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs index 842f1942f..8c940822f 100644 --- a/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs +++ b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs @@ -64,7 +64,9 @@ string[] CaptureOutput() } - protected string Capture(string binary, params string[] args) + protected string Capture(string binary, params string[] args) => Capture(false, binary, args); + + protected string Capture(bool muteExceptions, string binary, params string[] args) { // Try 10 times to capture the output of the command, if it fails, we'll throw an exception on the last try Exception? e = null; @@ -81,7 +83,7 @@ protected string Capture(string binary, params string[] args) } } - if (e is not null) + if (e is not null && !muteExceptions) collector.EmitError("", "failure capturing stdout", e); return string.Empty; @@ -96,9 +98,11 @@ string CaptureOutput() ConsoleOutWriter = NoopConsoleWriter.Instance }; var result = Proc.Start(arguments); - var line = result.ExitCode != 0 - ? throw new Exception($"Exit code is not 0. Received {result.ExitCode} from {binary}: {workingDirectory}") - : result.ConsoleOut.FirstOrDefault()?.Line ?? throw new Exception($"No output captured for {binary}: {workingDirectory}"); + var line = (result.ExitCode, muteExceptions) switch + { + (0, _) or (not 0, true) => result.ConsoleOut.FirstOrDefault()?.Line ?? throw new Exception($"No output captured for {binary}: {workingDirectory}"), + (not 0, false) => throw new Exception($"Exit code is not 0. Received {result.ExitCode} from {binary}: {workingDirectory}") + }; return line; } } diff --git a/src/tooling/docs-builder/Cli/ValidationCommands.cs b/src/tooling/docs-builder/Cli/DiffCommands.cs similarity index 79% rename from src/tooling/docs-builder/Cli/ValidationCommands.cs rename to src/tooling/docs-builder/Cli/DiffCommands.cs index 278a6f176..6e5f6aac3 100644 --- a/src/tooling/docs-builder/Cli/ValidationCommands.cs +++ b/src/tooling/docs-builder/Cli/DiffCommands.cs @@ -15,22 +15,25 @@ namespace Documentation.Builder.Cli; -internal sealed class ValidationCommands(ILoggerFactory logger, ICoreService githubActionsService) +internal sealed class DiffCommands(ILoggerFactory logger, ICoreService githubActionsService) { /// /// Validates redirect updates in the current branch using the redirects file against changes reported by git. /// + /// The baseline path to perform the check /// [SuppressMessage("Usage", "CA2254:Template should be a static expression")] [Command("validate")] [ConsoleAppFilter] [ConsoleAppFilter] - public async Task ValidateRedirects(Cancel ctx = default) + public async Task ValidateRedirects([Argument] string? path = null, Cancel ctx = default) { var log = logger.CreateLogger(); ConsoleApp.Log = msg => log.LogInformation(msg); ConsoleApp.LogError = msg => log.LogError(msg); + path ??= "docs"; + await using var collector = new ConsoleDiagnosticsCollector(logger, githubActionsService).StartAsync(ctx); var fs = new FileSystem(); @@ -51,11 +54,11 @@ public async Task ValidateRedirects(Cancel ctx = default) return collector.Errors; } - var tracker = new GitRepositoryTracker(collector, root); - var changed = tracker.GetChangedFiles(); + var tracker = new LocalGitRepositoryTracker(collector, root); + var changed = tracker.GetChangedFiles(path); foreach (var notFound in changed.Where(c => !redirects.ContainsKey(c))) - collector.EmitError(notFound, $"{notFound} has been moved or deleted without a redirect rule being set for it. Please add a redirect rule in this project's {redirectFileInfo.Name}."); + collector.EmitError(notFound, $"{notFound} does not have a redirect rule set. Please add a redirect rule in this project's {redirectFileInfo.Name}."); await collector.StopAsync(ctx); return collector.Errors; diff --git a/src/tooling/docs-builder/Program.cs b/src/tooling/docs-builder/Program.cs index 0dec3802b..15a0bbfb3 100644 --- a/src/tooling/docs-builder/Program.cs +++ b/src/tooling/docs-builder/Program.cs @@ -17,6 +17,6 @@ var app = ConsoleApp.Create(); app.Add(); app.Add("inbound-links"); -app.Add("diff"); +app.Add("diff"); await app.RunAsync(args).ConfigureAwait(false); diff --git a/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs b/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs deleted file mode 100644 index d153020d1..000000000 --- a/src/tooling/docs-builder/Tracking/GitRepositoryTracker.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System.IO.Abstractions; -using System.Text.RegularExpressions; -using Elastic.Documentation.Diagnostics; -using Elastic.Documentation.Tooling.ExternalCommands; - -namespace Documentation.Builder.Tracking; - -public partial class GitRepositoryTracker(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : ExternalCommandExecutor(collector, workingDirectory), IRepositoryTracker -{ - public IEnumerable GetChangedFiles() - { - var output = CaptureMultiple("git", "diff", "--name-status", "main...HEAD", "--", "./docs"); - if (output.Length == 0) - return []; - - var movedOrDeleted = output - .Where(line => line.StartsWith('R') || line.StartsWith('D')) - .Select(line => line.Split('\t')[1]) - .Select(line => DocsFolderRegex().Split(line)[1]); - return movedOrDeleted; - } - - [GeneratedRegex("docs/")] - private static partial Regex DocsFolderRegex(); -} diff --git a/src/tooling/docs-builder/Tracking/IRepositoryTracker.cs b/src/tooling/docs-builder/Tracking/IRepositoryTracker.cs index 37c20e36c..f97a2b8e2 100644 --- a/src/tooling/docs-builder/Tracking/IRepositoryTracker.cs +++ b/src/tooling/docs-builder/Tracking/IRepositoryTracker.cs @@ -6,5 +6,5 @@ namespace Documentation.Builder.Tracking; public interface IRepositoryTracker { - IEnumerable GetChangedFiles(); + IEnumerable GetChangedFiles(string lookupPath); } diff --git a/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs b/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs new file mode 100644 index 000000000..16d7aa3ea --- /dev/null +++ b/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs @@ -0,0 +1,37 @@ +// 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; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Tooling.ExternalCommands; +namespace Documentation.Builder.Tracking; + +public partial class LocalGitRepositoryTracker(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : ExternalCommandExecutor(collector, workingDirectory), IRepositoryTracker +{ + public IEnumerable GetChangedFiles(string lookupPath) + { + var defaultBranch = GetDefaultBranch(); + var commitChanges = CaptureMultiple("git", "diff", "--name-status", $"{defaultBranch}...HEAD", "--", $"./{lookupPath}"); + var localChanges = CaptureMultiple("git", "status", "--porcelain"); + List output = [ + .. commitChanges + .Where(line => line.StartsWith('R') || line.StartsWith('D') || line.StartsWith('A')) + .Select(line => line.Split('\t')[1]), + .. localChanges + .Select(x => x.TrimStart()) + .Where(line => line.StartsWith('R') || line.StartsWith('D') || line.StartsWith("A ", StringComparison.Ordinal) || line.StartsWith("??")) + .Select(line => line.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)[1]) + ]; + return output.Where(line => line.StartsWith(lookupPath)); + } + + private string GetDefaultBranch() + { + if (!Capture(true, "git", "merge-base", "-a", "HEAD", "main").StartsWith("fatal", StringComparison.InvariantCulture)) + return "main"; + if (!Capture(true, "git", "merge-base", "-a", "HEAD", "master").StartsWith("fatal", StringComparison.InvariantCulture)) + return "master"; + return Capture("git", "symbolic-ref", "refs/remotes/origin/HEAD").Split('/').Last(); + } +} From 7f8a2192cd7ec7eeba4c02a87903c4ccccf95eed Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 12 Jun 2025 14:34:32 -0300 Subject: [PATCH 08/11] Introduce records to keep track of git change data, and leverage them to make clearer reporting. --- src/tooling/docs-builder/Cli/DiffCommands.cs | 18 ++++- .../Tracking/IRepositoryTracker.cs | 12 +++- .../Tracking/LocalGitRepositoryTracker.cs | 69 +++++++++++++++---- 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/src/tooling/docs-builder/Cli/DiffCommands.cs b/src/tooling/docs-builder/Cli/DiffCommands.cs index 6e5f6aac3..5b5ac6fca 100644 --- a/src/tooling/docs-builder/Cli/DiffCommands.cs +++ b/src/tooling/docs-builder/Cli/DiffCommands.cs @@ -32,7 +32,7 @@ public async Task ValidateRedirects([Argument] string? path = null, Cancel ConsoleApp.Log = msg => log.LogInformation(msg); ConsoleApp.LogError = msg => log.LogError(msg); - path ??= "docs"; + path ??= ""; await using var collector = new ConsoleDiagnosticsCollector(logger, githubActionsService).StartAsync(ctx); @@ -57,8 +57,20 @@ public async Task ValidateRedirects([Argument] string? path = null, Cancel var tracker = new LocalGitRepositoryTracker(collector, root); var changed = tracker.GetChangedFiles(path); - foreach (var notFound in changed.Where(c => !redirects.ContainsKey(c))) - collector.EmitError(notFound, $"{notFound} does not have a redirect rule set. Please add a redirect rule in this project's {redirectFileInfo.Name}."); + foreach (var notFound in changed.Where(c => c.ChangeType is GitChangeType.Deleted or GitChangeType.Renamed + && !redirects.ContainsKey(c is RenamedGitChange renamed ? renamed.OldFilePath : c.FilePath))) + { + if (notFound is RenamedGitChange renamed) + { + collector.EmitError(redirectFileInfo.Name, + $"File '{renamed.OldFilePath}' was renamed to '{renamed.NewFilePath}' but it has no redirect configuration set."); + } + else if (notFound.ChangeType is GitChangeType.Deleted) + { + collector.EmitError(redirectFileInfo.Name, + $"File '{notFound.FilePath}' was deleted but it has no redirect targets. This will lead to broken links."); + } + } await collector.StopAsync(ctx); return collector.Errors; diff --git a/src/tooling/docs-builder/Tracking/IRepositoryTracker.cs b/src/tooling/docs-builder/Tracking/IRepositoryTracker.cs index f97a2b8e2..de01a1f01 100644 --- a/src/tooling/docs-builder/Tracking/IRepositoryTracker.cs +++ b/src/tooling/docs-builder/Tracking/IRepositoryTracker.cs @@ -4,7 +4,17 @@ namespace Documentation.Builder.Tracking; +public enum GitChangeType +{ + Added, + Modified, + Deleted, + Renamed, + Untracked, + Other +} + public interface IRepositoryTracker { - IEnumerable GetChangedFiles(string lookupPath); + IEnumerable GetChangedFiles(string lookupPath); } diff --git a/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs b/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs index 16d7aa3ea..48e981fe9 100644 --- a/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs +++ b/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs @@ -5,25 +5,21 @@ using System.IO.Abstractions; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Tooling.ExternalCommands; + namespace Documentation.Builder.Tracking; -public partial class LocalGitRepositoryTracker(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : ExternalCommandExecutor(collector, workingDirectory), IRepositoryTracker +public record GitChange(string FilePath, GitChangeType ChangeType); +public record RenamedGitChange(string OldFilePath, string NewFilePath, GitChangeType ChangeType) : GitChange(OldFilePath, ChangeType); + +public class LocalGitRepositoryTracker(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : ExternalCommandExecutor(collector, workingDirectory), IRepositoryTracker { - public IEnumerable GetChangedFiles(string lookupPath) + public IEnumerable GetChangedFiles(string lookupPath) { var defaultBranch = GetDefaultBranch(); var commitChanges = CaptureMultiple("git", "diff", "--name-status", $"{defaultBranch}...HEAD", "--", $"./{lookupPath}"); var localChanges = CaptureMultiple("git", "status", "--porcelain"); - List output = [ - .. commitChanges - .Where(line => line.StartsWith('R') || line.StartsWith('D') || line.StartsWith('A')) - .Select(line => line.Split('\t')[1]), - .. localChanges - .Select(x => x.TrimStart()) - .Where(line => line.StartsWith('R') || line.StartsWith('D') || line.StartsWith("A ", StringComparison.Ordinal) || line.StartsWith("??")) - .Select(line => line.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)[1]) - ]; - return output.Where(line => line.StartsWith(lookupPath)); + + return [.. GetCommitChanges(commitChanges), .. GetLocalChanges(localChanges)]; } private string GetDefaultBranch() @@ -34,4 +30,53 @@ private string GetDefaultBranch() return "master"; return Capture("git", "symbolic-ref", "refs/remotes/origin/HEAD").Split('/').Last(); } + + private static IEnumerable GetCommitChanges(string[] changes) + { + foreach (var change in changes) + { + var parts = change.AsSpan().TrimStart(); + if (parts.Length < 2) + continue; + + var changeType = parts[0] switch + { + 'A' => GitChangeType.Added, + 'M' => GitChangeType.Modified, + 'D' => GitChangeType.Deleted, + 'R' => GitChangeType.Renamed, + _ => GitChangeType.Other + }; + + yield return new GitChange(change.Split('\t')[1], changeType); + } + } + + private static IEnumerable GetLocalChanges(string[] changes) + { + foreach (var change in changes) + { + var changeStatusCode = change.AsSpan(); + if (changeStatusCode.Length < 2) + continue; + + var changeType = (changeStatusCode[0], changeStatusCode[1]) switch + { + ('R', _) or (_, 'R') => GitChangeType.Renamed, + ('D', _) or (_, 'D') when changeStatusCode[0] != 'A' => GitChangeType.Deleted, + ('?', '?') => GitChangeType.Untracked, + ('A', _) or (_, 'A') => GitChangeType.Added, + ('M', _) or (_, 'M') => GitChangeType.Modified, + _ => GitChangeType.Other + }; + + var changeParts = change.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + yield return changeType switch + { + GitChangeType.Renamed => new RenamedGitChange(changeParts[1], changeParts[3], changeType), + _ => new GitChange(changeParts[1], changeType) + }; + } + } } From dc3bbec55a898efd19bf174bc5f4cb76257e5dcd Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 12 Jun 2025 17:47:03 -0300 Subject: [PATCH 09/11] Revert default path change. --- src/tooling/docs-builder/Cli/DiffCommands.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tooling/docs-builder/Cli/DiffCommands.cs b/src/tooling/docs-builder/Cli/DiffCommands.cs index 5b5ac6fca..97ad2a50d 100644 --- a/src/tooling/docs-builder/Cli/DiffCommands.cs +++ b/src/tooling/docs-builder/Cli/DiffCommands.cs @@ -32,7 +32,7 @@ public async Task ValidateRedirects([Argument] string? path = null, Cancel ConsoleApp.Log = msg => log.LogInformation(msg); ConsoleApp.LogError = msg => log.LogError(msg); - path ??= ""; + path ??= "docs"; await using var collector = new ConsoleDiagnosticsCollector(logger, githubActionsService).StartAsync(ctx); From ab66a05f9b57db7f83b822f38e79546c0a463064 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 16 Jun 2025 04:27:31 -0300 Subject: [PATCH 10/11] Capture local unstaged changes --- src/tooling/docs-builder/Cli/DiffCommands.cs | 2 +- .../docs-builder/Tracking/LocalGitRepositoryTracker.cs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/tooling/docs-builder/Cli/DiffCommands.cs b/src/tooling/docs-builder/Cli/DiffCommands.cs index 97ad2a50d..8e5691aef 100644 --- a/src/tooling/docs-builder/Cli/DiffCommands.cs +++ b/src/tooling/docs-builder/Cli/DiffCommands.cs @@ -57,7 +57,7 @@ public async Task ValidateRedirects([Argument] string? path = null, Cancel var tracker = new LocalGitRepositoryTracker(collector, root); var changed = tracker.GetChangedFiles(path); - foreach (var notFound in changed.Where(c => c.ChangeType is GitChangeType.Deleted or GitChangeType.Renamed + foreach (var notFound in changed.DistinctBy(c => c.FilePath).Where(c => c.ChangeType is GitChangeType.Deleted or GitChangeType.Renamed && !redirects.ContainsKey(c is RenamedGitChange renamed ? renamed.OldFilePath : c.FilePath))) { if (notFound is RenamedGitChange renamed) diff --git a/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs b/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs index 48e981fe9..3315be46c 100644 --- a/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs +++ b/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs @@ -18,8 +18,11 @@ public IEnumerable GetChangedFiles(string lookupPath) var defaultBranch = GetDefaultBranch(); var commitChanges = CaptureMultiple("git", "diff", "--name-status", $"{defaultBranch}...HEAD", "--", $"./{lookupPath}"); var localChanges = CaptureMultiple("git", "status", "--porcelain"); + _ = Capture("git", "stash", "push", "--", $"./{lookupPath}"); + var localUnstagedChanges = CaptureMultiple("git", "stash", "show", "--name-status", "-u"); + _ = Capture("git", "stash", "pop"); - return [.. GetCommitChanges(commitChanges), .. GetLocalChanges(localChanges)]; + return [.. GetCommitChanges(commitChanges), .. GetLocalChanges(localChanges), .. GetCommitChanges(localUnstagedChanges)]; } private string GetDefaultBranch() From 6742b7abe0a3ddf4c3d1a8fdf74323151084f4e1 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 16 Jun 2025 07:16:00 -0300 Subject: [PATCH 11/11] Introduce silent execution method for ExternalCommandExecutor. --- .../ExternalCommands/ExternalCommandExecutor.cs | 13 +++++++++++++ .../Tracking/LocalGitRepositoryTracker.cs | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs index 8c940822f..363b60987 100644 --- a/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs +++ b/src/tooling/Elastic.Documentation.Tooling/ExternalCommands/ExternalCommandExecutor.cs @@ -24,6 +24,19 @@ protected void ExecIn(Dictionary environmentVars, string binary, collector.EmitError("", $"Exit code: {result} while executing {binary} {string.Join(" ", args)} in {workingDirectory}"); } + protected void ExecInSilent(Dictionary environmentVars, string binary, params string[] args) + { + var arguments = new StartArguments(binary, args) + { + Environment = environmentVars, + WorkingDirectory = workingDirectory.FullName, + ConsoleOutWriter = NoopConsoleWriter.Instance + }; + var result = Proc.Start(arguments); + if (result.ExitCode != 0) + collector.EmitError("", $"Exit code: {result.ExitCode} while executing {binary} {string.Join(" ", args)} in {workingDirectory}"); + } + protected string[] CaptureMultiple(string binary, params string[] args) { // Try 10 times to capture the output of the command, if it fails, we'll throw an exception on the last try diff --git a/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs b/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs index 3315be46c..f1280d13b 100644 --- a/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs +++ b/src/tooling/docs-builder/Tracking/LocalGitRepositoryTracker.cs @@ -18,9 +18,9 @@ public IEnumerable GetChangedFiles(string lookupPath) var defaultBranch = GetDefaultBranch(); var commitChanges = CaptureMultiple("git", "diff", "--name-status", $"{defaultBranch}...HEAD", "--", $"./{lookupPath}"); var localChanges = CaptureMultiple("git", "status", "--porcelain"); - _ = Capture("git", "stash", "push", "--", $"./{lookupPath}"); + ExecInSilent([], "git", "stash", "push", "--", $"./{lookupPath}"); var localUnstagedChanges = CaptureMultiple("git", "stash", "show", "--name-status", "-u"); - _ = Capture("git", "stash", "pop"); + ExecInSilent([], "git", "stash", "pop"); return [.. GetCommitChanges(commitChanges), .. GetLocalChanges(localChanges), .. GetCommitChanges(localUnstagedChanges)]; }