diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b694ab..81c787a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] - 2026-08-23 + +### Added + +- **The tool client works the way the SDK does.** `IDotNet`'s tool methods are one per `dotnet tool` verb — `ToolInstall`, `ToolUpdate`, `ToolUninstall`, and `InstalledToolVersion` for `list`. +- **The engine reads and writes project files.** `IProjectFiles` and `ProjectFile` load a `ritten.json` as a document, set values by key path, and write it back with everything else intact. +- **`Ritten.GitHub` maintains GitHub Actions workflows.** `IActionsWorkflows` and `ActionsWorkflow` read a workflow file, find its jobs by what they run, and write a job or a trigger back into it without disturbing a line of the rest. +- **`Ritten.DotNet` manages the tool manifest.** `IDotNet` gains `ToolUpdate` and `CreateToolManifest`, and `DotNetProjects` reads what a repository holds. +- **Jobs can declare that they run without a project.** `IJob.RequiresProject` is what lets a job create the project file. +- **Workflows can check compatibility.** `IWorkflow.IsCompatible` answers whether a directory looks like its kind, and `WorkflowRegistry.IsCompatible` asks each in registration order. +- **A file knows its directory, and a directory can place one.** `IFile.Directory` returns the directory a file is in, so anything writing a nested file can create the path first, and `IDirectory.RelativePath(file)`/`RelativePath(directory)` write a path the way a project file spells one. + +### Changed + +- **`ritten init` is now a job instead of a command.** It runs like every other job, with `--dry-run`, `--verbose` and the rest, and each workflow declares its own. A repository with no `ritten.json` yet has its workflow detected by what's in it, or `--workflow` sets one explicitly. +- **Resolving a repository is its own step.** `WorkflowApplication.SelectWorkflow` detects the workflow for a given directory. +- **Workflows can run without a project file.** `RittenProject.Resolve` answers with a synthetic project when nothing has been written yet. +- **Init ensures rather than scaffolds.** Every file it touches is loaded as a document, given whatever it's missing, and written back, so a changelog keeps its entries, a `ritten.json` keeps all its keys, a tool manifest keeps the other tools it pins, and an Actions workflow keeps its other jobs, triggers, and comments. +- **The Actions workflow is named for the project, and found by what it runs.** Ritten owns the jobs it wrote, not the file they live in: a renamed workflow file is updated in place rather than duplicated. + ## [0.9.0] - 2026-08-22 ### Added @@ -189,6 +209,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), Initial release. +[0.10.0]: https://github.com/ritten-org/Ritten/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/ritten-org/Ritten/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/ritten-org/Ritten/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/ritten-org/Ritten/compare/v0.6.0...v0.7.0 diff --git a/CLAUDE.md b/CLAUDE.md index f44333a..0928b1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,10 @@ Projects: `src/Ritten.Core` is the engine — package `Ritten.Core`, released in **Workflows and jobs are declarations, authored as object models; only steps are discovered by convention.** An `IWorkflow` (`Name` = the `ritten.json` identifier, `Label` = the printed human name) exposes stable job instances through its `Jobs` property. A job is a class extending `Job`, overriding `Name`, `Description` (the CLI is rendered from the model, so the help text lives with the job), `Steps` (an ordered list built with `Step.FromType()`, which throws for a malformed step type — that's a programming error, not configuration), and as needed `Arguments`/`Configure(builder, settings, args)` (see below) or `Configure(builder, settings)` (an `IWorkflowConfiguration` — just `Services` and the `Decorators` registry, which is all the domain `Add*` extensions need; both builders implement it, so domain registrations compose at run level or application level alike, and a job or runtime can never reach the run controls of the builder that owns it) and `ValidateSettings(settings)` — the latter takes a `SettingsValidator` offering `Require(s => s.Build.Project)` (the error's `ritten.json` key derives from the property chain) and `RequireEnvironment("VAR")` (dry runs warn instead of failing). Validation and registration are deferred until settings exist, so a job's shape cannot depend on any project's configuration, and the whole registry→workflow→job→step tree is data at startup. +**Resolution comes before the run, and a job that sets a repository up runs before there is one to resolve.** `WorkflowApplication.Resolve(directory, workflow?)` answers what a directory asks Ritten to be and returns a `WorkflowSelection` — the `RittenProject` (found, or `Synthetic` because nothing has been written yet), the `IWorkflow`, and how it was chosen; `Run(selection, args)` takes that, so `RunJobArgs` describes only the job. `RittenProject.Resolve` treats a missing file as a state and only a broken one as a failure, and `GetWorkflowName` says which of the two the matter is. The workflow comes from the project's `"workflow"` key; failing that from the name the caller gave (refused, never ignored, when the project declares a different one); failing that from the workflows themselves — `IWorkflow.Recognise(repository)` answers *why* a repository looks like its kind (`src/My.Tool/My.Tool.csproj packs as a tool`), `WorkflowRegistry.Recognise` asks each in registration order and the first to recognise it wins, so a host registers its most specific workflow first. `IJob.RequiresProject` (default true) is then judged of the chosen job: a job that needs a declaration and hasn't got one is refused with `WorkflowSelection.Undeclared`, while `init` proceeds on default settings that nothing validates — it is what makes them valid. A step confirms a *recognised* workflow with the person before writing it down. + +**Init ensures, it does not scaffold.** Each of its steps loads a file as a document, gives it whatever it lacks, and writes it back: `ritten.json` through the engine's `IProjectFiles`/`ProjectFile` (a `JsonNode` round-trip, so keys a newer tool wrote survive), `CHANGELOG.md` through `IChangelog`, the tool manifest through the SDK itself (`IDotNet.CreateToolManifest` then `ToolInstall`/`ToolUpdate` at `ToolScope.Local(root)` — the schema is Microsoft's, so a repository that pins other tools keeps them), and the GitHub Actions workflow through `Ritten.GitHub`'s `IActionsWorkflows`/`ActionsWorkflow`, which splices a job or a trigger into the document and leaves every other line — other jobs, comments, formatting — untouched. Nothing is compared byte for byte, so there is no drift to force: Ritten owns the jobs it wrote, found by *what they run* rather than by the file's name, and a repository of several projects gets one workflow file per project — named for the project it builds (the first declared, else the first found), never for the tool — each with its own working directory and concurrency group. + **A job declares what it can be asked for, the same way it declares what the repository tells it.** Settings come from `ritten.json`; *arguments* come from the invocation, and take the same path. `Arguments` lists `JobArgument` declarations — `JobArgument.Value(name, description, read)` carries a domain reader (`string → Result`, so a bad value is refused in the domain's own words) and `JobArgument.Flag(name, description)` is presence-only. `WorkflowApplication.Run` reads the supplied text into those types *before* anything is assembled, so an unknown name, an unreadable value, or a missing required one is a configuration error beside "unknown job" — never a step failure several steps in. The job then reads its values in `Configure(builder, settings, args)` through `args.Get(TheArgument)`/`args.IsSet(TheFlag)` — keyed by the declaration instance itself, so nothing is looked up by string or by type — and registers whatever domain value its steps consume (`RequestedVersion`, `ForceReinstall`). **Steps never see `JobArguments`, and never see a settings record either**: they depend on domain types, so a step stays reusable and the job stays free to source that value from anywhere. A scalar is never registered — `NuGetVersion` alone couldn't say whether it's the project's version, the feed's latest, or the one being asked for, and the type is what carries that. `Program.cs` follows the familiar .NET hosting shape: `WorkflowApplication.CreateBuilder()` returns a `WorkflowApplicationBuilder` exposing `Workflows`, `Runtimes`, and `Services` (registrations shared by every job of every workflow; the runtime's and the job's own land later, so the more specific wins), and `builder.Build()` judges the whole registered model (duplicate names and the structural job rules — every registered job, every run) as the first validation exit, narrating failures itself and returning `Result`. The commands themselves are rendered from the model, not hardcoded, and by a *package* rather than the engine: `Ritten.CommandLine` (`application.CreateCommandLine(description)`) calls `application.ResolveJobs(directory)` — which resolves `ritten.json` and returns that workflow's jobs, falling back to every registered job when no project resolves so a broken configuration can still ask for help — and turns each into a command carrying its `Description` and an option per declared argument. Each `JobArgument` maps through `JobArgument.Map` and an `IJobArgumentMapper`, which recovers the declaration's `T` so a `JobArgument` becomes an `Option` parsed by the domain's own reader; the engine references no CLI library, so System.CommandLine's churn stops at that package. A repository therefore only ever offers jobs its workflow can actually run. Each command then calls `application.Run(args, ct)` (`RunJobArgs` carries job/log-level/flags plus the read argument values — run-scoped things enter at `Run`, never through the builder; values are built through the declarations themselves, so one a job never declared can't be expressed, and the engine only checks that required ones are present). The engine's own flags are the ones true of every job — `--verbose`/`--quiet`, `--dry-run` (it drives the decorators) and `--auto-approve` (gate vocabulary), which is why they live on `WorkflowJob`; anything a single job honours is that job's argument instead (`--force` belongs to `install`). `Run` stages: detect the runtime and create the console narrative from it (`IWorkflowConsole` — the runtime's renderer at the flag-requested level, floored at Verbose when the runtime reports a debug request like `RUNNER_DEBUG`; errors before a runtime exists print through the engine's own Spectre renderer), resolve the project file (walking up from the cwd; `ritten.json` unless the host renames it via `builder.ProjectFileName`), select the workflow by the required `"workflow"` key, select the job *before* settings parse (a typo'd command needs no valid config to diagnose), then `WorkflowRunBuilder.Build(job)` assembles the one chosen job into a `WorkflowRun`: the job loads its settings as one operation (camelCase deserialization against the job's settings record — tolerant of unknown keys, so a project file can carry keys for newer tool versions without breaking a pinned one; every settings record extends `WorkflowSettings` — then `ValidateSettings` judges them, so invalid settings never leave the load), then services, steps, dry-run decoration, workflow-registered rules, and a DI container (`Microsoft.Extensions.DependencyInjection`, with `ValidateOnBuild`). `DefaultWorkflowRunner` executes the steps in order. @@ -62,7 +66,7 @@ Projects: `src/Ritten.Core` is the engine — package `Ritten.Core`, released in Each domain folder — `Changelogs/`, `CodeCoverage/`, `Commands/`, `DotNet/`, `Git/`, `GitHub/`, `NuGet/`, `Releases/`, `Reporting/` — owns its client interface, options, steps (in a `Steps/` subfolder), and a `WorkflowConfigurationExtensions.cs` registering them against `IWorkflowConfiguration` (services and dry-run decorators together). External processes (dotnet, git, gh) run through `Commands/ICommandRunner`. -`Workflows/DotNetTool/` (`"workflow": "dotnet-tool"`) holds the workflow and its four job classes (`status`, `build`, `check`, `deploy`), which share the standard service registrations through the workflow-local `DotNetToolJob` base; `Workflows/DotNetPackage/` (`"dotnet-package"`) is its deliberately-identical sibling for library packages — flat siblings, duplicated declarations, no sharing *across* workflows. `Workflows/DotNetToolSettings.cs` and its siblings define the `ritten.json` schema. +`Workflows/DotNetTool/` (`"workflow": "dotnet-tool"`) holds the workflow and its job classes (`init`, `status`, `build`, `install`, `prepare`, `check`, `deploy`), which share the standard service registrations through the workflow-local `DotNetToolJob` base (`init` registers only what it needs, since it runs before there are settings to register from); `Workflows/DotNetPackage/` (`"dotnet-package"`) is its deliberately-identical sibling for library packages — flat siblings, duplicated declarations, no sharing *across* workflows. `Workflows/DotNetToolSettings.cs` and its siblings define the `ritten.json` schema. **Reporting is two channels:** `IWorkflowLog` is the console narrative (rendered by `SpectreProgressReporter`), while `IBuildReport` accumulates a markdown report that `GitHubCommentSink` posts as the PR comment. Check steps typically write to both. diff --git a/Directory.Build.props b/Directory.Build.props index 7af147a..6540020 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Copyright © 2026 Tom Wolfe - 0.9.0 + 0.10.0 $(Version.Split('-')[0]) $(Version.Split('-')[0]) icon.png diff --git a/Directory.Packages.props b/Directory.Packages.props index 723c3a7..ddc28b9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,5 +24,6 @@ + \ No newline at end of file diff --git a/src/Ritten.CommandLine/CommandExtensions.cs b/src/Ritten.CommandLine/CommandExtensions.cs index ad7ade7..6e63c88 100644 --- a/src/Ritten.CommandLine/CommandExtensions.cs +++ b/src/Ritten.CommandLine/CommandExtensions.cs @@ -32,6 +32,14 @@ public async Task InstallRitten(WorkflowApplication application, CancellationTok } } + /// + /// The option that names a workflow, for jobs that run without one. + /// + private static Option WorkflowOption() => new($"--{WorkflowArguments.Workflow}") + { + Description = "The workflow to run. Recognised from what's in the project when omitted." + }; + /// /// Builds the command for a single job. /// @@ -44,7 +52,14 @@ private static Command JobCommand(IJob job, WorkflowFlags flags, WorkflowApplica command.Options.Add(argument.Option); } - command.SetAction(async (parseResult, cancellationToken) => + // A job that runs without a project has no predetermined workflow. + var workflow = job.RequiresProject ? null : WorkflowOption(); + if (workflow is not null) + { + command.Options.Add(workflow); + } + + command.SetAction(async (parseResult, ct) => { var builder = new JobArgumentsBuilder(parseResult); foreach (var argument in arguments) @@ -61,7 +76,13 @@ private static Command JobCommand(IJob job, WorkflowFlags flags, WorkflowApplica Arguments = jobArgs }; - return await application.Run(args, cancellationToken); + var selection = await application.SelectWorkflow( + Environment.CurrentDirectory, + workflow is null ? null : parseResult.GetValue(workflow), + ct + ); + + return await application.Run(selection, args, ct); }); return command; diff --git a/src/Ritten.Core/Contracts/FileSystem/DirectoryExtensions.cs b/src/Ritten.Core/Contracts/FileSystem/DirectoryExtensions.cs new file mode 100644 index 0000000..7274630 --- /dev/null +++ b/src/Ritten.Core/Contracts/FileSystem/DirectoryExtensions.cs @@ -0,0 +1,25 @@ +namespace Ritten.Contracts.FileSystem; + +/// +/// Contains extension methods for . +/// +public static class DirectoryExtensions +{ + extension(IDirectory directory) + { + /// + /// The path of the given file relative to this directory. + /// + /// The file to write the path of. + public string RelativePath(IFile file) => Relative(directory, file.AbsolutePath); + + /// + /// The path of the given directory relative to this one. + /// + /// The directory to write the path of. + public string RelativePath(IDirectory other) => Relative(directory, other.AbsolutePath); + } + + private static string Relative(IDirectory directory, string path) => + Path.GetRelativePath(directory.AbsolutePath, path).Replace(Path.DirectorySeparatorChar, '/'); +} diff --git a/src/Ritten.Core/Contracts/FileSystem/IFile.cs b/src/Ritten.Core/Contracts/FileSystem/IFile.cs index 9e3897d..14561b6 100644 --- a/src/Ritten.Core/Contracts/FileSystem/IFile.cs +++ b/src/Ritten.Core/Contracts/FileSystem/IFile.cs @@ -30,6 +30,11 @@ public interface IFile /// bool Exists { get; } + /// + /// Gets the directory the file is in, whether or not either exists yet. + /// + IDirectory Directory { get; } + /// /// Deletes the file from the file system if it exists. /// diff --git a/src/Ritten.Core/Engine/DryRunProjectFiles.cs b/src/Ritten.Core/Engine/DryRunProjectFiles.cs new file mode 100644 index 0000000..5937aba --- /dev/null +++ b/src/Ritten.Core/Engine/DryRunProjectFiles.cs @@ -0,0 +1,28 @@ +using Ritten.Contracts.FileSystem; +using Ritten.Reporting; + +namespace Ritten.Engine; + +/// +/// Reports what the project file would say instead of writing it. +/// +internal sealed class DryRunProjectFiles(IWorkflowLog log, IProjectFiles inner) : IProjectFiles +{ + /// + public Task> Read(IFile file, CancellationToken cancellationToken = default) => + inner.Read(file, cancellationToken); + + /// + public Task Write(IFile file, ProjectFile document, CancellationToken cancellationToken = default) + { + log.Skipped($"Would write {file.Name}:"); + log.Verbose(inner.Render(document)); + return Task.CompletedTask; + } + + /// + public Result Parse(string json) => inner.Parse(json); + + /// + public string Render(ProjectFile document) => inner.Render(document); +} diff --git a/src/Ritten.Core/Engine/FileSystem/PhysicalFile.cs b/src/Ritten.Core/Engine/FileSystem/PhysicalFile.cs index 5f241d0..42e3bd0 100644 --- a/src/Ritten.Core/Engine/FileSystem/PhysicalFile.cs +++ b/src/Ritten.Core/Engine/FileSystem/PhysicalFile.cs @@ -25,6 +25,9 @@ public class PhysicalFile(string path) : IFile /// public bool Exists => File.Exists(AbsolutePath); + /// + public IDirectory Directory => new PhysicalDirectory(Path.GetDirectoryName(AbsolutePath) ?? AbsolutePath); + /// public void Delete() { diff --git a/src/Ritten.Core/Engine/IProjectFiles.cs b/src/Ritten.Core/Engine/IProjectFiles.cs new file mode 100644 index 0000000..b2fda46 --- /dev/null +++ b/src/Ritten.Core/Engine/IProjectFiles.cs @@ -0,0 +1,37 @@ +using Ritten.Contracts.FileSystem; + +namespace Ritten.Engine; + +/// +/// Reads and writes project files as documents. +/// +public interface IProjectFiles +{ + /// + /// Reads the project file, failing rather than throwing when it isn't JSON. + /// A file that isn't there reads as an empty document. + /// + /// The file to read. + /// A token to monitor for cancellation requests. + Task> Read(IFile file, CancellationToken cancellationToken = default); + + /// + /// Writes the document to the given file, replacing its contents. + /// + /// The file to write. + /// The document to write. + /// A token to monitor for cancellation requests. + Task Write(IFile file, ProjectFile document, CancellationToken cancellationToken = default); + + /// + /// Parses the given project file. + /// + /// The document to parse. + Result Parse(string json); + + /// + /// Renders the given document as it would be written. + /// + /// The document to render. + string Render(ProjectFile document); +} diff --git a/src/Ritten.Core/Engine/ProjectFile.cs b/src/Ritten.Core/Engine/ProjectFile.cs new file mode 100644 index 0000000..03c70c2 --- /dev/null +++ b/src/Ritten.Core/Engine/ProjectFile.cs @@ -0,0 +1,100 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Ritten.Engine; + +/// +/// A Ritten project file as a document rather than as settings. +/// +public sealed class ProjectFile +{ + private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true }; + + private readonly JsonObject _root; + + internal ProjectFile(JsonObject root) => _root = root; + + /// + /// The default project file. + /// + public static ProjectFile Empty => new([]); + + /// + /// The workflow the project declares, or null when it declares none. + /// + public string? Workflow + { + get => _root["workflow"]?.GetValue(); + set + { + if (_root.ContainsKey("workflow")) + { + _root["workflow"] = value; + return; + } + + _root.Insert(0, "workflow", value); + } + } + + /// + /// Whether the document already says something at the given key, e.g. build.projects. + /// + /// The dotted key path to look for. + public bool Has(string key) => Find(key) is not null; + + /// + /// Sets the value at the given dotted key path, creating the objects along the way. + /// + /// The dotted key path to write, e.g. build.project. + /// The value to write. + public ProjectFile Set(string key, string value) => Set(key, JsonValue.Create(value)); + + /// + /// Sets the list at the given dotted key path, creating the objects along the way. + /// + /// The dotted key path to write, e.g. build.projects. + /// The values to write. + public ProjectFile Set(string key, IEnumerable values) => + Set(key, new JsonArray([.. values.Select(v => (JsonNode)JsonValue.Create(v))])); + + /// + /// Renders the document as it would be written. + /// + public override string ToString() => _root.ToJsonString(Indented) + "\n"; + + private ProjectFile Set(string key, JsonNode? value) + { + var path = key.Split('.'); + var parent = _root; + foreach (var segment in path[..^1]) + { + if (parent[segment] is not JsonObject child) + { + child = []; + parent[segment] = child; + } + + parent = child; + } + + parent[path[^1]] = value; + return this; + } + + private JsonNode? Find(string key) + { + JsonNode? node = _root; + foreach (var segment in key.Split('.')) + { + if (node is not JsonObject parent) + { + return null; + } + + node = parent[segment]; + } + + return node; + } +} diff --git a/src/Ritten.Core/Engine/ProjectFileClient.cs b/src/Ritten.Core/Engine/ProjectFileClient.cs new file mode 100644 index 0000000..ffd30cf --- /dev/null +++ b/src/Ritten.Core/Engine/ProjectFileClient.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Ritten.Contracts.FileSystem; + +namespace Ritten.Engine; + +/// +/// Reads and writes project files tolerantly. +/// +internal sealed class ProjectFileClient : IProjectFiles +{ + private static readonly JsonNodeOptions NodeOptions = new() { PropertyNameCaseInsensitive = false }; + + private static readonly JsonDocumentOptions DocumentOptions = new() + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + /// + public async Task> Read(IFile file, CancellationToken cancellationToken = default) + { + if (!file.Exists) + { + return ProjectFile.Empty; + } + + using var reader = new StreamReader(file.OpenRead()); + var document = Parse(await reader.ReadToEndAsync(cancellationToken)); + return document.IsError + ? new Result([Result.Error($"Could not read '{file.Name}': {document.Errors.First().Message}")]) + : document; + } + + /// + public async Task Write(IFile file, ProjectFile document, CancellationToken cancellationToken = default) + { + var stream = file.OpenWrite(); + stream.SetLength(0); // OpenWrite isn't guaranteed to truncate an existing file. + await using var writer = new StreamWriter(stream); + await writer.WriteAsync(Render(document).AsMemory(), cancellationToken); + } + + /// + public Result Parse(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return ProjectFile.Empty; + } + + try + { + // Anything but an object — a list, a number — is a project file nobody could have + // meant, so it's refused rather than quietly replaced. + return JsonNode.Parse(json, NodeOptions, DocumentOptions) is JsonObject root + ? new ProjectFile(root) + : Result.Error("it isn't an object."); + } + catch (JsonException exception) + { + return Result.Error(exception.Message, exception); + } + } + + /// + public string Render(ProjectFile document) => document.ToString(); +} diff --git a/src/Ritten.Core/Engine/RittenProject.cs b/src/Ritten.Core/Engine/RittenProject.cs index b13381b..4ccbaeb 100644 --- a/src/Ritten.Core/Engine/RittenProject.cs +++ b/src/Ritten.Core/Engine/RittenProject.cs @@ -5,7 +5,7 @@ namespace Ritten.Engine; /// /// A located Ritten build project: where it is, and the settings it declares. /// -internal sealed class RittenProject +public sealed class RittenProject { /// /// The configuration file that marks the root of a project, unless the host renames it. @@ -33,16 +33,41 @@ internal sealed class RittenProject /// internal JsonElement Settings { get; init; } + /// + /// Whether the project file was created in memory rather than loaded from a file. + /// + public bool IsSynthetic { get; private init; } + /// /// The path of the project file, for error messages. /// public string FilePath => Path.Combine(Directory, FileName); /// - /// Reads which workflow the settings declare. + /// The project a repository would have, for a directory that hasn't got one yet. + /// + /// The directory the project file would live in. + /// The name of the file that marks a project's root. + public static RittenProject Synthetic(string directory, string fileName) => new() + { + Directory = Path.GetFullPath(directory), + FileName = fileName, + IsSynthetic = true, + + // An empty document, so every setting reads as its default rather than failing to read. + Settings = JsonDocument.Parse("{}").RootElement + }; + + /// + /// Reads which workflow the settings declare. s /// public Result GetWorkflowName() { + if (IsSynthetic) + { + return Result.Error($"No {FileName} found in '{Directory}' or any parent directory."); + } + if (Settings.TryGetProperty("workflow", out var workflowProp)) { var workflow = workflowProp.GetString(); @@ -55,12 +80,13 @@ public Result GetWorkflowName() } /// - /// Walks up from the given directory looking for a project. + /// Walks up from the given directory looking for a project. A directory + /// with no project file resolves to a one. /// /// The directory to start from, usually the working directory. /// The name of the file that marks a project's root. /// Cancellation token. - public static async Task> Resolve(string directory, string fileName, CancellationToken ct) + internal static async Task> Resolve(string directory, string fileName, CancellationToken ct) { var current = new DirectoryInfo(Path.GetFullPath(directory)); while (current is not null) @@ -84,6 +110,6 @@ public static async Task> Resolve(string directory, string current = current.Parent; } - return Result.Error($"No {fileName} found in '{Path.GetFullPath(directory)}' or any parent directory."); + return Synthetic(directory, fileName); } } diff --git a/src/Ritten.Core/Engine/RunJobArgs.cs b/src/Ritten.Core/Engine/RunJobArgs.cs index 754fdab..8cd1f7a 100644 --- a/src/Ritten.Core/Engine/RunJobArgs.cs +++ b/src/Ritten.Core/Engine/RunJobArgs.cs @@ -4,7 +4,7 @@ namespace Ritten.Engine; /// -/// What the command line asked for: the job to run, and how to run it. +/// What the command line asked of a job. /// /// The name of the job to run. public sealed record RunJobArgs(string Job) @@ -14,11 +14,6 @@ public sealed record RunJobArgs(string Job) /// public WorkflowLogLevel LogLevel { get; init; } = WorkflowLogLevel.Detail; - /// - /// The directory on the file system in which to run the job. - /// - public string Directory { get; init; } = Environment.CurrentDirectory; - /// /// Rehearses the job without doing anything that reaches outside the working directory. /// diff --git a/src/Ritten.Core/Engine/Runs/WorkflowRunBuilder.cs b/src/Ritten.Core/Engine/Runs/WorkflowRunBuilder.cs index 3bc7b8d..7a6fe27 100644 --- a/src/Ritten.Core/Engine/Runs/WorkflowRunBuilder.cs +++ b/src/Ritten.Core/Engine/Runs/WorkflowRunBuilder.cs @@ -18,7 +18,7 @@ public class WorkflowRunBuilder : IWorkflowBuilder { private readonly RittenProject _project; private readonly DetectRuntimeResult _runtime; - private string _workflowLabel = ""; + private SelectedWorkflow? _workflow; private bool _dryRun; private bool _autoApprove; private JobArguments _arguments = JobArguments.None; @@ -42,6 +42,8 @@ internal WorkflowRunBuilder(RittenProject project, DetectRuntimeResult runtime, Services.AddSingleton(TimeProvider.System); Services.TryAddSingleton(); Services.TryAddSingleton(); + Services.TryAddSingleton(); + Decorators.Decorate(); Services.AddSingleton(console); Services.TryAddSingleton(_ => new ConsolePrompt(AnsiConsole.Console)); } @@ -57,12 +59,12 @@ internal WorkflowRunBuilder(RittenProject project, DetectRuntimeResult runtime, public DecoratorRegistry Decorators { get; } = new(); /// - /// Names the workflow the job belongs to, for the run's narrative. + /// Sets the workflow the job belongs to. /// - /// The human label of the workflow being assembled. - public WorkflowRunBuilder WithWorkflowLabel(string label) + /// The workflow being assembled. + public WorkflowRunBuilder WithWorkflow(SelectedWorkflow workflow) { - _workflowLabel = label; + _workflow = workflow; return this; } @@ -140,7 +142,8 @@ public Result Build(IJob job) } Services.AddSingleton(new WorkflowEnvironment(_runtime.Environment)); - Services.AddSingleton(new WorkflowJob(_workflowLabel, job.Name, _dryRun, _autoApprove)); + Services.AddSingleton(_workflow ?? throw new InvalidOperationException("The run has no workflow; call WithWorkflow first.")); + Services.AddSingleton(new WorkflowJob(_workflow.Workflow.Label, job.Name, _dryRun, _autoApprove)); _runtime.Runtime.Configure(this, _runtime.Raw); job.Configure(this, settings.Value, _arguments); diff --git a/src/Ritten.Core/Engine/WorkflowApplication.cs b/src/Ritten.Core/Engine/WorkflowApplication.cs index f3d639d..447e982 100644 --- a/src/Ritten.Core/Engine/WorkflowApplication.cs +++ b/src/Ritten.Core/Engine/WorkflowApplication.cs @@ -1,11 +1,11 @@ using Microsoft.Extensions.DependencyInjection; using Ritten.Contracts; using Ritten.Engine.DryRun; +using Ritten.Engine.FileSystem; using Ritten.Engine.Runs; using Ritten.Engine.Runtimes; using Ritten.Engine.Workflows; using Ritten.Reporting; -using Spectre.Console; namespace Ritten.Engine; @@ -41,18 +41,77 @@ string projectFileName public static WorkflowApplicationBuilder CreateBuilder() => new(); /// - /// Runs the requested job of whichever registered workflow the resolved project declares, - /// returning its exit code. + /// Resolves what the given directory asks Ritten to be: the project file it declares — or + /// hasn't written yet — and the workflow that follows from it. Done before any job is chosen, + /// since which jobs there are to choose from is the answer. /// - /// What the command line asked for. + /// The directory the tool was invoked in. + /// + /// The workflow to run, for a repository whose project file doesn't declare one. Refused when + /// the project declares a different one, so a name given is never quietly discarded. + /// /// Cancellation token. - public Task Run(RunJobArgs args, CancellationToken ct) => - Run(args, Environment.GetEnvironmentVariable, ct); + public async Task> SelectWorkflow(string directory, string? workflow = null, CancellationToken ct = default) + { + var known = Result.Error($"Known workflows: {string.Join(", ", _workflows.Names)}."); + + var resolved = await RittenProject.Resolve(directory, _projectFileName, ct); + if (resolved.IsError) + { + return new Result(resolved.Errors); + } + + var project = resolved.Value; + var declared = project.GetWorkflowName(); + if (declared.IsSuccess) + { + if (_workflows.Find(declared.Value) is not { } workflow2) + { + return new Result([ + Result.Error($"'{project.FilePath}' declares the unknown workflow '{declared.Value}'."), + known + ]); + } + + // Error if manually specified workflow clashes with project workflow. + return workflow is { Length: > 0 } named && !string.Equals(named, workflow2.Name, StringComparison.OrdinalIgnoreCase) + ? new Result([ + Result.Error($"'{project.FilePath}' declares the {workflow2.Label} workflow, so '{named}' can't be run here."), + Result.Error("Change the \"workflow\" key to run a different one.") + ]) + : new SelectedWorkflow(workflow2, project); + } + + // No project file declared in the directory, check the user args. + var undeclared = declared.Errors.First(); + if (workflow is { Length: > 0 } name) + { + return _workflows.Find(name) is { } named + ? new SelectedWorkflow(named, project) { MissingProjectReason = undeclared } + : new Result([Result.Error($"There is no workflow named '{name}'."), known]); + } + + if (await _workflows.IsCompatible(new PhysicalDirectory(directory), ct) is { } recognised) + { + return new SelectedWorkflow(recognised.Workflow, project, recognised.Reason) { MissingProjectReason = undeclared }; + } + + return new Result([undeclared, known]); + } /// - /// Runs the requested job against the given directory and environment. + /// Runs the requested job of the resolved workflow. /// - internal async Task Run(RunJobArgs args, Func environment, CancellationToken ct) + /// What made of the directory. + /// What the command line asked of the job. + /// Cancellation token. + public Task Run(Result workflow, RunJobArgs args, CancellationToken ct) => + Run(workflow, args, Environment.GetEnvironmentVariable, ct); + + /// + /// Runs the requested job against the given environment. + /// + internal async Task Run(Result workflow, RunJobArgs args, Func environment, CancellationToken ct) { var runtime = _runtimes.Detect(environment); if (runtime.IsError) @@ -63,54 +122,45 @@ internal async Task Run(RunJobArgs args, Func environ // From now on, we have a fancy forge-specific logger that can handle error reporting and escaping and stuff. var console = runtime.Value.CreateConsole(args.LogLevel); - - var project = await RittenProject.Resolve(args.Directory, _projectFileName, ct); - if (project.IsError) - { - return ConfigurationError(console, project.Errors); - } - - var knownWorkflows = Result.Error($"Known workflows: {string.Join(", ", _workflows.Names)}."); - var name = project.Value.GetWorkflowName(); - if (name.IsError) + if (workflow.IsError) { - return ConfigurationError(console, [.. name.Errors, knownWorkflows]); + return ConfigurationError(console, workflow.Errors); } - if (_workflows.Find(name.Value) is not { } workflow) - { - return ConfigurationError(console, [Result.Error($"'{project.Value.FilePath}' declares the unknown workflow '{name.Value}'."), knownWorkflows]); - } + var resolved = workflow.Value; - // The job is picked out of the static model before settings are parsed: a typo'd - // command shouldn't need a valid configuration to be diagnosed. - var jobs = workflow.Jobs; - var declared = jobs.FirstOrDefault(j => j.Name == args.Job); - if (declared is null) + // Make sure the job is valid. + var jobs = resolved.Workflow.Jobs; + var job = jobs.FirstOrDefault(j => j.Name == args.Job); + if (job is null) { return ConfigurationError(console, [ - Result.Error($"The {workflow.Label} workflow has no job named '{args.Job}'."), + Result.Error($"The {resolved.Workflow.Label} workflow has no job named '{args.Job}'."), Result.Error($"Known jobs: {string.Join(", ", jobs.Select(j => j.Name))}.") ]); } - // Judged before anything is assembled, so a job that wasn't given what it needs says so - // beside "unknown job" rather than failing several steps in. Nothing checks the names: - // a value can only be supplied through a declaration, so a stray one can't be expressed. - if (Missing(declared, args.Arguments) is { Count: > 0 } missing) + // Check the project file exists if it's required. + if (job.RequiresProject && resolved.MissingProjectReason is { } noProject) + { + return ConfigurationError(console, [noProject]); + } + + // Check for any missing arguments. + if (Missing(job, args.Arguments) is { Count: > 0 } missing) { return ConfigurationError(console, missing); } - var builder = new WorkflowRunBuilder(project.Value, runtime.Value, console) - .WithWorkflowLabel(workflow.Label) + var builder = new WorkflowRunBuilder(resolved.Project, runtime.Value, console) + .WithWorkflow(resolved) .WithDryRun(args.DryRun) .WithAutoApprove(args.AutoApprove) .WithArguments(args.Arguments) .WithServices(_services) .WithDecorators(_decorators); - var run = builder.Build(declared); + var run = builder.Build(job); if (run.IsError) { return ConfigurationError(console, run.Errors); diff --git a/src/Ritten.Core/Engine/WorkflowArguments.cs b/src/Ritten.Core/Engine/WorkflowArguments.cs index 71b88c7..175044c 100644 --- a/src/Ritten.Core/Engine/WorkflowArguments.cs +++ b/src/Ritten.Core/Engine/WorkflowArguments.cs @@ -24,4 +24,9 @@ public static class WorkflowArguments /// Shows only failures. /// public const string Quiet = "quiet"; + + /// + /// Names the workflow to run. Offered by jobs that run without a project. + /// + public const string Workflow = "workflow"; } diff --git a/src/Ritten.Core/Engine/Workflows/CompatibleWorkflow.cs b/src/Ritten.Core/Engine/Workflows/CompatibleWorkflow.cs new file mode 100644 index 0000000..97c6260 --- /dev/null +++ b/src/Ritten.Core/Engine/Workflows/CompatibleWorkflow.cs @@ -0,0 +1,8 @@ +namespace Ritten.Engine.Workflows; + +/// +/// A workflow that has validated a directory as being compatible with itself. +/// +/// The workflow that recognized the repository. +/// Why it did, phrased to be read by whoever is being told what will happen. +public sealed record CompatibleWorkflow(IWorkflow Workflow, string Reason); diff --git a/src/Ritten.Core/Engine/Workflows/IJob.cs b/src/Ritten.Core/Engine/Workflows/IJob.cs index 51a9971..49882c1 100644 --- a/src/Ritten.Core/Engine/Workflows/IJob.cs +++ b/src/Ritten.Core/Engine/Workflows/IJob.cs @@ -33,6 +33,11 @@ public interface IJob /// IReadOnlyList Arguments => []; + /// + /// Whether the job needs a project file to run. + /// + bool RequiresProject => true; + /// /// Reads the given project's settings as this job's settings type. /// diff --git a/src/Ritten.Core/Engine/Workflows/IWorkflow.cs b/src/Ritten.Core/Engine/Workflows/IWorkflow.cs index efd4dc5..907c090 100644 --- a/src/Ritten.Core/Engine/Workflows/IWorkflow.cs +++ b/src/Ritten.Core/Engine/Workflows/IWorkflow.cs @@ -1,3 +1,5 @@ +using Ritten.Contracts.FileSystem; + namespace Ritten.Engine.Workflows; /// @@ -19,4 +21,12 @@ public interface IWorkflow /// The workflow's jobs. /// IReadOnlyList Jobs { get; } + + /// + /// Works out if this workflow is compatible with the project in the given directory. + /// + /// The directory being set up. + /// A token to monitor for cancellation requests. + /// The reason for claim compatibility, or null. + Task IsCompatible(IDirectory directory, CancellationToken cancellationToken = default) => Task.FromResult(null); } diff --git a/src/Ritten.Core/Engine/Workflows/Job.cs b/src/Ritten.Core/Engine/Workflows/Job.cs index fb3b9f0..33f142b 100644 --- a/src/Ritten.Core/Engine/Workflows/Job.cs +++ b/src/Ritten.Core/Engine/Workflows/Job.cs @@ -24,6 +24,9 @@ public abstract class Job : IJob where TSettings : WorkflowSettings /// public virtual IReadOnlyList Arguments => []; + /// + public virtual bool RequiresProject => true; + /// /// Registers the services the job's steps need. /// @@ -58,6 +61,12 @@ Result IJob.ReadSettings(RittenProject project, Func(settings.Errors); } + // There is nothing to judge in settings nobody has written yet. + if (project.IsSynthetic) + { + return settings.Value; + } + var validator = new SettingsValidator(settings.Value, environment, dryRun, log, project.FileName); ValidateSettings(validator); return validator.Errors.Count > 0 diff --git a/src/Ritten.Core/Engine/Workflows/SelectedWorkflow.cs b/src/Ritten.Core/Engine/Workflows/SelectedWorkflow.cs new file mode 100644 index 0000000..e1bcb2c --- /dev/null +++ b/src/Ritten.Core/Engine/Workflows/SelectedWorkflow.cs @@ -0,0 +1,15 @@ +namespace Ritten.Engine.Workflows; + +/// +/// The workflow selected for a project directory. +/// +/// The workflow the run is of. +/// The project file the workflow was chosen for. +/// Why this workflow was chosen. +public sealed record SelectedWorkflow(IWorkflow Workflow, RittenProject Project, string? Recognised = null) +{ + /// + /// Why the project declares no workflow. + /// + internal Error? MissingProjectReason { get; init; } +} diff --git a/src/Ritten.Core/Engine/Workflows/WorkflowRegistry.cs b/src/Ritten.Core/Engine/Workflows/WorkflowRegistry.cs index 04e5080..d5a7faa 100644 --- a/src/Ritten.Core/Engine/Workflows/WorkflowRegistry.cs +++ b/src/Ritten.Core/Engine/Workflows/WorkflowRegistry.cs @@ -1,4 +1,5 @@ using Ritten.Contracts; +using Ritten.Contracts.FileSystem; using Ritten.Engine.Rules; namespace Ritten.Engine.Workflows; @@ -51,6 +52,24 @@ public WorkflowRegistry Add(IWorkflow workflow) public IWorkflow? Find(string name) => _workflows .FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + /// + /// Asks each workflow whether it recognizes the given repository. + /// + /// The repository being set up. + /// A token to monitor for cancellation requests. + public async Task IsCompatible(IDirectory directory, CancellationToken cancellationToken = default) + { + foreach (var workflow in _workflows) + { + if (await workflow.IsCompatible(directory, cancellationToken) is { Length: > 0 } reason) + { + return new CompatibleWorkflow(workflow, reason); + } + } + + return null; + } + /// /// Validates the entire registered workflow model. /// diff --git a/src/Ritten.DotNet/DiscoveredProjects.cs b/src/Ritten.DotNet/DiscoveredProjects.cs new file mode 100644 index 0000000..91fafb2 --- /dev/null +++ b/src/Ritten.DotNet/DiscoveredProjects.cs @@ -0,0 +1,8 @@ +namespace Ritten.DotNet; + +/// +/// The C# projects found in the repository. +/// +/// The projects that aren't tests, in path order, relative to the project root. +/// The test projects, in path order, relative to the project root. +public sealed record DiscoveredProjects(IReadOnlyList Shipped, IReadOnlyList Tests); diff --git a/src/Ritten.DotNet/DotNetClient.cs b/src/Ritten.DotNet/DotNetClient.cs index a52102c..a071d0b 100644 --- a/src/Ritten.DotNet/DotNetClient.cs +++ b/src/Ritten.DotNet/DotNetClient.cs @@ -82,11 +82,16 @@ public async Task> ReadProject(IFile file, CancellationToken can }; } - public async Task InstalledToolVersion(string packageId, CancellationToken cancellationToken = default) + public async Task InstalledToolVersion(string packageId, ToolScope scope, CancellationToken cancellationToken = default) { - // A probe whose output the caller consumes, not part of the step's story. - var command = Command.Create("dotnet").WithArguments("tool", "list", "--global").QuietOutput().ThrowOnError(); + // A probe whose output the caller consumes, not part of the step's story. A directory no + // manifest governs is a question, not a failure: the answer is that nothing carries it. + var command = Tool(scope).WithArguments("tool", "list", scope.Flag).QuietOutput(); var result = await commands.Run(command, cancellationToken); + if (!result.IsSuccess) + { + return null; + } // The table's first two lines are the header and its underline, and ids print lowercased. foreach (var line in result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries).Skip(2)) @@ -101,27 +106,60 @@ public async Task> ReadProject(IFile file, CancellationToken can return null; } - public async Task ToolInstall(ToolInstallArgs args, CancellationToken cancellationToken = default) + public Task ToolInstall(ToolInstallArgs args, CancellationToken cancellationToken = default) => + RunTool("install", args, cancellationToken); + + public Task ToolUpdate(ToolInstallArgs args, CancellationToken cancellationToken = default) => + RunTool("update", args, cancellationToken); + + public async Task ToolUninstall(string packageId, ToolScope scope, CancellationToken cancellationToken = default) { - // --source rather than --add-source: the artifacts directory replaces every configured - // feed, so a published package with the same version can't shadow the build being installed. - var command = Command - .Create("dotnet") - .WithArguments( - "tool", "install", args.PackageId, - "--global", - "--version", args.Version.ToString(), - "--source", args.Source.AbsolutePath) - .ThrowOnError(); + var command = Tool(scope).WithArguments("tool", "uninstall", packageId, scope.Flag).ThrowOnError(); await commands.Run(command, cancellationToken); } - public async Task ToolUninstall(string packageId, CancellationToken cancellationToken = default) + public async Task CreateToolManifest(IDirectory directory, CancellationToken cancellationToken = default) { - var command = Command.Create("dotnet").WithArguments("tool", "uninstall", packageId, "--global").ThrowOnError(); + // The SDK reads a manifest from either the directory itself or its .config, and the + // template's own default has moved between versions. Naming the conventional one keeps + // every repository Ritten sets up looking the same. + var command = Command.Create("dotnet") + .WithArguments("new", "tool-manifest", "--output", DotNetProjects.ToolManifestDirectory) + .InDirectory(directory.AbsolutePath) + .QuietOutput() + .ThrowOnError(); await commands.Run(command, cancellationToken); } + /// + /// One dotnet tool verb, spelled the way the SDK spells it. + /// + private async Task RunTool(string verb, ToolInstallArgs args, CancellationToken cancellationToken) + { + var command = Tool(args.Scope).WithArguments("tool", verb, args.PackageId, args.Scope.Flag); + if (args.Version is { } version) + { + command = command.AndArguments("--version", version.ToString()); + } + + if (args.Source is { } source) + { + // --source rather than --add-source: the given directory replaces every configured + // feed, so a published package of the same version can't shadow the one asked for. + command = command.AndArguments("--source", source.AbsolutePath); + } + + await commands.Run(command.QuietOutput().ThrowOnError(), cancellationToken); + } + + /// + /// A tool command runs where its scope resolves the manifest from; a global one runs wherever + /// the workflow is. + /// + private static Command Tool(ToolScope scope) => scope.Directory is { } directory + ? Command.Create("dotnet").InDirectory(directory.AbsolutePath) + : Command.Create("dotnet"); + private static string? Property(JsonElement properties, string name) => properties.TryGetProperty(name, out var value) && value.GetString() is { Length: > 0 } text ? text : null; diff --git a/src/Ritten.DotNet/DotNetProjects.cs b/src/Ritten.DotNet/DotNetProjects.cs new file mode 100644 index 0000000..d98bc01 --- /dev/null +++ b/src/Ritten.DotNet/DotNetProjects.cs @@ -0,0 +1,89 @@ +using Ritten.Contracts.FileSystem; + +namespace Ritten.DotNet; + +/// +/// What a repository holds, read from the files themselves rather than from any configuration. +/// +public static class DotNetProjects +{ + /// + /// The shared build properties every project in a repository inherits. + /// + private const string SharedProperties = "Directory.Build.props"; + + /// + /// The directory a repository keeps its tool manifest in, by dotnet's convention. + /// + public const string ToolManifestDirectory = ".config"; + + /// + /// The two names the SDK will read a directory's tool manifest under. The conventional one + /// is first, which is also the one a new manifest is written to. + /// + private static readonly string[] ToolManifestNames = [$"{ToolManifestDirectory}/dotnet-tools.json", "dotnet-tools.json"]; + + /// + /// The repository's tool manifest, wherever the SDK would read it from, or null when the + /// repository has none of its own. + /// + /// The directory the manifest belongs in. + public static IFile? ToolManifest(IDirectory root) => + ToolManifestNames.Select(root.GetFile).FirstOrDefault(file => file.Exists); + + /// + /// Every project in the repository, in path order and without the build output's copies. + /// + /// The directory to look under. + public static IEnumerable Projects(IDirectory root) => root + .GetFiles("**/*.csproj") + .Where(file => !IsBuildOutput(file)) + .OrderBy(file => file.AbsolutePath, StringComparer.Ordinal); + + /// + /// Whether the project is a test project, by two conventions: what it's called, and where it lives. + /// + /// The project to judge. + public static bool IsTests(IFile project) => + project.NameWithoutExtension.EndsWith("Tests", StringComparison.OrdinalIgnoreCase) + || Segments(project).Contains("tests", StringComparer.OrdinalIgnoreCase); + + /// + /// The first file in the repository that declares the given MSBuild property. + /// + /// The directory to look under. + /// The literal element to look for, e.g. <PackAsTool>true</PackAsTool>. + /// A token to monitor for cancellation requests. + public static async Task FileContainingMsBuildElement(IDirectory root, string element, CancellationToken cancellationToken = default) + { + var shared = root.GetFile(SharedProperties); + foreach (var file in shared.Exists ? [shared, .. Projects(root)] : Projects(root)) + { + if (await Declares(file, element, cancellationToken)) + { + return file; + } + } + + return null; + } + + /// + /// Whether the given file declares the given MSBuild property. + /// + /// The file to read. + /// The literal element to look for. + /// A token to monitor for cancellation requests. + public static async Task Declares(IFile file, string element, CancellationToken cancellationToken = default) + { + using var reader = new StreamReader(file.OpenRead()); + var content = await reader.ReadToEndAsync(cancellationToken); + return content.Contains(element, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsBuildOutput(IFile file) => + Segments(file).Any(segment => segment is "bin" or "obj"); + + private static string[] Segments(IFile file) => + file.AbsolutePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); +} diff --git a/src/Ritten.DotNet/DryRunDotNet.cs b/src/Ritten.DotNet/DryRunDotNet.cs index 48b0f48..dd3c2cf 100644 --- a/src/Ritten.DotNet/DryRunDotNet.cs +++ b/src/Ritten.DotNet/DryRunDotNet.cs @@ -52,20 +52,39 @@ public IReadOnlyList ParseDiagnostics(string buildOutput) => inner.ParseDiagnostics(buildOutput); /// - public Task InstalledToolVersion(string packageId, CancellationToken cancellationToken = default) => - inner.InstalledToolVersion(packageId, cancellationToken); + public Task InstalledToolVersion(string packageId, ToolScope scope, CancellationToken cancellationToken = default) => + inner.InstalledToolVersion(packageId, scope, cancellationToken); /// public Task ToolInstall(ToolInstallArgs args, CancellationToken cancellationToken = default) { - log.Skipped($"Would install {args.PackageId} {args.Version} globally from {args.Source.Name}."); + log.Skipped($"Would install {Named(args)} {Where(args.Scope)}."); return Task.CompletedTask; } /// - public Task ToolUninstall(string packageId, CancellationToken cancellationToken = default) + public Task ToolUpdate(ToolInstallArgs args, CancellationToken cancellationToken = default) { - log.Skipped($"Would uninstall {packageId}."); + log.Skipped($"Would update {Named(args)} {Where(args.Scope)}."); return Task.CompletedTask; } + + /// + public Task ToolUninstall(string packageId, ToolScope scope, CancellationToken cancellationToken = default) + { + log.Skipped($"Would uninstall {packageId} {Where(scope)}."); + return Task.CompletedTask; + } + + /// + public Task CreateToolManifest(IDirectory directory, CancellationToken cancellationToken = default) + { + log.Skipped($"Would create a tool manifest in {directory.Name}."); + return Task.CompletedTask; + } + + private static string Named(ToolInstallArgs args) => + args.Version is { } version ? $"{args.PackageId} {version}" : args.PackageId; + + private static string Where(ToolScope scope) => scope.IsGlobal ? "globally" : "in the tool manifest"; } diff --git a/src/Ritten.DotNet/IDotNet.cs b/src/Ritten.DotNet/IDotNet.cs index 80adc6e..0db4b1f 100644 --- a/src/Ritten.DotNet/IDotNet.cs +++ b/src/Ritten.DotNet/IDotNet.cs @@ -55,19 +55,41 @@ public interface IDotNet Task ReadTestResults(IFile file, CancellationToken cancellationToken = default); /// - /// Reads the version of the given tool installed globally, or null when it isn't installed. + /// Runs dotnet tool list and reads the version of the given tool, or null when it isn't installed. /// - Task InstalledToolVersion(string packageId, CancellationToken cancellationToken = default); + /// The package ID of the tool to look for. + /// Whether to ask the machine or a repository's manifest. + /// A token to monitor for cancellation requests. + Task InstalledToolVersion(string packageId, ToolScope scope, CancellationToken cancellationToken = default); /// - /// Runs dotnet tool install --global against the given source alone, throwing a on failure. + /// Runs dotnet tool install. /// + /// What to install, and where. + /// A token to monitor for cancellation requests. Task ToolInstall(ToolInstallArgs args, CancellationToken cancellationToken = default); /// - /// Runs dotnet tool uninstall --global, throwing a on failure. + /// Runs dotnet tool update. /// - Task ToolUninstall(string packageId, CancellationToken cancellationToken = default); + /// What to move, and where. + /// A token to monitor for cancellation requests. + Task ToolUpdate(ToolInstallArgs args, CancellationToken cancellationToken = default); + + /// + /// Runs dotnet tool uninstall. + /// + /// The package ID of the tool to remove. + /// Whether to remove it from the machine or from a repository's manifest. + /// A token to monitor for cancellation requests. + Task ToolUninstall(string packageId, ToolScope scope, CancellationToken cancellationToken = default); + + /// + /// Runs dotnet new tool-manifest in the given directory. + /// + /// The directory to create the manifest in. + /// A token to monitor for cancellation requests. + Task CreateToolManifest(IDirectory directory, CancellationToken cancellationToken = default); /// /// Extracts the compiler and MSBuild diagnostics from dotnet build output. diff --git a/src/Ritten.DotNet/Steps/DotnetToolInstall.cs b/src/Ritten.DotNet/Steps/DotnetToolInstall.cs index d607886..e7f3f6d 100644 --- a/src/Ritten.DotNet/Steps/DotnetToolInstall.cs +++ b/src/Ritten.DotNet/Steps/DotnetToolInstall.cs @@ -38,7 +38,7 @@ public async Task Run(PackageSet packages, PackResult packed, Cancel return StepResult.Failed($"{tool.Name} {tool.Version} was not packed; expected {package} in the artifacts."); } - var current = await dotnet.InstalledToolVersion(tool.Name, cancellationToken); + var current = await dotnet.InstalledToolVersion(tool.Name, ToolScope.Global, cancellationToken); if (current == tool.Version && !force.Requested) { log.Skipped($"{tool.Name} {tool.Version} is already installed; pass --{ToolArguments.Reinstall.Name} to reinstall this build."); @@ -49,11 +49,17 @@ public async Task Run(PackageSet packages, PackResult packed, Cancel { // `dotnet tool install` refuses while any version is installed, so replacing — // same version or not — starts by removing the old install. - await dotnet.ToolUninstall(tool.Name, cancellationToken); + await dotnet.ToolUninstall(tool.Name, ToolScope.Global, cancellationToken); } await dotnet.ToolInstall( - new ToolInstallArgs { PackageId = tool.Name, Version = tool.Version, Source = fileSystem.Artifacts }, + new ToolInstallArgs + { + PackageId = tool.Name, + Scope = ToolScope.Global, + Version = tool.Version, + Source = fileSystem.Artifacts + }, cancellationToken); installed++; diff --git a/src/Ritten.DotNet/Steps/FindProjects.cs b/src/Ritten.DotNet/Steps/FindProjects.cs new file mode 100644 index 0000000..9e5289d --- /dev/null +++ b/src/Ritten.DotNet/Steps/FindProjects.cs @@ -0,0 +1,41 @@ +using Ritten.Contracts; +using Ritten.Contracts.FileSystem; +using Ritten.Reporting; + +namespace Ritten.DotNet.Steps; + +/// +/// Finds what the repository builds, for the jobs that run before anything says. +/// +/// The workflow log. +/// The file system. +[Step("find projects", StepKind.Work)] +public class FindProjects(IWorkflowLog log, IFileSystem fileSystem) +{ + /// + /// Reads the repository's projects off the disk. + /// + public StepResult Run() + { + var root = fileSystem.ProjectRoot; + var projects = DotNetProjects.Projects(root).ToList(); + var found = new DiscoveredProjects( + [.. projects.Where(p => !DotNetProjects.IsTests(p)).Select(root.RelativePath)], + [.. projects.Where(DotNetProjects.IsTests).Select(root.RelativePath)] + ); + + log.Detail(found switch + { + { Shipped.Count: 0, Tests.Count: 0 } => "No projects here yet.", + { Tests.Count: 0 } => $"Found {found.Shipped.Count} project(s).", + _ => $"Found {found.Shipped.Count} project(s) and {found.Tests.Count} test project(s)." + }); + + foreach (var project in found.Shipped) + { + log.Verbose(project); + } + + return found; + } +} diff --git a/src/Ritten.DotNet/ToolInstallArgs.cs b/src/Ritten.DotNet/ToolInstallArgs.cs index 38d5e53..dcb062e 100644 --- a/src/Ritten.DotNet/ToolInstallArgs.cs +++ b/src/Ritten.DotNet/ToolInstallArgs.cs @@ -4,22 +4,27 @@ namespace Ritten.DotNet; /// -/// The arguments for dotnet tool install. +/// The arguments for dotnet tool install and dotnet tool update. /// public record ToolInstallArgs { /// - /// The package ID of the tool to install. + /// The package ID of the tool. /// public required string PackageId { get; init; } /// - /// The exact version to install. + /// Whether the tool belongs to the machine or to a repository's manifest. /// - public required NuGetVersion Version { get; init; } + public required ToolScope Scope { get; init; } /// - /// The directory holding the packed tool, used as the only package source. + /// The exact version, or null for whatever the feed's latest is. /// - public required IDirectory Source { get; init; } + public NuGetVersion? Version { get; init; } + + /// + /// The directory holding the packed tool, if it shouldn't be installed from the feed. + /// + public IDirectory? Source { get; init; } } diff --git a/src/Ritten.DotNet/ToolScope.cs b/src/Ritten.DotNet/ToolScope.cs new file mode 100644 index 0000000..794e828 --- /dev/null +++ b/src/Ritten.DotNet/ToolScope.cs @@ -0,0 +1,37 @@ +using Ritten.Contracts.FileSystem; + +namespace Ritten.DotNet; + +/// +/// Where a tool command applies: the machine's own tools, or the manifest governing a directory. +/// +public sealed record ToolScope +{ + private ToolScope(IDirectory? directory) => Directory = directory; + + /// + /// The machine's tools, installed for the user. + /// + public static ToolScope Global { get; } = new((IDirectory?)null); + + /// + /// The tools pinned by the manifest governing the given directory. + /// + /// The directory the manifest is resolved from. + public static ToolScope Local(IDirectory directory) => new(directory); + + /// + /// The directory a local command runs in, or null for the machine's own tools. + /// + public IDirectory? Directory { get; } + + /// + /// Whether this is the machine's own tools rather than a repository's. + /// + public bool IsGlobal => Directory is null; + + /// + /// The flag the SDK spells this scope with. + /// + internal string Flag => IsGlobal ? "--global" : "--local"; +} diff --git a/src/Ritten.GitHub/ActionsJob.cs b/src/Ritten.GitHub/ActionsJob.cs new file mode 100644 index 0000000..96515a8 --- /dev/null +++ b/src/Ritten.GitHub/ActionsJob.cs @@ -0,0 +1,15 @@ +namespace Ritten.GitHub; + +/// +/// One job of a GitHub Actions workflow: as much of it as anything needs to recognise its own. +/// +/// The job's id. +/// The job's steps that run a command, in order. +public sealed record ActionsJob(string Id, IReadOnlyList Steps) +{ + /// + /// Whether any of the job's steps run the given command. + /// + /// The command to look for, e.g. dotnet ritten check. + public bool Invokes(string command) => Steps.Any(step => step.Run.Contains(command, StringComparison.Ordinal)); +} diff --git a/src/Ritten.GitHub/ActionsStep.cs b/src/Ritten.GitHub/ActionsStep.cs new file mode 100644 index 0000000..f369196 --- /dev/null +++ b/src/Ritten.GitHub/ActionsStep.cs @@ -0,0 +1,8 @@ +namespace Ritten.GitHub; + +/// +/// One step of a GitHub Actions job that runs a command, and where it runs it. +/// +/// The script the step runs. +/// The directory the step runs in, when it sets one of its own. +public sealed record ActionsStep(string Run, string? WorkingDirectory); diff --git a/src/Ritten.GitHub/ActionsWorkflow.cs b/src/Ritten.GitHub/ActionsWorkflow.cs new file mode 100644 index 0000000..5b1f468 --- /dev/null +++ b/src/Ritten.GitHub/ActionsWorkflow.cs @@ -0,0 +1,262 @@ +using YamlDotNet.RepresentationModel; + +namespace Ritten.GitHub; + +/// +/// A GitHub Actions workflow file. +/// +public sealed class ActionsWorkflow +{ + private readonly string[] _lines; + private readonly string _newline; + + private ActionsWorkflow(string text) + { + Text = text; + _newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + _lines = [.. text.Split('\n').Select(line => line.TrimEnd('\r'))]; + + var root = Root(text); + Name = Scalar(root, "name"); + WorkingDirectory = WorkingDirectoryOf(root); + Triggers = [.. Keys(Value(root, "on") as YamlMappingNode)]; + Jobs = [.. JobsOf(Value(root, "jobs") as YamlMappingNode)]; + } + + /// + /// The document as it stands. + /// + public string Text { get; } + + /// + /// The workflow's name, as the Actions tab lists it. + /// + public string? Name { get; } + + /// + /// The directory every run step defaults to, when the workflow sets one. + /// + public string? WorkingDirectory { get; } + + /// + /// The events the workflow triggers on. + /// + public IReadOnlyCollection Triggers { get; } + + /// + /// The workflow's jobs, in the order they're declared. + /// + public IReadOnlyList Jobs { get; } + + /// + /// Parses the given workflow document. + /// + /// The document to parse. + /// The document isn't YAML. + public static ActionsWorkflow Parse(string text) => new(text); + + /// + /// Returns the workflow with the given job saying what the given block says, replacing the + /// job of that id when it has one and adding it at the end of the jobs when it hasn't. + /// + /// The id of the job to write. + /// The job as YAML, indented as it will appear under jobs. + public ActionsWorkflow WithJob(string id, string block) => + FindKey(id, under: "jobs") is { } key ? Replace(Block(key), block) : Append(block, under: "jobs", separate: true); + + /// + /// Returns the workflow triggering on the given event, leaving the event's own configuration + /// alone when it already triggers on it — a repository that narrowed its branches meant to. + /// + /// The event's name, e.g. pull_request. + /// The event as YAML, indented as it will appear under on. + public ActionsWorkflow WithTrigger(string trigger, string block) => + Triggers.Contains(trigger) ? this : Append(block, under: "on", separate: false); + + /// + public override string ToString() => Text; + + /// + /// Replaces the given span of lines with the given block. + /// + private ActionsWorkflow Replace((int Start, int End) span, string block) + { + List lines = [.. _lines[..span.Start], .. Lines(block), .. _lines[span.End..]]; + return new ActionsWorkflow(string.Join(_newline, lines)); + } + + /// + /// Adds the given block at the end of the given top-level mapping, or at the end of the + /// document when the mapping isn't there at all. Jobs read better a blank line apart and + /// triggers read better together, so the caller says which it is adding. + /// + private ActionsWorkflow Append(string block, string under, bool separate) + { + if (FindKey(under, under: null) is not { } key) + { + List lines = [.. TrimEnd(_lines), "", $"{under}:", .. Lines(block)]; + return new ActionsWorkflow(string.Join(_newline, lines) + _newline); + } + + var mapping = Block(key); + var (from, to, _) = Entries(mapping); + List before = [.. TrimEnd(_lines[..mapping.End])]; + List after = [.. _lines[mapping.End..]]; + + // There's nothing to separate the first entry from. + List separator = separate && from < to ? [""] : []; + return new ActionsWorkflow(string.Join(_newline, [.. before, .. separator, .. Lines(block), .. after])); + } + + /// + /// Where the given key's line is, and how deeply it's indented. A key is only ever looked for + /// among its siblings — the entries of the document, or the entries of one of its mappings — + /// so a job called check is never confused for a step of one. + /// + private (int Line, int Indent)? FindKey(string name, string? under) + { + var (from, to, depth) = under is null + ? (0, _lines.Length, 0) + : FindKey(under, under: null) is { } parent + ? Entries(Block(parent)) + : (0, 0, 0); + + for (var line = from; line < to; line++) + { + if (Indent(_lines[line]) == depth && Names(_lines[line].TrimStart(), name)) + { + return (line, depth); + } + } + + return null; + } + + /// + /// The lines a mapping's own entries live on, and the indentation they share. YAML lets a + /// document choose its own, so it's read from the first entry rather than assumed. + /// + private (int From, int To, int Indent) Entries((int Start, int End) block) + { + for (var line = block.Start + 1; line < block.End; line++) + { + if (Indent(_lines[line]) is { } indent) + { + return (block.Start + 1, block.End, indent); + } + } + + return (block.End, block.End, 0); + } + + /// + /// Whether the line declares the given key, quoted or not. + /// + private static bool Names(string trimmed, string key) => + trimmed.StartsWith($"{key}:", StringComparison.Ordinal) + || trimmed.StartsWith($"\"{key}\":", StringComparison.Ordinal) + || trimmed.StartsWith($"'{key}':", StringComparison.Ordinal); + + /// + /// The lines a key owns: its own, and everything indented under it. + /// + private (int Start, int End) Block((int Line, int Indent) key) + { + var end = key.Line + 1; + var last = end; + while (end < _lines.Length) + { + if (Indent(_lines[end]) is { } indent) + { + if (indent <= key.Indent) + { + break; + } + + // Blank lines and comments inside a block belong to it; ones trailing it don't. + last = end + 1; + } + + end++; + } + + return (key.Line, last); + } + + /// + /// How deeply the line is indented, or null for a line that holds nothing. + /// + private static int? Indent(string line) => + string.IsNullOrWhiteSpace(line) ? null : line.Length - line.TrimStart().Length; + + private static IEnumerable Lines(string block) => + block.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd('\n').Split('\n'); + + private static IEnumerable TrimEnd(IEnumerable lines) + { + var list = lines.ToList(); + while (list.Count > 0 && string.IsNullOrWhiteSpace(list[^1])) + { + list.RemoveAt(list.Count - 1); + } + + return list; + } + + private static YamlMappingNode? Root(string text) + { + var stream = new YamlStream(); + stream.Load(new StringReader(text)); + return stream.Documents.Count > 0 ? stream.Documents[0].RootNode as YamlMappingNode : null; + } + + private static YamlNode? Value(YamlMappingNode? mapping, string key) => mapping?.Children + .FirstOrDefault(child => child.Key is YamlScalarNode { Value: { } name } && name == key) + .Value; + + private static string? Scalar(YamlMappingNode? mapping, string key) => (Value(mapping, key) as YamlScalarNode)?.Value; + + private static IEnumerable Keys(YamlMappingNode? mapping) => mapping is null + ? [] + : mapping.Children.Keys.OfType().Select(key => key.Value).OfType(); + + /// + /// The workflow-wide defaults.run.working-directory, which is where its jobs run. + /// + private static string? WorkingDirectoryOf(YamlMappingNode? root) => + Scalar(Value(Value(root, "defaults") as YamlMappingNode, "run") as YamlMappingNode, "working-directory"); + + private static IEnumerable JobsOf(YamlMappingNode? jobs) + { + if (jobs is null) + { + yield break; + } + + foreach (var (key, value) in jobs.Children) + { + if (key is not YamlScalarNode { Value: { } id }) + { + continue; + } + + yield return new ActionsJob(id, [.. StepsOf(value as YamlMappingNode)]); + } + } + + private static IEnumerable StepsOf(YamlMappingNode? job) + { + if (Value(job, "steps") is not YamlSequenceNode steps) + { + yield break; + } + + foreach (var step in steps.OfType()) + { + if (Scalar(step, "run") is { } run) + { + yield return new ActionsStep(run, Scalar(step, "working-directory")); + } + } + } +} diff --git a/src/Ritten.GitHub/ActionsWorkflowClient.cs b/src/Ritten.GitHub/ActionsWorkflowClient.cs new file mode 100644 index 0000000..b0adaa2 --- /dev/null +++ b/src/Ritten.GitHub/ActionsWorkflowClient.cs @@ -0,0 +1,60 @@ +using Ritten.Contracts.FileSystem; +using Ritten.Engine; +using YamlDotNet.Core; + +namespace Ritten.GitHub; + +/// +/// Reads and writes workflow files where GitHub Actions keeps them. +/// +internal sealed class ActionsWorkflowClient : IActionsWorkflows +{ + /// + /// Where GitHub Actions reads workflows from, by GitHub's convention. + /// + private const string WorkflowDirectory = ".github/workflows"; + + /// + public IEnumerable Files(IDirectory repository) + { + var directory = repository.GetDirectory(WorkflowDirectory); + return directory.Exists ? [.. directory.GetFiles("*.yml"), .. directory.GetFiles("*.yaml")] : []; + } + + /// + public IFile File(IDirectory repository, string name) => repository.GetFile($"{WorkflowDirectory}/{name}.yml"); + + /// + public async Task> Read(IFile file, CancellationToken cancellationToken = default) + { + using var reader = new StreamReader(file.OpenRead()); + return Parse(await reader.ReadToEndAsync(cancellationToken)); + } + + /// + public async Task Write(IFile file, ActionsWorkflow workflow, CancellationToken cancellationToken = default) + { + file.Directory.Create(); + + var stream = file.OpenWrite(); + stream.SetLength(0); // OpenWrite isn't guaranteed to truncate an existing file. + await using var writer = new StreamWriter(stream); + await writer.WriteAsync(Render(workflow).AsMemory(), cancellationToken); + } + + /// + public Result Parse(string yaml) + { + try + { + return ActionsWorkflow.Parse(yaml); + } + catch (YamlException exception) + { + return Result.Error($"Could not read the workflow: {exception.Message}", exception); + } + } + + /// + public string Render(ActionsWorkflow workflow) => workflow.Text; +} diff --git a/src/Ritten.GitHub/DryRunActionsWorkflows.cs b/src/Ritten.GitHub/DryRunActionsWorkflows.cs new file mode 100644 index 0000000..d82fcda --- /dev/null +++ b/src/Ritten.GitHub/DryRunActionsWorkflows.cs @@ -0,0 +1,36 @@ +using Ritten.Contracts.FileSystem; +using Ritten.Engine; +using Ritten.Reporting; + +namespace Ritten.GitHub; + +/// +/// Reports what a workflow file would say instead of writing it. Reading and parsing pass +/// through, so a rehearsal narrates the document it would have written in full. +/// +internal sealed class DryRunActionsWorkflows(IWorkflowLog log, IActionsWorkflows inner) : IActionsWorkflows +{ + /// + public IEnumerable Files(IDirectory repository) => inner.Files(repository); + + /// + public IFile File(IDirectory repository, string name) => inner.File(repository, name); + + /// + public Task> Read(IFile file, CancellationToken cancellationToken = default) => + inner.Read(file, cancellationToken); + + /// + public Task Write(IFile file, ActionsWorkflow workflow, CancellationToken cancellationToken = default) + { + log.Skipped($"Would write {file.Name}:"); + log.Verbose(inner.Render(workflow)); + return Task.CompletedTask; + } + + /// + public Result Parse(string yaml) => inner.Parse(yaml); + + /// + public string Render(ActionsWorkflow workflow) => inner.Render(workflow); +} diff --git a/src/Ritten.GitHub/IActionsWorkflows.cs b/src/Ritten.GitHub/IActionsWorkflows.cs new file mode 100644 index 0000000..f9bc49d --- /dev/null +++ b/src/Ritten.GitHub/IActionsWorkflows.cs @@ -0,0 +1,50 @@ +using Ritten.Contracts.FileSystem; +using Ritten.Engine; + +namespace Ritten.GitHub; + +/// +/// Reads and writes the GitHub Actions workflows of a repository. +/// +public interface IActionsWorkflows +{ + /// + /// The repository's workflow files, wherever GitHub looks for them. + /// + /// The root of the repository, which is the only place GitHub reads. + IEnumerable Files(IDirectory repository); + + /// + /// The file a workflow of the given name belongs in, whether or not it exists yet. + /// + /// The root of the repository. + /// The file's name, without an extension. + IFile File(IDirectory repository, string name); + + /// + /// Reads the given workflow file, failing rather than throwing when it isn't YAML. + /// + /// The file to read. + /// A token to monitor for cancellation requests. + Task> Read(IFile file, CancellationToken cancellationToken = default); + + /// + /// Writes the workflow to the given file, replacing its contents. + /// + /// The file to write. + /// The workflow to write. + /// A token to monitor for cancellation requests. + Task Write(IFile file, ActionsWorkflow workflow, CancellationToken cancellationToken = default); + + /// + /// Parses the given workflow document. + /// + /// The document to parse. + Result Parse(string yaml); + + /// + /// Renders the given workflow as it would be written. + /// + /// The workflow to render. + string Render(ActionsWorkflow workflow); +} diff --git a/src/Ritten.GitHub/Ritten.GitHub.csproj b/src/Ritten.GitHub/Ritten.GitHub.csproj index 10b1109..d424094 100644 --- a/src/Ritten.GitHub/Ritten.GitHub.csproj +++ b/src/Ritten.GitHub/Ritten.GitHub.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Ritten.GitHub/WorkflowBuilderExtensions.cs b/src/Ritten.GitHub/WorkflowBuilderExtensions.cs index a14595a..3d6c923 100644 --- a/src/Ritten.GitHub/WorkflowBuilderExtensions.cs +++ b/src/Ritten.GitHub/WorkflowBuilderExtensions.cs @@ -47,6 +47,16 @@ public IWorkflowBuilder AddGitHubClient(string? clientName = null) return builder; } + + /// + /// Adds the client the workflow maintains its GitHub Actions workflow files with. + /// + public IWorkflowBuilder AddGitHubActions() + { + builder.Services.TryAddSingleton(); + builder.Decorators.Decorate(); + return builder; + } } /// diff --git a/src/Ritten/Changelogs/DryRunChangelog.cs b/src/Ritten/Changelogs/DryRunChangelog.cs index 9b805ed..e7df5f2 100644 --- a/src/Ritten/Changelogs/DryRunChangelog.cs +++ b/src/Ritten/Changelogs/DryRunChangelog.cs @@ -20,14 +20,14 @@ public Task ReadEntry(IFile file, CancellationToken cancellation /// public Task Write(IFile file, Changelog changelog, CancellationToken cancellationToken = default) { - log.Skipped($"Would rewrite {file.Name}."); + log.Skipped($"Would write {file.Name}."); return Task.CompletedTask; } /// public Task WriteEntry(IFile file, ChangelogEntry entry, CancellationToken cancellationToken = default) { - log.Skipped($"Would rewrite {file.Name}."); + log.Skipped($"Would write {file.Name}."); return Task.CompletedTask; } diff --git a/src/Ritten/Init/ActionsWorkflowTemplate.cs b/src/Ritten/Init/ActionsWorkflowTemplate.cs new file mode 100644 index 0000000..50bf55c --- /dev/null +++ b/src/Ritten/Init/ActionsWorkflowTemplate.cs @@ -0,0 +1,167 @@ +using Ritten.Contracts; +using Ritten.Engine.Workflows; + +namespace Ritten.Init; + +/// +/// What a repository's GitHub Actions workflow says about the jobs Ritten runs. +/// +internal static class ActionsWorkflowTemplate +{ + /// + /// The jobs worth putting in CI: what guards a change, and what ships one. The rest are run + /// by hand, and scaffolding them would be noise. + /// + public static IEnumerable Automated(IWorkflow workflow) => + workflow.Jobs.Where(job => job.Kind is JobKind.Check or JobKind.Deploy); + + /// + /// An empty workflow document, for a repository that hasn't got one to add to. + /// + /// The workflow's name, as the Actions tab will list it. + public static string Document(string name) => $"name: {name}\n\non:\n\njobs:\n"; + + /// + /// The events a job of the given kind runs on, as the entries they appear as under on. + /// + /// The job the triggers are for. + public static IEnumerable<(string Name, string Block)> Triggers(IJob job) => job.Kind switch + { + // A release is asked for, never triggered by a change. + JobKind.Deploy => + [ + ("workflow_dispatch", + """ + workflow_dispatch: + inputs: + dry-run: + description: 'Dry Run' + type: boolean + default: false + """) + ], + + // A check guards the change that triggered it. + JobKind.Check => + [ + ("pull_request", + """ + pull_request: + branches: [ main ] + """), + ("push", + """ + push: + branches: [ main ] + """) + ], + _ => [] + }; + + /// + /// The job, as it appears under jobs. + /// + /// The job to render. + /// The tool the job runs. + /// The project's directory, when it isn't the repository's root. + /// The SDK version file, when the repository has one. + public static string Job(IJob job, ToolPin tool, string? directory, string? globalJson) => job.Kind switch + { + JobKind.Deploy => Deploy(job, tool, directory, globalJson), + _ => Check(job, tool, directory, globalJson) + }; + + /// + /// A job that guards every change: it runs on the change, and a newer push supersedes it. + /// + private static string Check(IJob job, ToolPin tool, string? directory, string? globalJson) => Lines([ + $" {job.Name}:", + $" name: {Title(job.Name)}", + " runs-on: ubuntu-latest", + " if: github.event_name == 'pull_request' || github.event_name == 'push'", + " permissions:", + " contents: read", + " # The report is posted as a pull request comment.", + " pull-requests: write", + " concurrency:", + " # A newer push to the same branch supersedes any run still going. The workflow's own", + " # name keeps one project's runs from cancelling another's.", + $" group: ${{{{ github.workflow }}}}-{job.Name}-${{{{ github.ref }}}}", + " cancel-in-progress: true", + " steps:", + .. Setup(directory, globalJson), + "", + $" - name: Run {job.Name}", + $" run: dotnet {tool.Command} {job.Name}", + " shell: bash", + .. In(directory), + " env:", + " GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}" + ]); + + /// + /// A job that releases: asked for deliberately, queued rather than cancelled, and approved up + /// front because there's nobody at the terminal to confirm. + /// + private static string Deploy(IJob job, ToolPin tool, string? directory, string? globalJson) => Lines([ + $" {job.Name}:", + $" name: {Title(job.Name)}", + " runs-on: ubuntu-latest", + " if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'", + " permissions:", + " # Tags and releases are written back to the repository.", + " contents: write", + " id-token: write", + " concurrency:", + " # Releases queue rather than interleave.", + $" group: ${{{{ github.workflow }}}}-{job.Name}", + " cancel-in-progress: false", + " steps:", + .. Setup(directory, globalJson), + "", + " - name: NuGet login", + " uses: NuGet/login@v1", + " id: nuget-login", + " with:", + " user: ${{ secrets.NUGET_USER }}", + "", + $" - name: Run {job.Name}", + $" run: dotnet {tool.Command} {job.Name} --auto-approve ${{{{ inputs['dry-run'] && '--dry-run' || '' }}}}", + " shell: bash", + .. In(directory), + " env:", + " RITTEN_NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }}", + " RITTEN_COMMIT_SHA: ${{ github.sha }}", + " GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}" + ]); + + /// + /// Checking out, and getting the tool that runs the job. The SDK version is whatever + /// global.json pins, so the workflow never names one — and says nothing at all when + /// the repository doesn't pin one, rather than pointing at a file that isn't there. + /// + private static IEnumerable Setup(string? directory, string? globalJson) => + [ + " - name: Checkout repository", + " uses: actions/checkout@v7", + "", + " - name: Set up .NET", + " uses: actions/setup-dotnet@v6", + .. globalJson is null ? Array.Empty() : [" with:", $" global-json-file: {globalJson}"], + "", + " - name: Restore tools", + " run: dotnet tool restore", + .. In(directory) + ]; + + /// + /// Where a step runs, for a project that isn't the repository. Everything Ritten needs is + /// found by walking up from here: the project file, the tool manifest, the repository itself. + /// + private static IEnumerable In(string? directory) => + directory is null ? [] : [$" working-directory: {directory}"]; + + private static string Lines(IEnumerable lines) => string.Join('\n', lines); + + private static string Title(string name) => string.Concat(char.ToUpperInvariant(name[0]), name[1..]); +} diff --git a/src/Ritten/Init/InitCommand.cs b/src/Ritten/Init/InitCommand.cs deleted file mode 100644 index 4968ebb..0000000 --- a/src/Ritten/Init/InitCommand.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.CommandLine; -using Ritten.Engine.Workflows; -using Ritten.Reporting; - -namespace Ritten.Init; - -/// -/// The init command, which sets a repository up to run a workflow. -/// -internal static class InitCommand -{ - public static Command Create(WorkflowRegistry workflows, string projectFile) - { - var workflow = new Argument("workflow") - { - Description = "The workflow to scaffold for. Derived from the repository when omitted.", - Arity = ArgumentArity.ZeroOrOne - }; - - var check = new Option("--check") - { - Description = "Report what's missing or out of date without writing anything." - }; - - var force = new Option("--force") - { - Description = "Rewrite the files Ritten generates, discarding local changes to them. Your own files are left alone." - }; - - var verbose = new Option("--verbose", "-v") { Description = "Show every log entry in its highest detail." }; - - var command = new Command("init", "Sets this repository up to run a Ritten workflow.") { workflow, check, force, verbose }; - command.SetAction(async (parseResult, ct) => - { - var console = EngineConsole.Create(parseResult.GetValue(verbose) ? WorkflowLogLevel.Verbose : WorkflowLogLevel.Detail); - - var init = new ProjectInitializer(workflows, console, EngineConsole.Prompt(), projectFile); - var exitCode = await init.Run( - Environment.CurrentDirectory, - parseResult.GetValue(workflow), - parseResult.GetValue(check) - ? ScaffoldMode.Check - : parseResult.GetValue(force) - ? ScaffoldMode.Rewrite - : ScaffoldMode.Write, - ct - ); - - return exitCode; - }); - - return command; - } -} diff --git a/src/Ritten/Init/ProjectInitializer.cs b/src/Ritten/Init/ProjectInitializer.cs deleted file mode 100644 index 3ce69d7..0000000 --- a/src/Ritten/Init/ProjectInitializer.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System.Reflection; -using Ritten.Contracts; -using Ritten.Contracts.FileSystem; -using Ritten.Engine; -using Ritten.Engine.FileSystem; -using Ritten.Engine.Workflows; -using Ritten.Reporting; - -namespace Ritten.Init; - -/// -/// Sets a repository up to run a workflow, or reports how far its scaffolding has drifted. -/// -/// The workflows this tool can scaffold for. -/// The workflow log. -/// The prompt used to confirm a derived workflow. -/// The name the host gives the project file. -public sealed class ProjectInitializer( - WorkflowRegistry workflows, - IWorkflowLog log, - IWorkflowPrompt prompt, - string projectFile = "ritten.json" -) -{ - /// - /// Scaffolds the repository in the given directory. - /// - /// The repository to set up. - /// The workflow to scaffold for, or null to derive one. - /// How far the scaffolder is allowed to go. - /// A token to monitor for cancellation requests. - public async Task Run(string directory, string? name, ScaffoldMode mode = ScaffoldMode.Write, CancellationToken ct = default) - { - var root = new PhysicalDirectory(directory); - var workflow = await SelectWorkflow(root, name, ct); - if (workflow.IsError) - { - foreach (var error in workflow.Errors) - { - log.Error(error.Message); - } - - return ExitCode.ConfigurationError; - } - - var files = RepositoryScaffold.For(workflow.Value, ShippedProject(root), Version, projectFile); - var outcomes = await new Scaffolder(FileSystemAt(root)).Apply(files, root, mode, ct); - - return Report(workflow.Value, outcomes, mode); - } - - /// - /// The version doing the scaffolding is the one the repository gets pinned to. - /// - private static string Version => - Assembly.GetExecutingAssembly().GetCustomAttribute()?.InformationalVersion.Split('+')[0] - ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) - ?? "0.0.0"; - - private async Task> SelectWorkflow(IDirectory root, string? name, CancellationToken ct) - { - var known = $"Known workflows: {string.Join(", ", workflows.Workflows.Select(w => w.Name))}."; - if (name is { Length: > 0 }) - { - return workflows.Find(name) is { } named - ? new Result(named) - : new Result([Result.Error($"There is no workflow named '{name}'."), Result.Error(known)]); - } - - // Nothing named, so propose from what's in the repository. - if (SuggestWorkflow(root) is not { } proposal) - { - return new Result([Result.Error("Name the workflow to scaffold for."), Result.Error(known)]); - } - - if (!prompt.IsInteractive) - { - return new Result([ - Result.Error($"This looks like a '{proposal.Name}' repository, but nobody is here to confirm it."), - Result.Error($"Name the workflow to scaffold for. {known}") - ]); - } - - return await prompt.Confirm($"This looks like a '{proposal.Name}' repository. Scaffold for that?", ct) - ? new Result(proposal) - : new Result([Result.Error("Nothing scaffolded."), Result.Error(known)]); - } - - /// - /// Reads the repository the way a person would: a project that packs as a tool means a tool, - /// one that packs at all means a package, and anything else just builds. - /// - private IWorkflow? SuggestWorkflow(IDirectory root) - { - var projects = Projects(root).ToList(); - var name = projects.Any(p => Declares(p, "true")) - ? "dotnet-tool" - : projects.Any(p => Declares(p, "") || Declares(p, "true")) - ? "dotnet-package" - : "dotnet"; - - return workflows.Find(name); - } - - /// - /// The project the repository ships, when exactly one candidate stands out. - /// - private static string? ShippedProject(IDirectory root) - { - var projects = Projects(root).Where(p => !IsTests(p)).ToList(); - return projects.Count == 1 ? Relative(root, projects[0]) : null; - } - - private static IEnumerable Projects(IDirectory root) => - root.GetFiles("**/*.csproj").Where(file => !file.AbsolutePath.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")); - - private static bool IsTests(IFile project) => - project.NameWithoutExtension.EndsWith("Tests", StringComparison.OrdinalIgnoreCase) - || project.AbsolutePath.Contains($"{Path.DirectorySeparatorChar}tests{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase); - - private static bool Declares(IFile project, string element) - { - using var reader = new StreamReader(project.OpenRead()); - return reader.ReadToEnd().Contains(element, StringComparison.OrdinalIgnoreCase); - } - - private static string Relative(IDirectory root, IFile file) => - Path.GetRelativePath(root.AbsolutePath, file.AbsolutePath).Replace(Path.DirectorySeparatorChar, '/'); - - private ExitCode Report(IWorkflow workflow, IReadOnlyList<(ScaffoldedFile File, ScaffoldOutcome Outcome)> outcomes, ScaffoldMode mode) - { - var checking = mode == ScaffoldMode.Check; - foreach (var (file, outcome) in outcomes) - { - switch (outcome) - { - case ScaffoldOutcome.Written when checking: - log.Warning($"{file.Path} is missing."); - break; - case ScaffoldOutcome.Written: - log.Detail($"Wrote {file.Path}."); - break; - case ScaffoldOutcome.Rewritten: - log.Detail($"Rewrote {file.Path}."); - break; - case ScaffoldOutcome.Differs: - log.Warning($"{file.Path} differs from what the {workflow.Label} workflow generates."); - - // Asked what's expected, without being asked to write it. - log.Verbose($"{file.Path} should say:\n{file.Content}"); - break; - default: - log.Verbose($"{file.Path} needs no changes."); - break; - } - } - - var drifted = outcomes.Count(o => o.Outcome is ScaffoldOutcome.Written or ScaffoldOutcome.Differs); - if (checking) - { - if (drifted == 0) - { - log.Status($"The scaffolding matches the {workflow.Label} workflow."); - return ExitCode.Success; - } - - log.Error($"{drifted} of {outcomes.Count} files are missing or out of date. Run `ritten init --force` to bring them up to date, or `--verbose` to see what's expected."); - return ExitCode.Failed; - } - - // Nothing was rewritten because nothing was asked to be: say how, rather than leaving the - // drift reported and unfixable. - if (outcomes.Any(o => o.Outcome == ScaffoldOutcome.Differs)) - { - log.Status("Run `ritten init --force` to rewrite the files Ritten generates. Your other files are left alone."); - return ExitCode.Success; - } - - log.Status($"Set up for the {workflow.Label} workflow. Run `dotnet tool restore`, then `dotnet ritten check`."); - return ExitCode.Success; - } - - private static IFileSystem FileSystemAt(IDirectory root) => new ScaffoldFileSystem(root); - - private sealed class ScaffoldFileSystem(IDirectory root) : IFileSystem - { - public IDirectory ProjectRoot => root; - public IDirectory Artifacts => root.GetDirectory("artifacts"); - public IDirectory Temp => root.GetDirectory("temp"); - } -} diff --git a/src/Ritten/Init/RepositoryScaffold.cs b/src/Ritten/Init/RepositoryScaffold.cs deleted file mode 100644 index f20bf4f..0000000 --- a/src/Ritten/Init/RepositoryScaffold.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using Ritten.Engine.Workflows; - -namespace Ritten.Init; - -/// -/// What a repository needs in order to run a Ritten workflow. -/// -internal static class RepositoryScaffold -{ - /// - /// Where the tool manifest lives, by dotnet's convention. - /// - public const string ToolManifest = ".config/dotnet-tools.json"; - - /// - /// Where the GitHub Actions workflow lives, by GitHub's convention. - /// - public const string ActionsWorkflow = ".github/workflows/ritten.yml"; - - private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true }; - - /// - /// Every file the repository should have, and what it should say. - /// - /// The workflow the repository runs. - /// The project file the repository ships, when one was found. - /// The version of Ritten to pin, which is the one doing the scaffolding. - /// The name the host gives the project file. - public static IReadOnlyList For(IWorkflow workflow, string? project, string version, string projectFile) => - [ - new(projectFile, RittenJson(workflow, project)), - new("CHANGELOG.md", Changelog()), - new(ToolManifest, Manifest(version), Generated: true), - new(ActionsWorkflow, WorkflowYaml.Render(workflow), Generated: true) - ]; - - private static string RittenJson(IWorkflow workflow, string? project) - { - var json = new JsonObject { ["workflow"] = workflow.Name }; - - // A workflow with nothing to release needs no project: it builds whatever it finds. - if (project is not null) - { - json["build"] = new JsonObject { ["project"] = project }; - } - - return json.ToJsonString(Indented) + "\n"; - } - - private static string Changelog() => - """ - # Changelog - - All notable changes to this project will be documented in this file. - - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - - ## [Unreleased] - - """; - - private static string Manifest(string version) - { - var manifest = new JsonObject - { - ["version"] = 1, - ["isRoot"] = true, - ["tools"] = new JsonObject - { - ["ritten"] = new JsonObject - { - ["version"] = version, - ["commands"] = new JsonArray("ritten"), - ["rollForward"] = false - } - } - }; - - return manifest.ToJsonString(Indented) + "\n"; - } -} diff --git a/src/Ritten/Init/RittenTool.cs b/src/Ritten/Init/RittenTool.cs new file mode 100644 index 0000000..7640e83 --- /dev/null +++ b/src/Ritten/Init/RittenTool.cs @@ -0,0 +1,30 @@ +using System.Reflection; +using NuGet.Versioning; + +namespace Ritten.Init; + +/// +/// This tool, as the repositories it sets up will pin it. +/// +internal static class RittenTool +{ + /// + /// The name the tool is published and invoked under. + /// + private const string Name = "ritten"; + + /// + /// The version doing the setting up is the one the repository gets pinned to: what a + /// repository runs should be what wrote down how to run it. + /// + public static ToolPin Pin { get; } = new(Name, Name, Version()); + + private static NuGetVersion Version() + { + var assembly = typeof(RittenTool).Assembly; + var informational = assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0]; + return NuGetVersion.TryParse(informational ?? assembly.GetName().Version?.ToString(3), out var version) + ? version + : new NuGetVersion(0, 0, 0); + } +} diff --git a/src/Ritten/Init/ScaffoldMode.cs b/src/Ritten/Init/ScaffoldMode.cs deleted file mode 100644 index 05392bc..0000000 --- a/src/Ritten/Init/ScaffoldMode.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Ritten.Init; - -/// -/// How far the scaffolder is allowed to go. -/// -public enum ScaffoldMode -{ - /// - /// Write what's missing, and leave anything already there alone. - /// - Write, - - /// - /// Write what's missing, and bring the files Ritten generates back to what it generates. - /// Seeds are still left alone: a changelog belongs to the repository the moment it exists. - /// - Rewrite, - - /// - /// Write nothing, and report what would change. - /// - Check -} diff --git a/src/Ritten/Init/ScaffoldOutcome.cs b/src/Ritten/Init/ScaffoldOutcome.cs deleted file mode 100644 index 3b73bf7..0000000 --- a/src/Ritten/Init/ScaffoldOutcome.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Ritten.Init; - -/// -/// What became of one scaffolded file. -/// -public enum ScaffoldOutcome -{ - /// - /// The file wasn't there, so it was written. - /// - Written, - - /// - /// The file had drifted from what Ritten generates, so it was written again. - /// - Rewritten, - - /// - /// Nothing to do: the file is the repository's, or it already says what it should. - /// - Matches, - - /// - /// The file is Ritten's to generate and says something else - /// . - Differs -} diff --git a/src/Ritten/Init/ScaffoldedFile.cs b/src/Ritten/Init/ScaffoldedFile.cs deleted file mode 100644 index 34950b9..0000000 --- a/src/Ritten/Init/ScaffoldedFile.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Ritten.Init; - -/// -/// A file a repository needs in order to run its workflow, and what it should contain. -/// -/// Where the file belongs, relative to the repository root. -/// What the file should say. -/// Whether Ritten owns the file's content. A generated file can be checked for drift. -public sealed record ScaffoldedFile(string Path, string Content, bool Generated = false); diff --git a/src/Ritten/Init/Scaffolder.cs b/src/Ritten/Init/Scaffolder.cs deleted file mode 100644 index ccb9c42..0000000 --- a/src/Ritten/Init/Scaffolder.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Ritten.Contracts.FileSystem; - -namespace Ritten.Init; - -/// -/// Puts a repository's scaffolding in place, or reports how far it has drifted from what the -/// workflow expects. -/// -/// The file system. -public sealed class Scaffolder(IFileSystem fileSystem) -{ - /// - /// Puts the scaffolding in place as far as the mode allows, and reports what became of each file. - /// - /// What the repository should have. - /// The directory the files belong under. - /// How far to go. - /// A token to monitor for cancellation requests. - public async Task> Apply( - IReadOnlyList files, - IDirectory? root = null, - ScaffoldMode mode = ScaffoldMode.Write, - CancellationToken cancellationToken = default) - { - root ??= fileSystem.ProjectRoot; - List<(ScaffoldedFile, ScaffoldOutcome)> outcomes = []; - foreach (var file in files) - { - var target = root.GetFile(file.Path); - if (target.Exists) - { - // A seed is the repository's the moment it exists; only what Ritten generates - // is held to what Ritten would generate. - if (!file.Generated || await Read(target, cancellationToken) == file.Content) - { - outcomes.Add((file, ScaffoldOutcome.Matches)); - continue; - } - - if (mode != ScaffoldMode.Rewrite) - { - outcomes.Add((file, ScaffoldOutcome.Differs)); - continue; - } - - await Write(root, file, cancellationToken); - outcomes.Add((file, ScaffoldOutcome.Rewritten)); - continue; - } - - if (mode != ScaffoldMode.Check) - { - await Write(root, file, cancellationToken); - } - - outcomes.Add((file, ScaffoldOutcome.Written)); - } - - return outcomes; - } - - private static async Task Read(IFile file, CancellationToken cancellationToken) - { - using var reader = new StreamReader(file.OpenRead()); - return await reader.ReadToEndAsync(cancellationToken); - } - - private static async Task Write(IDirectory root, ScaffoldedFile file, CancellationToken cancellationToken) - { - // .config and .github/workflows won't exist yet in a fresh repository. - if (Path.GetDirectoryName(file.Path) is { Length: > 0 } directory) - { - root.GetDirectory(directory).Create(); - } - - var stream = root.GetFile(file.Path).OpenWrite(); - stream.SetLength(0); - await using var writer = new StreamWriter(stream); - await writer.WriteAsync(file.Content.AsMemory(), cancellationToken); - } -} diff --git a/src/Ritten/Init/Steps/EnsureActionsWorkflow.cs b/src/Ritten/Init/Steps/EnsureActionsWorkflow.cs new file mode 100644 index 0000000..d48ed7a --- /dev/null +++ b/src/Ritten/Init/Steps/EnsureActionsWorkflow.cs @@ -0,0 +1,196 @@ +using Microsoft.Extensions.Options; +using Ritten.Contracts; +using Ritten.Contracts.FileSystem; +using Ritten.DotNet; +using Ritten.Engine.Workflows; +using Ritten.Git; +using Ritten.GitHub; +using Ritten.Reporting; + +namespace Ritten.Init.Steps; + +/// +/// Makes sure the repository's GitHub Actions workflow runs the jobs that guard and ship a change. +/// +/// The workflow log. +/// The GitHub Actions workflow client. +/// The git client, for the root GitHub reads workflows from. +/// The file system. +/// The workflow's .NET options, for the project the repository ships. +/// The workflow being set up, whose jobs the file runs. +/// The tool the jobs run. +[Step("ensure actions workflow", StepKind.Work)] +public class EnsureActionsWorkflow( + IWorkflowLog log, + IActionsWorkflows actions, + IGit git, + IFileSystem fileSystem, + IOptions options, + SelectedWorkflow workflow, + ToolPin tool +) +{ + /// + /// Writes the workflow's automated jobs into the repository's Actions workflow. + /// + /// What the repository builds (see ). + /// A token to monitor for cancellation requests. + public async Task Run(DiscoveredProjects found, CancellationToken ct = default) + { + if (await git.RepositoryRoot(ct) is not { } root) + { + log.Warning("This isn't a git repository, so there's nowhere GitHub Actions would read a workflow from."); + return StepResult.Successful; + } + + List jobs = [.. ActionsWorkflowTemplate.Automated(workflow.Workflow)]; + if (jobs.Count == 0) + { + log.Skipped($"The {workflow.Workflow.Label} workflow has no jobs worth running on GitHub Actions."); + return StepResult.Successful; + } + + // A repository of several projects runs each one's jobs from its own directory, and gets + // one workflow file each: same jobs, different working directory, and the project's own + // name on each. + var directory = Directory(root); + var name = Named(found); + var file = await Ours(root, directory, ct) ?? await Free(root, directory, name, ct); + + var read = file.Exists ? await actions.Read(file, ct) : actions.Parse(ActionsWorkflowTemplate.Document(name)); + if (read.IsError) + { + return StepResult.Failed(read.Errors); + } + + var globalJson = GlobalJson(root, directory); + if (globalJson is null) + { + log.Warning("There's no global.json, so the workflow doesn't pin an SDK version. Add one, then run init again."); + } + + var ghaWorkflow = read.Value; + var before = ghaWorkflow.Text; + foreach (var job in jobs) + { + foreach (var (trigger, block) in ActionsWorkflowTemplate.Triggers(job)) + { + ghaWorkflow = ghaWorkflow.WithTrigger(trigger, block); + } + + ghaWorkflow = ghaWorkflow.WithJob(job.Name, ActionsWorkflowTemplate.Job(job, tool, directory, globalJson)); + } + + if (ghaWorkflow.Text == before) + { + log.Skipped($"{file.Name} already runs {Named(jobs)}."); + return StepResult.Successful; + } + + await actions.Write(file, ghaWorkflow, ct); + log.Detail($"{file.Name}: {Named(jobs)}."); + return StepResult.Successful; + } + + /// + /// The workflow file that already runs this project's jobs, wherever it is and whatever it's + /// called. A file that doesn't parse is somebody else's problem, not a reason to stop. + /// + private async Task Ours(IDirectory root, string? directory, CancellationToken ct) + { + foreach (var candidate in actions.Files(root)) + { + var read = await actions.Read(candidate, ct); + if (read.IsError) + { + log.Verbose($"Skipped {candidate.Name}: {read.Errors[0].Message}"); + continue; + } + + if (Runs(read.Value, directory)) + { + return candidate; + } + } + + return null; + } + + /// + /// Whether the workflow already runs this tool for this project. The working directory is + /// what tells one project's workflow from another's in a repository of several. + /// + private bool Runs(ActionsWorkflow workflow, string? directory) => workflow.Jobs + .SelectMany(job => job.Steps) + .Any(step => step.Run.Contains($"dotnet {tool.Command} ", StringComparison.Ordinal) + && (step.WorkingDirectory ?? workflow.WorkingDirectory) == directory); + + /// + /// Where the project is in the repository, or null when it is the repository. + /// + private string? Directory(IDirectory root) + { + var relative = root.RelativePath(fileSystem.ProjectRoot); + return relative is "." or "" ? null : relative; + } + + /// + /// The SDK version file the workflow should point at: the project's own when it pins one, the + /// repository's otherwise, and nothing at all when neither does. + /// + private static string? GlobalJson(IDirectory root, string? directory) + { + var project = directory is null ? null : $"{directory}/global.json"; + return project is not null && root.GetFile(project).Exists ? project + : root.GetFile("global.json").Exists ? "global.json" + : null; + } + + /// + /// What the Actions tab calls the workflow: the project it builds. A workflow named for the + /// tool that wrote it would be the same name in every repository, and the same name twice in + /// a repository of several projects — and the name is what keeps each project's runs, and + /// each project's pull request comment, its own. + /// + private string Named(DiscoveredProjects found) + { + // What the project file declares, when it declares one: the first project is the face of + // whatever the repository ships. A repository that declares nothing yet is read off disk, + // and one with no projects at all is named for where it is. + var shipped = options.Value.ProjectFile is { Length: > 0 } declared ? declared : found.Shipped.FirstOrDefault(); + return Path.GetFileNameWithoutExtension(shipped) is { Length: > 0 } project ? project : fileSystem.ProjectRoot.Name; + } + + /// + /// The file the project's workflow belongs in: named for the project, and — where another + /// project of the same name got there first — for where this one is, so that ensuring one + /// project's jobs can never overwrite another's. + /// + private async Task Free(IDirectory root, string? directory, string name, CancellationToken ct) + { + var file = actions.File(root, Slug(name)); + if (!file.Exists) + { + return file; + } + + var read = await actions.Read(file, ct); + return read.IsSuccess && RunsTheTool(read.Value) + ? actions.File(root, $"{Slug(name)}-{Slug(directory ?? root.Name)}") + : file; + } + + /// + /// Whether the workflow runs this tool at all — for a file that isn't this project's, which + /// makes it another project's. + /// + private bool RunsTheTool(ActionsWorkflow workflow) => + workflow.Jobs.Any(job => job.Invokes($"dotnet {tool.Command} ")); + + /// + /// A name as a file is spelled: lowercase, and separated the way paths and file names are. + /// + private static string Slug(string name) => name.ToLowerInvariant().Replace('.', '-').Replace('/', '-').Replace(' ', '-'); + + private static string Named(IEnumerable jobs) => string.Join(", ", jobs.Select(job => job.Name)); +} diff --git a/src/Ritten/Init/Steps/EnsureChangelog.cs b/src/Ritten/Init/Steps/EnsureChangelog.cs new file mode 100644 index 0000000..4171023 --- /dev/null +++ b/src/Ritten/Init/Steps/EnsureChangelog.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Options; +using Ritten.Changelogs; +using Ritten.Contracts; +using Ritten.Contracts.FileSystem; +using Ritten.Reporting; + +namespace Ritten.Init.Steps; + +/// +/// Makes sure the repository has a changelog with somewhere to write the next release's notes. +/// +/// The workflow log. +/// The workflow's changelog options. +/// The file system. +/// The changelog client. +[Step("ensure changelog", StepKind.Work)] +public class EnsureChangelog(IWorkflowLog log, IOptions options, IFileSystem fileSystem, IChangelog changelogs) +{ + /// + /// What a changelog says before anybody has released anything. + /// + private const string Preamble = + """ + # Changelog + + All notable changes to this project will be documented in this file. + + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + """; + + /// + /// Writes a changelog when there isn't one, and gives an existing one an unreleased section + /// when it hasn't got one. Everything already written stays exactly as it was written. + /// + /// A token to monitor for cancellation requests. + public async Task Run(CancellationToken ct = default) + { + var file = fileSystem.ProjectRoot.GetFile(options.Value.File); + if (!file.Exists) + { + await changelogs.Write(file, new Changelog { Preamble = Preamble, Entries = [new ChangelogEntry()] }, ct); + log.Detail($"{options.Value.File}: a changelog, with somewhere to write the next release's notes."); + return StepResult.Successful; + } + + var changelog = await changelogs.Read(file, ct); + if (changelog.Unreleased is not null) + { + log.Skipped($"{options.Value.File} already has somewhere to write the next release's notes."); + return StepResult.Successful; + } + + // The unreleased notes go at the top, above every version that has already shipped. + await changelogs.Write(file, changelog with { Entries = [new ChangelogEntry(), .. changelog.Entries] }, ct); + log.Detail($"{options.Value.File}: an unreleased section, above everything already released."); + return StepResult.Successful; + } +} diff --git a/src/Ritten/Init/Steps/EnsureRittenProject.cs b/src/Ritten/Init/Steps/EnsureRittenProject.cs new file mode 100644 index 0000000..e352fcc --- /dev/null +++ b/src/Ritten/Init/Steps/EnsureRittenProject.cs @@ -0,0 +1,125 @@ +using Ritten.Contracts; +using Ritten.Contracts.FileSystem; +using Ritten.DotNet; +using Ritten.DotNet.Steps; +using Ritten.Engine; +using Ritten.Engine.Workflows; +using Ritten.Reporting; + +namespace Ritten.Init.Steps; + +/// +/// Makes sure the repository's project file declares the workflow it runs, and what it builds. +/// +/// The workflow log. +/// The project file client. +/// The file system. +/// Where the project file is, and whether it was there to read. +/// The workflow being set up, and how it was chosen. +/// The job being run. +/// The prompt used to confirm a workflow nobody declared. +[Step("ensure project", StepKind.Work)] +public class EnsureRittenProject( + IWorkflowLog log, + IProjectFiles files, + IFileSystem fileSystem, + RittenProject project, + SelectedWorkflow workflow, + WorkflowJob job, + IWorkflowPrompt prompt +) +{ + /// + /// Writes down what the repository runs, filling in only what it doesn't already say. + /// + /// What the repository builds (see ). + /// A token to monitor for cancellation requests. + public async Task Run(DiscoveredProjects found, CancellationToken ct = default) + { + if (await Confirmed(ct) is { IsFailure: true } refused) + { + return refused; + } + + var file = fileSystem.ProjectRoot.GetFile(project.FileName); + var read = await files.Read(file, ct); + if (read.IsError) + { + return StepResult.Failed(read.Errors); + } + + var document = read.Value; + List written = []; + + if (document.Workflow is null) + { + document.Workflow = workflow.Workflow.Name; + written.Add($"workflow: {workflow.Workflow.Name}"); + } + + if (!document.Has("build.project") && !document.Has("build.projects")) + { + // One package is spelled singular and several plural. + switch (found.Shipped) + { + case [var only]: + document.Set("build.project", only); + written.Add($"build.project: {only}"); + break; + case { Count: > 1 }: + document.Set("build.projects", found.Shipped); + written.Add($"build.projects: {found.Shipped.Count} projects"); + break; + default: + log.Warning($"No projects found, so {project.FileName} doesn't say what to build. Add 'build.project' once there is one."); + break; + } + } + + if (written.Count == 0) + { + log.Skipped($"{project.FileName} already says what it runs."); + return StepResult.Successful; + } + + await files.Write(file, document, ct); + log.Detail($"{project.FileName}: {string.Join(", ", written)}."); + return StepResult.Successful; + } + + /// + /// A workflow nobody declared was recognized from what's in the repository, and a guess about + /// what a repository is for is worth confirming before it's written into the repository. + /// + private async Task Confirmed(CancellationToken ct) + { + if (workflow.Recognised is not { } reason) + { + return StepResult.Successful; + } + + log.Detail($"Nothing declares a workflow yet. This looks like a {workflow.Workflow.Label} repository: {reason}."); + + if (job.DryRun) + { + return StepResult.Successful; + } + + if (job.AutoApprove) + { + log.Skipped($"Approved automatically by --{WorkflowArguments.AutoApprove}."); + return StepResult.Successful; + } + + if (!prompt.IsInteractive) + { + return StepResult.Failed( + $"This looks like a {workflow.Workflow.Label} repository, and there's no terminal to confirm that at. " + + $"Pass --{WorkflowArguments.Workflow} to name the workflow to set up."); + } + + return await prompt.Confirm($"Set this repository up for the {workflow.Workflow.Label} workflow?", ct) + ? StepResult.Successful + : StepResult.Failed($"Nothing was set up. Pass --{WorkflowArguments.Workflow} to name a different workflow."); + } +} diff --git a/src/Ritten/Init/Steps/EnsureToolManifest.cs b/src/Ritten/Init/Steps/EnsureToolManifest.cs new file mode 100644 index 0000000..6109d6a --- /dev/null +++ b/src/Ritten/Init/Steps/EnsureToolManifest.cs @@ -0,0 +1,55 @@ +using Ritten.Contracts; +using Ritten.Contracts.FileSystem; +using Ritten.DotNet; +using Ritten.Git; +using Ritten.Reporting; + +namespace Ritten.Init.Steps; + +/// +/// Makes sure the repository pins the tool version that set it up. +/// +/// The workflow log. +/// The .NET client. +/// The git client, for the root the manifest belongs at. +/// The file system. +/// The tool being pinned. +[Step("ensure tool manifest", StepKind.Work)] +public class EnsureToolManifest(IWorkflowLog log, IDotNet dotnet, IGit git, IFileSystem fileSystem, ToolPin tool) +{ + /// + /// Pins the tool in whichever manifest governs the repository. + /// + /// A token to monitor for cancellation requests. + public async Task Run(CancellationToken ct = default) + { + // One manifest at the repository's root serves every project in it. + var root = await git.RepositoryRoot(ct) ?? fileSystem.ProjectRoot; + var scope = ToolScope.Local(root); + var pinned = await dotnet.InstalledToolVersion(tool.PackageId, scope, ct); + if (pinned == tool.Version) + { + log.Skipped($"{tool.PackageId} {tool.Version} is already pinned."); + return StepResult.Successful; + } + + if (DotNetProjects.ToolManifest(root) is null) + { + await dotnet.CreateToolManifest(root, ct); + log.Detail($"{DotNetProjects.ToolManifestDirectory}: a tool manifest for the repository."); + } + + var args = new ToolInstallArgs { PackageId = tool.PackageId, Scope = scope, Version = tool.Version }; + if (pinned is null) + { + await dotnet.ToolInstall(args, ct); + log.Detail($"The tool manifest: {tool.PackageId} {tool.Version}."); + return StepResult.Successful; + } + + await dotnet.ToolUpdate(args, ct); + log.Detail($"The tool manifest: {tool.PackageId} {tool.Version}, up from {pinned}."); + + return StepResult.Successful; + } +} diff --git a/src/Ritten/Init/ToolPin.cs b/src/Ritten/Init/ToolPin.cs new file mode 100644 index 0000000..9e167f7 --- /dev/null +++ b/src/Ritten/Init/ToolPin.cs @@ -0,0 +1,11 @@ +using NuGet.Versioning; + +namespace Ritten.Init; + +/// +/// The tool a repository is being set up to run. +/// +/// The tool's package ID, as the tool manifest pins it. +/// The command the tool is invoked as, as in dotnet ritten. +/// The version to pin. +public sealed record ToolPin(string PackageId, string Command, NuGetVersion Version); diff --git a/src/Ritten/Init/WorkflowBuilderExtensions.cs b/src/Ritten/Init/WorkflowBuilderExtensions.cs new file mode 100644 index 0000000..43582c2 --- /dev/null +++ b/src/Ritten/Init/WorkflowBuilderExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.DependencyInjection; +using Ritten.Engine; + +namespace Ritten.Init; + +/// +/// Registers what a job that sets a repository up needs. +/// +public static class WorkflowBuilderExtensions +{ + extension(IWorkflowBuilder builder) + { + /// + /// Adds the tool the repository is being set up to run. + /// + /// The tool being pinned, which is the one doing the setting up. + public IWorkflowBuilder AddInit(ToolPin tool) + { + builder.Services.AddSingleton(tool); + return builder; + } + } +} diff --git a/src/Ritten/Init/WorkflowYaml.cs b/src/Ritten/Init/WorkflowYaml.cs deleted file mode 100644 index ff0518b..0000000 --- a/src/Ritten/Init/WorkflowYaml.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System.Text; -using Ritten.Contracts; -using Ritten.Engine.Workflows; - -namespace Ritten.Init; - -/// -/// Renders the GitHub Actions workflow that runs a Ritten workflow's jobs. -/// -internal static class WorkflowYaml -{ - /// - /// The .NET SDK is pinned by global.json, so the workflow never names a version. - /// - private const string Setup = - """ - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Set up .NET - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - - - name: Restore tools - run: dotnet tool restore - """; - - /// A release is asked for, never triggered by a change. - private const string Dispatch = - """ - workflow_dispatch: - inputs: - dry-run: - description: 'Dry Run' - type: boolean - default: false - """; - - /// A check guards the change that triggered it. - private const string OnChange = - """ - pull_request: - branches: [ main ] - push: - branches: [ main ] - """; - - public static string Render(IWorkflow workflow) - { - var checks = workflow.Jobs.Where(job => job.Kind == JobKind.Check).ToList(); - var deploys = workflow.Jobs.Where(job => job.Kind == JobKind.Deploy).ToList(); - - var yaml = new StringBuilder(); - yaml.Append("name: Ritten").Append('\n').Append('\n').Append("on:").Append('\n'); - - if (deploys.Count > 0) - { - yaml.Append(Dispatch).Append('\n'); - } - - if (checks.Count > 0) - { - yaml.Append(OnChange).Append('\n'); - } - - yaml.Append('\n').Append("jobs:").Append('\n'); - foreach (var job in checks) - { - yaml.Append(Check(job)); - } - - foreach (var job in deploys) - { - yaml.Append(Deploy(job)); - } - - // Each job renders with a blank line after it, which leaves one too many at the end. - return yaml.ToString().TrimEnd() + "\n"; - } - - /// - /// A job that guards every change: it runs on the change, and a newer push supersedes it. - /// - private static string Check(IJob job) => - $$$""" - {{{job.Name}}}: - name: {{{Title(job.Name)}}} - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' || github.event_name == 'push' - permissions: - contents: read - # The report is posted as a pull request comment. - pull-requests: write - concurrency: - # A newer push to the same branch supersedes any run still going. - group: {{{job.Name}}}-${{ github.ref }} - cancel-in-progress: true - steps: - {{{Setup}}} - - - name: Run {{{job.Name}}} - run: dotnet ritten {{{job.Name}}} - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - """; - - /// - /// A job that releases: asked for deliberately, queued rather than cancelled, and approved up - /// front because there's nobody at the terminal to confirm. - /// - private static string Deploy(IJob job) => - $$$""" - {{{job.Name}}}: - name: {{{Title(job.Name)}}} - runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' - permissions: - # Tags and releases are written back to the repository. - contents: write - id-token: write - concurrency: - # Releases queue rather than interleave. - group: {{{job.Name}}} - cancel-in-progress: false - steps: - {{{Setup}}} - - - name: NuGet login - uses: NuGet/login@v1 - id: nuget-login - with: - user: ${{ secrets.NUGET_USER }} - - - name: Run {{{job.Name}}} - run: dotnet ritten {{{job.Name}}} --auto-approve ${{ inputs['dry-run'] && '--dry-run' || '' }} - shell: bash - env: - RITTEN_NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} - RITTEN_COMMIT_SHA: ${{ github.sha }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - """; - - private static string Title(string name) => string.Concat(char.ToUpperInvariant(name[0]), name[1..]); -} diff --git a/src/Ritten/Program.cs b/src/Ritten/Program.cs index 51e0e4f..2706f7b 100644 --- a/src/Ritten/Program.cs +++ b/src/Ritten/Program.cs @@ -3,7 +3,6 @@ using Ritten.Contracts; using Ritten.Engine; using Ritten.GitHub; -using Ritten.Init; using Ritten.Workflows.DotNet; using Ritten.Workflows.DotNetPackage; using Ritten.Workflows.DotNetTool; @@ -25,7 +24,6 @@ } var root = new RootCommand("The Ritten build workflow."); -root.Subcommands.Add(InitCommand.Create(builder.Workflows, builder.ProjectFileName)); await root.InstallRitten(built.Value); return await root.Parse(args).InvokeAsync(); diff --git a/src/Ritten/Workflows/DotNet/DotNetWorkflow.cs b/src/Ritten/Workflows/DotNet/DotNetWorkflow.cs index 0f07f0b..8cb9f12 100644 --- a/src/Ritten/Workflows/DotNet/DotNetWorkflow.cs +++ b/src/Ritten/Workflows/DotNet/DotNetWorkflow.cs @@ -1,3 +1,5 @@ +using Ritten.Contracts.FileSystem; +using Ritten.DotNet; using Ritten.Engine.Workflows; namespace Ritten.Workflows.DotNet; @@ -16,7 +18,14 @@ public class DotNetWorkflow : IWorkflow /// public IReadOnlyList Jobs { get; } = [ + new InitJob(), new BuildJob(), new CheckJob() ]; + + /// + public Task IsCompatible(IDirectory repository, CancellationToken cancellationToken = default) => + Task.FromResult(DotNetProjects.Projects(repository).FirstOrDefault() is { } project + ? $"{repository.RelativePath(project)} is a .NET project" + : null); } diff --git a/src/Ritten/Workflows/DotNet/InitJob.cs b/src/Ritten/Workflows/DotNet/InitJob.cs new file mode 100644 index 0000000..667335e --- /dev/null +++ b/src/Ritten/Workflows/DotNet/InitJob.cs @@ -0,0 +1,47 @@ +using Ritten.Changelogs; +using Ritten.Contracts; +using Ritten.DotNet.Steps; +using Ritten.Engine; +using Ritten.Engine.Workflows; +using Ritten.Git; +using Ritten.GitHub; +using Ritten.Init; +using Ritten.Init.Steps; + +namespace Ritten.Workflows.DotNet; + +/// +/// Sets a repository up to run this workflow, and brings one already set up back up to date. +/// +internal sealed class InitJob : Job +{ + /// + public override string Name => "init"; + + /// + public override string Description => "Sets this repository up to run the workflow, and tops up whatever it's missing."; + + /// + public override JobKind Kind => JobKind.Work; + + /// + public override bool RequiresProject => false; + + /// + protected override void Configure(IWorkflowBuilder builder, DotNetSettings settings) => builder + .AddChangelogs(new ChangelogSettings()) + .AddDotNet(settings.Build) + .AddGit() + .AddGitHubActions() + .AddInit(RittenTool.Pin); + + /// + public override IReadOnlyList Steps { get; } = + [ + Step.FromType(), + Step.FromType(), + Step.FromType(), + Step.FromType(), + Step.FromType() + ]; +} diff --git a/src/Ritten/Workflows/DotNetPackage/DotNetPackageWorkflow.cs b/src/Ritten/Workflows/DotNetPackage/DotNetPackageWorkflow.cs index 591afc9..20c3ecd 100644 --- a/src/Ritten/Workflows/DotNetPackage/DotNetPackageWorkflow.cs +++ b/src/Ritten/Workflows/DotNetPackage/DotNetPackageWorkflow.cs @@ -1,3 +1,5 @@ +using Ritten.Contracts.FileSystem; +using Ritten.DotNet; using Ritten.Engine.Workflows; namespace Ritten.Workflows.DotNetPackage; @@ -16,10 +18,25 @@ public class DotNetPackageWorkflow : IWorkflow /// public IReadOnlyList Jobs { get; } = [ + new InitJob(), new StatusJob(), new BuildJob(), new PrepareJob(), new CheckJob(), new DeployJob() ]; + + /// + public async Task IsCompatible(IDirectory repository, CancellationToken cancellationToken = default) + { + foreach (var element in (string[])["", "true"]) + { + if (await DotNetProjects.FileContainingMsBuildElement(repository, element, cancellationToken) is { } project) + { + return $"{repository.RelativePath(project)} packs as a package"; + } + } + + return null; + } } diff --git a/src/Ritten/Workflows/DotNetPackage/InitJob.cs b/src/Ritten/Workflows/DotNetPackage/InitJob.cs new file mode 100644 index 0000000..5c4b89b --- /dev/null +++ b/src/Ritten/Workflows/DotNetPackage/InitJob.cs @@ -0,0 +1,47 @@ +using Ritten.Changelogs; +using Ritten.Contracts; +using Ritten.DotNet.Steps; +using Ritten.Engine; +using Ritten.Engine.Workflows; +using Ritten.Git; +using Ritten.GitHub; +using Ritten.Init; +using Ritten.Init.Steps; + +namespace Ritten.Workflows.DotNetPackage; + +/// +/// Sets a repository up to run this workflow, and brings one already set up back up to date. +/// +internal sealed class InitJob : Job +{ + /// + public override string Name => "init"; + + /// + public override string Description => "Sets this repository up to run the workflow, and tops up whatever it's missing."; + + /// + public override JobKind Kind => JobKind.Work; + + /// + public override bool RequiresProject => false; + + /// + protected override void Configure(IWorkflowBuilder builder, DotNetPackageSettings settings) => builder + .AddChangelogs(settings.Changelog) + .AddDotNet(settings.Build, settings.Repository) + .AddGit() + .AddGitHubActions() + .AddInit(RittenTool.Pin); + + /// + public override IReadOnlyList Steps { get; } = + [ + Step.FromType(), + Step.FromType(), + Step.FromType(), + Step.FromType(), + Step.FromType() + ]; +} diff --git a/src/Ritten/Workflows/DotNetTool/DotNetToolWorkflow.cs b/src/Ritten/Workflows/DotNetTool/DotNetToolWorkflow.cs index 5254153..e8a4c7c 100644 --- a/src/Ritten/Workflows/DotNetTool/DotNetToolWorkflow.cs +++ b/src/Ritten/Workflows/DotNetTool/DotNetToolWorkflow.cs @@ -1,3 +1,5 @@ +using Ritten.Contracts.FileSystem; +using Ritten.DotNet; using Ritten.Engine.Workflows; namespace Ritten.Workflows.DotNetTool; @@ -16,6 +18,7 @@ public class DotNetToolWorkflow : IWorkflow /// public IReadOnlyList Jobs { get; } = [ + new InitJob(), new StatusJob(), new BuildJob(), new InstallJob(), @@ -23,4 +26,10 @@ public class DotNetToolWorkflow : IWorkflow new CheckJob(), new DeployJob() ]; + + /// + public async Task IsCompatible(IDirectory repository, CancellationToken cancellationToken = default) => + await DotNetProjects.FileContainingMsBuildElement(repository, "true", cancellationToken) is { } project + ? $"{repository.RelativePath(project)} packs as a tool" + : null; } diff --git a/src/Ritten/Workflows/DotNetTool/InitJob.cs b/src/Ritten/Workflows/DotNetTool/InitJob.cs new file mode 100644 index 0000000..eaded50 --- /dev/null +++ b/src/Ritten/Workflows/DotNetTool/InitJob.cs @@ -0,0 +1,47 @@ +using Ritten.Changelogs; +using Ritten.Contracts; +using Ritten.DotNet.Steps; +using Ritten.Engine; +using Ritten.Engine.Workflows; +using Ritten.Git; +using Ritten.GitHub; +using Ritten.Init; +using Ritten.Init.Steps; + +namespace Ritten.Workflows.DotNetTool; + +/// +/// Sets a repository up to run this workflow, and brings one already set up back up to date. +/// +internal sealed class InitJob : Job +{ + /// + public override string Name => "init"; + + /// + public override string Description => "Sets this repository up to run the workflow, and tops up whatever it's missing."; + + /// + public override JobKind Kind => JobKind.Work; + + /// + public override bool RequiresProject => false; + + /// + protected override void Configure(IWorkflowBuilder builder, DotNetToolSettings settings) => builder + .AddChangelogs(settings.Changelog) + .AddDotNet(settings.Build, settings.Repository) + .AddGit() + .AddGitHubActions() + .AddInit(RittenTool.Pin); + + /// + public override IReadOnlyList Steps { get; } = + [ + Step.FromType(), + Step.FromType(), + Step.FromType(), + Step.FromType(), + Step.FromType() + ]; +} diff --git a/tests/Ritten.Tests/Contracts/DirectoryExtensionsTests.cs b/tests/Ritten.Tests/Contracts/DirectoryExtensionsTests.cs new file mode 100644 index 0000000..8f628de --- /dev/null +++ b/tests/Ritten.Tests/Contracts/DirectoryExtensionsTests.cs @@ -0,0 +1,35 @@ +using Ritten.Contracts.FileSystem; +using Ritten.Engine.FileSystem; + +namespace Ritten.Tests.Contracts; + +/// +/// A path written into a project file, a workflow file, or a sentence is spelled with forward +/// slashes whichever platform read it off the disk. +/// +public class DirectoryExtensionsTests +{ + private static readonly IDirectory Root = new PhysicalDirectory(Path.Combine(Path.GetTempPath(), "repo")); + + [Fact] + public void WritesAFilesPathRelativeToTheDirectory() + { + var file = new PhysicalFile(Path.Combine(Root.AbsolutePath, "src", "My.Tool", "My.Tool.csproj")); + + Root.RelativePath(file).ShouldBe("src/My.Tool/My.Tool.csproj"); + } + + [Fact] + public void WritesADirectorysPathRelativeToAnother() + { + var nested = new PhysicalDirectory(Path.Combine(Root.AbsolutePath, "services", "api")); + + Root.RelativePath(nested).ShouldBe("services/api"); + } + + [Fact] + public void ADirectoryRelativeToItselfIsHere() + { + Root.RelativePath(Root).ShouldBe("."); + } +} diff --git a/tests/Ritten.Tests/DotNet/DotNetClientTests.cs b/tests/Ritten.Tests/DotNet/DotNetClientTests.cs index 52f706a..c85d054 100644 --- a/tests/Ritten.Tests/DotNet/DotNetClientTests.cs +++ b/tests/Ritten.Tests/DotNet/DotNetClientTests.cs @@ -95,7 +95,7 @@ public async Task InstalledToolVersion_FindsTheToolCaseInsensitively() c => c.Arguments.Contains("list"), new CommandResult(0, "Package Id Version Commands\n----------------------------------------\nmy.tool 1.2.3 mytool\nother 2.0.0 other\n", "")); - var version = await _client.InstalledToolVersion("My.Tool", TestContext.Current.CancellationToken); + var version = await _client.InstalledToolVersion("My.Tool", ToolScope.Global, TestContext.Current.CancellationToken); _commands.Executed.ShouldHaveSingleItem().Arguments.ShouldBe(["tool", "list", "--global"]); version.ShouldBe(NuGetVersion.Parse("1.2.3")); @@ -108,7 +108,7 @@ public async Task InstalledToolVersion_ReturnsNullWhenTheToolIsNotInstalled() c => c.Arguments.Contains("list"), new CommandResult(0, "Package Id Version Commands\n----------------------------------------\nother 2.0.0 other\n", "")); - var version = await _client.InstalledToolVersion("My.Tool", TestContext.Current.CancellationToken); + var version = await _client.InstalledToolVersion("My.Tool", ToolScope.Global, TestContext.Current.CancellationToken); version.ShouldBeNull(); } @@ -122,17 +122,91 @@ public async Task ToolInstall_ReplacesTheFeedsWithTheSource() source.AbsolutePath.Returns("/repo/artifacts"); await _client.ToolInstall( - new ToolInstallArgs { PackageId = "My.Tool", Version = NuGetVersion.Parse("1.2.3"), Source = source }, + new ToolInstallArgs { PackageId = "My.Tool", Scope = ToolScope.Global, Version = NuGetVersion.Parse("1.2.3"), Source = source }, TestContext.Current.CancellationToken); _commands.Executed.ShouldHaveSingleItem().Arguments .ShouldBe(["tool", "install", "My.Tool", "--global", "--version", "1.2.3", "--source", "/repo/artifacts"]); } + [Fact] + public async Task InstalledToolVersion_ReadsThePinFromTheManifestGoverningTheDirectory() + { + _commands.Respond( + c => c.Arguments.Contains("list"), + new CommandResult(0, "Package Id Version Commands Manifest\n------------------------------------------------\nritten 0.9.0 ritten /repo/.config/dotnet-tools.json\n", "")); + + var version = await _client.InstalledToolVersion("ritten", ToolScope.Local(In("/repo/services/api")), TestContext.Current.CancellationToken); + + // The scope is the flag the SDK takes and the directory it resolves the manifest from. + var command = _commands.Executed.ShouldHaveSingleItem(); + command.Arguments.ShouldBe(["tool", "list", "--local"]); + command.WorkingDirectory.ShouldBe("/repo/services/api"); + version.ShouldBe(NuGetVersion.Parse("0.9.0")); + } + + [Fact] + public async Task InstalledToolVersion_AnswersNothingForADirectoryNoManifestGoverns() + { + _commands.Respond(c => c.Arguments.Contains("list"), new CommandResult(1, "", "Cannot find a manifest file.")); + + var version = await _client.InstalledToolVersion("ritten", ToolScope.Local(In("/elsewhere")), TestContext.Current.CancellationToken); + + version.ShouldBeNull(); + } + + [Fact] + public async Task CreateToolManifest_AsksTheSdkForOneWhereRepositoriesKeepIt() + { + // The manifest's schema is the SDK's, so the SDK writes it — this only says where. + await _client.CreateToolManifest(In("/repo"), TestContext.Current.CancellationToken); + + var command = _commands.Executed.ShouldHaveSingleItem(); + command.Arguments.ShouldBe(["new", "tool-manifest", "--output", ".config"]); + command.WorkingDirectory.ShouldBe("/repo"); + } + + [Fact] + public async Task ToolInstall_PinsATheManifestsTool() + { + await _client.ToolInstall(Pin("/repo"), TestContext.Current.CancellationToken); + + var command = _commands.Executed.ShouldHaveSingleItem(); + command.Arguments.ShouldBe(["tool", "install", "ritten", "--local", "--version", "1.2.3"]); + command.WorkingDirectory.ShouldBe("/repo"); + } + + [Fact] + public async Task ToolUpdate_MovesAPinTheManifestAlreadyHas() + { + await _client.ToolUpdate(Pin("/repo"), TestContext.Current.CancellationToken); + + _commands.Executed.ShouldHaveSingleItem().Arguments.ShouldBe(["tool", "update", "ritten", "--local", "--version", "1.2.3"]); + } + + [Fact] + public async Task ToolInstall_AsksTheFeedForTheLatestWhenNoVersionIsNamed() + { + // Both options are the SDK's to default, so an argument nobody set isn't passed. + await _client.ToolInstall(new ToolInstallArgs { PackageId = "ritten", Scope = ToolScope.Global }, TestContext.Current.CancellationToken); + + _commands.Executed.ShouldHaveSingleItem().Arguments.ShouldBe(["tool", "install", "ritten", "--global"]); + } + + private static ToolInstallArgs Pin(string directory) => + new() { PackageId = "ritten", Scope = ToolScope.Local(In(directory)), Version = NuGetVersion.Parse("1.2.3") }; + + private static IDirectory In(string path) + { + var directory = Substitute.For(); + directory.AbsolutePath.Returns(path); + return directory; + } + [Fact] public async Task ToolUninstall_RemovesTheGlobalTool() { - await _client.ToolUninstall("My.Tool", TestContext.Current.CancellationToken); + await _client.ToolUninstall("My.Tool", ToolScope.Global, TestContext.Current.CancellationToken); _commands.Executed.ShouldHaveSingleItem().Arguments.ShouldBe(["tool", "uninstall", "My.Tool", "--global"]); } diff --git a/tests/Ritten.Tests/DotNet/DotnetToolInstallTests.cs b/tests/Ritten.Tests/DotNet/DotnetToolInstallTests.cs index f0ab459..996062b 100644 --- a/tests/Ritten.Tests/DotNet/DotnetToolInstallTests.cs +++ b/tests/Ritten.Tests/DotNet/DotnetToolInstallTests.cs @@ -16,12 +16,12 @@ public class DotnetToolInstallTests [Fact] public async Task InstallsAFreshTool() { - _dotnet.InstalledToolVersion("My.Tool", Arg.Any()).Returns((NuGetVersion?)null); + _dotnet.InstalledToolVersion("My.Tool", ToolScope.Global, Arg.Any()).Returns((NuGetVersion?)null); var result = await Step().Run(Tool("1.2.0"), Packed("My.Tool.1.2.0.nupkg"), TestContext.Current.CancellationToken); result.ShouldBe(StepResult.Successful); - await _dotnet.DidNotReceive().ToolUninstall(Arg.Any(), Arg.Any()); + await _dotnet.DidNotReceive().ToolUninstall(Arg.Any(), ToolScope.Global, Arg.Any()); await _dotnet.Received().ToolInstall( Arg.Is(a => a.PackageId == "My.Tool" && a.Version == NuGetVersion.Parse("1.2.0") && a.Source == _fileSystem.Artifacts), Arg.Any()); @@ -30,7 +30,7 @@ await _dotnet.Received().ToolInstall( [Fact] public async Task StopsWhenThisVersionIsAlreadyInstalled() { - _dotnet.InstalledToolVersion("My.Tool", Arg.Any()).Returns(NuGetVersion.Parse("1.2.0")); + _dotnet.InstalledToolVersion("My.Tool", ToolScope.Global, Arg.Any()).Returns(NuGetVersion.Parse("1.2.0")); var result = await Step().Run(Tool("1.2.0"), Packed("My.Tool.1.2.0.nupkg"), TestContext.Current.CancellationToken); @@ -43,12 +43,12 @@ public async Task StopsWhenThisVersionIsAlreadyInstalled() [Fact] public async Task ReinstallsTheSameVersionWithForce() { - _dotnet.InstalledToolVersion("My.Tool", Arg.Any()).Returns(NuGetVersion.Parse("1.2.0")); + _dotnet.InstalledToolVersion("My.Tool", ToolScope.Global, Arg.Any()).Returns(NuGetVersion.Parse("1.2.0")); var result = await Step(force: true).Run(Tool("1.2.0"), Packed("My.Tool.1.2.0.nupkg"), TestContext.Current.CancellationToken); result.ShouldBe(StepResult.Successful); - await _dotnet.Received().ToolUninstall("My.Tool", Arg.Any()); + await _dotnet.Received().ToolUninstall("My.Tool", ToolScope.Global, Arg.Any()); await _dotnet.Received().ToolInstall(Arg.Any(), Arg.Any()); } @@ -57,12 +57,12 @@ public async Task ReplacesADifferentVersionWithoutForce() { // Moving to the working tree's version is the job's ordinary work; force only guards // repeating an install that already matches. - _dotnet.InstalledToolVersion("My.Tool", Arg.Any()).Returns(NuGetVersion.Parse("1.1.0")); + _dotnet.InstalledToolVersion("My.Tool", ToolScope.Global, Arg.Any()).Returns(NuGetVersion.Parse("1.1.0")); var result = await Step().Run(Tool("1.2.0"), Packed("My.Tool.1.2.0.nupkg"), TestContext.Current.CancellationToken); result.ShouldBe(StepResult.Successful); - await _dotnet.Received().ToolUninstall("My.Tool", Arg.Any()); + await _dotnet.Received().ToolUninstall("My.Tool", ToolScope.Global, Arg.Any()); await _dotnet.Received().ToolInstall( Arg.Is(a => a.Version == NuGetVersion.Parse("1.2.0")), Arg.Any()); diff --git a/tests/Ritten.Tests/DotNet/FindProjectsTests.cs b/tests/Ritten.Tests/DotNet/FindProjectsTests.cs new file mode 100644 index 0000000..b59004b --- /dev/null +++ b/tests/Ritten.Tests/DotNet/FindProjectsTests.cs @@ -0,0 +1,75 @@ +using Ritten.Contracts.FileSystem; +using Ritten.DotNet; +using Ritten.DotNet.Steps; +using Ritten.Engine.FileSystem; +using Ritten.Reporting; + +namespace Ritten.Tests.DotNet; + +/// +/// What a repository builds, read off the disk for the jobs that run before anything says. +/// +public class FindProjectsTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), $"ritten-projects-{Guid.NewGuid():N}"); + private readonly IFileSystem _fileSystem = Substitute.For(); + + public FindProjectsTests() + { + var root = new PhysicalDirectory(_root); + _fileSystem.ProjectRoot.Returns(root); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + if (Directory.Exists(_root)) + { + Directory.Delete(_root, true); + } + } + + [Fact] + public void SeparatesWhatShipsFromWhatTests() + { + Project("src/My.Tool/My.Tool.csproj"); + Project("src/My.Core/My.Core.csproj"); + Project("tests/My.Tool.Tests/My.Tool.Tests.csproj"); + + var found = Run(); + + found.Shipped.ShouldBe(["src/My.Core/My.Core.csproj", "src/My.Tool/My.Tool.csproj"]); + found.Tests.ShouldBe(["tests/My.Tool.Tests/My.Tool.Tests.csproj"]); + } + + [Fact] + public void IgnoresTheBuildOutputsCopies() + { + Project("src/My.Tool/My.Tool.csproj"); + Project("src/My.Tool/bin/Debug/net10.0/My.Tool.csproj"); + Project("src/My.Tool/obj/My.Tool.csproj"); + + Run().Shipped.ShouldHaveSingleItem().ShouldBe("src/My.Tool/My.Tool.csproj"); + } + + [Fact] + public void FindsNothingInAnEmptyRepository() + { + Directory.CreateDirectory(_root); + + var found = Run(); + + found.Shipped.ShouldBeEmpty(); + found.Tests.ShouldBeEmpty(); + } + + private DiscoveredProjects Run() => + new FindProjects(Substitute.For(), _fileSystem).Run().Value.ShouldNotBeNull(); + + private void Project(string path) + { + var file = Path.Combine(_root, path.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + File.WriteAllText(file, ""); + } +} diff --git a/tests/Ritten.Tests/Engine/DryRun/DecoratorTests.cs b/tests/Ritten.Tests/Engine/DryRun/DecoratorTests.cs index f4d1804..876a18c 100644 --- a/tests/Ritten.Tests/Engine/DryRun/DecoratorTests.cs +++ b/tests/Ritten.Tests/Engine/DryRun/DecoratorTests.cs @@ -97,8 +97,9 @@ public async Task Run_AppliesTheApplicationsSharedDecoratorsInADryRun() builder.Decorators.Decorate(); var application = builder.Build().Value.ShouldNotBeNull(); - var args = new RunJobArgs("verify") { Directory = _root, DryRun = true }; - var exitCode = await application.Run(args, _ => null, TestContext.Current.CancellationToken); + var selection = await application.SelectWorkflow(_root, ct: TestContext.Current.CancellationToken); + var args = new RunJobArgs("verify") { DryRun = true }; + var exitCode = await application.Run(selection, args, _ => null, TestContext.Current.CancellationToken); exitCode.ShouldBe(ExitCode.Success); client.Pushes.ShouldBe(0, "an application-level decorator must reach the run"); diff --git a/tests/Ritten.Tests/Engine/Helpers/WorkflowRunBuilderHelpers.cs b/tests/Ritten.Tests/Engine/Helpers/WorkflowRunBuilderHelpers.cs index 89812ea..beba968 100644 --- a/tests/Ritten.Tests/Engine/Helpers/WorkflowRunBuilderHelpers.cs +++ b/tests/Ritten.Tests/Engine/Helpers/WorkflowRunBuilderHelpers.cs @@ -2,6 +2,7 @@ using Ritten.Engine; using Ritten.Engine.Runs; using Ritten.Engine.Runtimes; +using Ritten.Engine.Workflows; using Ritten.Reporting; using Spectre.Console; @@ -27,11 +28,18 @@ public static WorkflowRunBuilder Create( RuntimeRegistry? runtimes = null, string fileName = RittenProject.DefaultFileName) { + var project = new RittenProject + { + Directory = Path.GetTempPath(), + FileName = fileName, + Settings = JsonSerializer.Deserialize(settings) + }; + var builder = new WorkflowRunBuilder( - new RittenProject { Directory = Path.GetTempPath(), FileName = fileName, Settings = JsonSerializer.Deserialize(settings) }, + project, (runtimes ?? new RuntimeRegistry()).Detect(environment ?? Complete).Value.ShouldNotBeNull(), new SpectreWorkflowConsole(AnsiConsole.Console, WorkflowLogLevel.Detail)) - .WithWorkflowLabel(workflowName) + .WithWorkflow(new SelectedWorkflow(new Support.TestWorkflow(workflowName, label: workflowName), project)) .WithDryRun(dryRun); return log is null ? builder : builder.WithLog(log); } diff --git a/tests/Ritten.Tests/Engine/ProjectFileTests.cs b/tests/Ritten.Tests/Engine/ProjectFileTests.cs new file mode 100644 index 0000000..8355bf7 --- /dev/null +++ b/tests/Ritten.Tests/Engine/ProjectFileTests.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.DependencyInjection; +using Ritten.Engine; +using Ritten.Tests.Engine.Helpers; + +namespace Ritten.Tests.Engine; + +/// +/// The project file is read as a document rather than as settings, so that a job which fills in +/// what's missing leaves everything else — including keys this version has never heard of — +/// exactly as it found them. +/// +public class ProjectFileTests +{ + private static readonly IProjectFiles Files = WorkflowRunBuilderHelpers.Create() + .Services.BuildServiceProvider() + .GetRequiredService(); + + [Fact] + public void DeclaresTheWorkflowWhereAReaderLooksForIt() + { + var document = Parse("""{ "build": { "project": "src/Thing/Thing.csproj" } }"""); + + document.Workflow = "dotnet-tool"; + + Files.Render(document).ShouldStartWith("{\n \"workflow\": \"dotnet-tool\",".ReplaceLineEndings("\n")); + } + + [Fact] + public void RefusesAProjectFileThatIsNotJson() + { + // Reading is a client call, so a file somebody broke fails the step that reads it rather + // than throwing out of it. + var read = Files.Parse("{ not json"); + + read.IsError.ShouldBeTrue(); + } + + [Fact] + public void KeepsKeysItHasNeverHeardOf() + { + // A project file written by a newer tool still round-trips through an older one. + var document = Parse("""{ "workflow": "dotnet-tool", "somethingNewer": { "keep": "me" } }"""); + + document.Set("build.project", "src/Thing/Thing.csproj"); + + Files.Render(document).ShouldContain("\"keep\": \"me\""); + } + + [Fact] + public void WritesNestedKeysByPath() + { + var document = ProjectFile.Empty; + document.Workflow = "dotnet-tool"; + document.Set("build.projects", ["src/A/A.csproj", "src/B/B.csproj"]); + + Files.Render(document).ShouldBe( + """ + { + "workflow": "dotnet-tool", + "build": { + "projects": [ + "src/A/A.csproj", + "src/B/B.csproj" + ] + } + } + + """.ReplaceLineEndings("\n")); + } + + private static ProjectFile Parse(string json) => Files.Parse(json).Value.ShouldNotBeNull(); + + [Theory] + [InlineData("workflow", true)] + [InlineData("build.project", true)] + [InlineData("build.projects", false)] + [InlineData("release.tagPrefix", false)] + public void AnswersWhetherItAlreadySaysSomething(string key, bool expected) + { + var document = Parse("""{ "workflow": "dotnet-tool", "build": { "project": "src/Thing/Thing.csproj" } }"""); + + document.Has(key).ShouldBe(expected); + } + + [Fact] + public void ReadsAnEmptyDocumentAsOneNobodyHasWritten() + { + // A repository being set up has no project file, which is a document with nothing in it + // rather than an error. + var document = Parse(""); + + document.Workflow.ShouldBeNull(); + document.Has("build").ShouldBeFalse(); + } +} diff --git a/tests/Ritten.Tests/Engine/RittenProjectTests.cs b/tests/Ritten.Tests/Engine/RittenProjectTests.cs index 2bf20b2..d0e827f 100644 --- a/tests/Ritten.Tests/Engine/RittenProjectTests.cs +++ b/tests/Ritten.Tests/Engine/RittenProjectTests.cs @@ -51,14 +51,17 @@ public async Task Resolve_PrefersTheNearestFile() } [Fact] - public async Task Resolve_ReportsNoProjectUpToTheFilesystemRoot() + public async Task Resolve_AnswersWithASyntheticProjectWhenThereIsNoneUpToTheFilesystemRoot() { + // Nothing written yet is a state, not a failure: the job that writes one has to be able to + // run here. What's missing is only reported when something needs it. Directory.CreateDirectory(_root); var project = await RittenProject.Resolve(_root, RittenProject.DefaultFileName, TestContext.Current.CancellationToken); - project.IsError.ShouldBeTrue(); - project.Errors.ShouldHaveSingleItem().Message.ShouldContain("No ritten.json found"); + project.IsSuccess.ShouldBeTrue(); + project.Value.ShouldNotBeNull().IsSynthetic.ShouldBeTrue(); + project.Value.GetWorkflowName().Errors.ShouldHaveSingleItem().Message.ShouldContain("No ritten.json found"); } [Fact] diff --git a/tests/Ritten.Tests/Engine/WorkflowApplicationTests.cs b/tests/Ritten.Tests/Engine/WorkflowApplicationTests.cs index bdd5bf2..9fc5b45 100644 --- a/tests/Ritten.Tests/Engine/WorkflowApplicationTests.cs +++ b/tests/Ritten.Tests/Engine/WorkflowApplicationTests.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.DependencyInjection; -using Ritten.CommandLine; using Ritten.Contracts; using Ritten.Engine; using Ritten.Engine.Workflows; @@ -59,7 +58,7 @@ public async Task Run_RunsTheDeclaredJobWithTheSharedServices() builder.Services.AddSingleton(Substitute.For()); var application = builder.Build().Value.ShouldNotBeNull(); - var exitCode = await application.Run(new RunJobArgs("verify") { Directory = _root }, Empty, TestContext.Current.CancellationToken); + var exitCode = await Run(application, "verify"); exitCode.ShouldBe(ExitCode.Success); probe.Ran.ShouldHaveSingleItem(); @@ -127,8 +126,7 @@ private async Task Run(TestJob job, JobArguments arguments, StepProbe? builder.Services.AddSingleton(Substitute.For()); var application = builder.Build().Value.ShouldNotBeNull(); - var args = new RunJobArgs(job.Name) { Directory = _root, Arguments = arguments }; - return await application.Run(args, Empty, TestContext.Current.CancellationToken); + return await Run(application, job.Name, arguments: arguments); } [Fact] @@ -137,7 +135,7 @@ public async Task Run_ReportsAJobTheWorkflowDoesNotDeclare() WriteRittenJson("""{ "workflow": "test" }"""); var application = Application(new TestWorkflow()); - var exitCode = await application.Run(new RunJobArgs("deploy") { Directory = _root }, Empty, TestContext.Current.CancellationToken); + var exitCode = await Run(application, "deploy"); exitCode.ShouldBe(ExitCode.ConfigurationError); } @@ -148,7 +146,7 @@ public async Task Run_ReportsAWorkflowTheApplicationDoesNotKnow() WriteRittenJson("""{ "workflow": "imaginary" }"""); var application = Application(new TestWorkflow()); - var exitCode = await application.Run(new RunJobArgs("verify") { Directory = _root }, Empty, TestContext.Current.CancellationToken); + var exitCode = await Run(application, "verify"); exitCode.ShouldBe(ExitCode.ConfigurationError); } @@ -167,18 +165,162 @@ public async Task Run_ResolvesTheProjectFileTheHostRenamed() builder.Services.AddSingleton(Substitute.For()); var application = builder.Build().Value.ShouldNotBeNull(); - var exitCode = await application.Run(new RunJobArgs("verify") { Directory = _root }, Empty, TestContext.Current.CancellationToken); + var exitCode = await Run(application, "verify"); exitCode.ShouldBe(ExitCode.Success); probe.Ran.ShouldHaveSingleItem(); } + [Fact] + public async Task Run_RunsAJobThatNeedsNoProjectWithoutOne() + { + // Nothing has been set up yet: the job that does the setting up is told which workflow to + // run, and reads the settings it would have loaded as their defaults. + var probe = new StepProbe(); + var application = Application(new TestWorkflow(jobs: + [new TestJob(name: "init", steps: [Step.FromType()], requiresProject: false)]), probe); + + var exitCode = await Run(application, "init", workflow: "test"); + + exitCode.ShouldBe(ExitCode.Success); + probe.Ran.ShouldHaveSingleItem(); + } + + [Fact] + public async Task Run_JudgesNoSettingsForAJobThatNeedsNoProject() + { + // There is nothing to judge in settings nobody has written: the job that writes them + // can't be refused for their not being there. + var job = new TestJob(name: "init", requiresProject: false, validate: settings => settings.Require(s => s.Build.Project)); + var application = Application(new TestWorkflow(jobs: [job])); + + var exitCode = await Run(application, "init", workflow: "test"); + + exitCode.ShouldBe(ExitCode.Success); + } + + [Fact] + public async Task Run_LetsAJobThatNeedsNoProjectFinishAHalfWrittenOne() + { + // A project file that exists but declares nothing is exactly what init is for; every + // other job still gets told the declaration is missing. + WriteRittenJson("""{ "build": { "project": "src/Thing/Thing.csproj" } }"""); + var probe = new StepProbe(); + var application = Application(new TestWorkflow(jobs: + [new TestJob(name: "init", steps: [Step.FromType()], requiresProject: false)]), probe); + + var exitCode = await Run(application, "init", workflow: "test"); + + exitCode.ShouldBe(ExitCode.Success); + probe.Ran.ShouldHaveSingleItem(); + } + + [Fact] + public async Task Run_ReportsAProjectThatDeclaresNoWorkflowForAJobThatNeedsOne() + { + WriteRittenJson("""{ "build": { "project": "src/Thing/Thing.csproj" } }"""); + var application = Application(new TestWorkflow(jobs: [new TestJob()], recognises: "there's a project here")); + + var exitCode = await Run(application, "verify"); + + exitCode.ShouldBe(ExitCode.ConfigurationError); + } + + [Fact] + public async Task Run_ReportsTheMissingProjectForAJobThatNeedsOne() + { + var application = Application(new TestWorkflow(jobs: [new TestJob()], recognises: "there's a project here")); + + var exitCode = await Run(application, "verify"); + + exitCode.ShouldBe(ExitCode.ConfigurationError); + } + + [Fact] + public async Task Run_RecognisesTheWorkflowWhenNothingDeclaresOne() + { + // Registration order is precedence: the first workflow to recognise the repository wins, + // and what it recognised is handed to the run so the job can say why it's doing this. + SelectedWorkflow? selected = null; + var builder = WorkflowApplication.CreateBuilder(); + builder.Workflows.Add(new TestWorkflow("indifferent", [new TestJob(name: "init", requiresProject: false)])); + builder.Workflows.Add(new TestWorkflow("specific", [ + new TestJob(name: "init", requiresProject: false, configure: (b, _) => selected = Selected(b)) + ], recognises: "it packs as a tool")); + builder.Services.AddSingleton(Substitute.For()); + var application = builder.Build().Value.ShouldNotBeNull(); + + var exitCode = await Run(application, "init"); + + exitCode.ShouldBe(ExitCode.Success); + selected.ShouldNotBeNull().Workflow.Name.ShouldBe("specific"); + selected.Recognised.ShouldBe("it packs as a tool"); + } + + [Fact] + public async Task Resolve_RefusesANameTheProjectContradicts() + { + // A repository that has declared its workflow has settled the question, so a name that + // disagrees is a mistaken belief worth reporting — never quietly discarded. + WriteRittenJson("""{ "workflow": "declared" }"""); + var builder = WorkflowApplication.CreateBuilder(); + builder.Workflows.Add(new TestWorkflow("declared", [new TestJob(name: "init", requiresProject: false)])); + builder.Workflows.Add(new TestWorkflow("named", [new TestJob(name: "init", requiresProject: false)])); + var application = builder.Build().Value.ShouldNotBeNull(); + + var selection = await application.SelectWorkflow(_root, "named", TestContext.Current.CancellationToken); + + selection.IsError.ShouldBeTrue(); + selection.Errors.First().Message.ShouldContain("'named' can't be run here"); + } + + [Fact] + public async Task Resolve_TakesTheNameWhenItAgreesWithTheProject() + { + WriteRittenJson("""{ "workflow": "declared" }"""); + var builder = WorkflowApplication.CreateBuilder(); + builder.Workflows.Add(new TestWorkflow("declared", [new TestJob(name: "init", requiresProject: false)])); + var application = builder.Build().Value.ShouldNotBeNull(); + + var selection = await application.SelectWorkflow(_root, "declared", TestContext.Current.CancellationToken); + + selection.IsSuccess.ShouldBeTrue(); + selection.Value.ShouldNotBeNull().Recognised.ShouldBeNull(); + } + + [Fact] + public async Task Run_ReportsAWorkflowNameNobodyKnows() + { + var application = Application(new TestWorkflow(jobs: [new TestJob(name: "init", requiresProject: false)])); + + var exitCode = await Run(application, "init", workflow: "imaginary"); + + exitCode.ShouldBe(ExitCode.ConfigurationError); + } + + /// What the run was assembled for, read back out of the registrations it made. + private static SelectedWorkflow? Selected(IWorkflowBuilder builder) => builder.Services + .FirstOrDefault(service => service.ServiceType == typeof(SelectedWorkflow))?.ImplementationInstance as SelectedWorkflow; + + /// + /// The whole path a command line takes: resolve what the directory asks for, then run the job + /// against it. + /// + private async Task Run(WorkflowApplication application, string job, string? workflow = null, JobArguments? arguments = null) + { + var ct = TestContext.Current.CancellationToken; + var selection = await application.SelectWorkflow(_root, workflow, ct); + return await application.Run(selection, new RunJobArgs(job) { Arguments = arguments ?? JobArguments.None }, Empty, ct); + } + private static Func Empty { get; } = _ => null; - private static WorkflowApplication Application(TestWorkflow workflow) + private static WorkflowApplication Application(TestWorkflow workflow, StepProbe? probe = null) { var builder = WorkflowApplication.CreateBuilder(); builder.Workflows.Add(workflow); + builder.Services.AddSingleton(probe ?? new StepProbe()); + builder.Services.AddSingleton(Substitute.For()); return builder.Build().Value.ShouldNotBeNull(); } diff --git a/tests/Ritten.Tests/Engine/Workflows/WorkflowRegistryTests.cs b/tests/Ritten.Tests/Engine/Workflows/WorkflowRegistryTests.cs index a27208f..62753b6 100644 --- a/tests/Ritten.Tests/Engine/Workflows/WorkflowRegistryTests.cs +++ b/tests/Ritten.Tests/Engine/Workflows/WorkflowRegistryTests.cs @@ -16,7 +16,7 @@ public void Add_TakesEachWorkflowsDeclaredJobs() .Add(new DotNetPackageWorkflow()); registry.Workflows.Select(p => p.Name).ShouldBe(["dotnet-tool", "dotnet-package"]); - registry.Find("dotnet-tool").ShouldNotBeNull().Jobs.Select(j => j.Name).ShouldBe(["status", "build", "install", "prepare", "check", "deploy"]); + registry.Find("dotnet-tool").ShouldNotBeNull().Jobs.Select(j => j.Name).ShouldBe(["init", "status", "build", "install", "prepare", "check", "deploy"]); } [Fact] diff --git a/tests/Ritten.Tests/GitHub/ActionsWorkflowTests.cs b/tests/Ritten.Tests/GitHub/ActionsWorkflowTests.cs new file mode 100644 index 0000000..f82c437 --- /dev/null +++ b/tests/Ritten.Tests/GitHub/ActionsWorkflowTests.cs @@ -0,0 +1,140 @@ +using Ritten.GitHub; + +namespace Ritten.Tests.GitHub; + +/// +/// The workflow file belongs to the repository, so what these tests pin is what survives being +/// written back: other jobs, other triggers, comments, and the shape of everything untouched. +/// +public class ActionsWorkflowTests +{ + private const string Existing = + """ + # Ours, hand tended. + name: CI + + on: + push: + branches: [ main ] # only main + + jobs: + # The important one. + lint: + runs-on: ubuntu-latest + steps: + - run: make lint + + check: + runs-on: ubuntu-latest + steps: + - name: Run check + run: dotnet ritten check + working-directory: services/api + + """; + + private const string Check = + """ + check: + runs-on: ubuntu-24.04 + steps: + - run: dotnet ritten check + """; + + [Fact] + public void ReadsWhatTheWorkflowRunsAndWhere() + { + var workflow = ActionsWorkflow.Parse(Existing); + + workflow.Name.ShouldBe("CI"); + workflow.Triggers.ShouldBe(["push"]); + workflow.Jobs.Select(job => job.Id).ShouldBe(["lint", "check"]); + + // What a job runs is how the tool that wrote it recognises its own, and the working + // directory is what tells one project's job from another's. + var check = workflow.Jobs.Last(); + check.Invokes("dotnet ritten check").ShouldBeTrue(); + check.Steps.ShouldHaveSingleItem().WorkingDirectory.ShouldBe("services/api"); + } + + [Fact] + public void ReplacesTheJobItOwnsAndNothingElse() + { + var written = ActionsWorkflow.Parse(Existing).WithJob("check", Check).Text; + + written.ShouldContain("runs-on: ubuntu-24.04"); + written.ShouldNotContain("working-directory: services/api"); + + // Everything that isn't the job stays exactly as the repository wrote it. + written.ShouldContain("# Ours, hand tended."); + written.ShouldContain("branches: [ main ] # only main"); + written.ShouldContain("# The important one."); + written.ShouldContain("- run: make lint"); + } + + [Fact] + public void AddsAJobTheWorkflowDoesNotHave() + { + var written = ActionsWorkflow.Parse(Existing).WithJob("deploy", " deploy:\n runs-on: ubuntu-latest").Text; + + ActionsWorkflow.Parse(written).Jobs.Select(job => job.Id).ShouldBe(["lint", "check", "deploy"]); + written.ShouldContain("- run: make lint"); + } + + [Fact] + public void AddsATriggerWithoutTouchingTheOnesAlreadyThere() + { + var written = ActionsWorkflow.Parse(Existing) + .WithTrigger("push", " push:\n branches: [ trunk ]") + .WithTrigger("pull_request", " pull_request:\n branches: [ main ]") + .Text; + + // A repository that narrowed its own branches meant to. + written.ShouldContain("branches: [ main ] # only main"); + written.ShouldNotContain("trunk"); + ActionsWorkflow.Parse(written).Triggers.ShouldBe(["push", "pull_request"]); + } + + [Fact] + public void WritingTheSameJobTwiceChangesNothing() + { + var once = ActionsWorkflow.Parse(Existing).WithJob("check", Check); + + once.WithJob("check", Check).Text.ShouldBe(once.Text); + } + + [Fact] + public void BuildsAWorkflowFromNothing() + { + var written = ActionsWorkflow + .Parse("name: Ritten\n\non:\n\njobs:\n") + .WithTrigger("push", " push:\n branches: [ main ]") + .WithJob("check", Check) + .Text; + + var parsed = ActionsWorkflow.Parse(written); + parsed.Name.ShouldBe("Ritten"); + parsed.Triggers.ShouldBe(["push"]); + parsed.Jobs.ShouldHaveSingleItem().Id.ShouldBe("check"); + } + + [Fact] + public void ReadsTheWorkingDirectoryEveryStepDefaultsTo() + { + var workflow = ActionsWorkflow.Parse( + """ + name: CI + + defaults: + run: + working-directory: services/web + + jobs: + check: + steps: + - run: dotnet ritten check + """); + + workflow.WorkingDirectory.ShouldBe("services/web"); + } +} diff --git a/tests/Ritten.Tests/Init/ActionsWorkflowTemplateTests.cs b/tests/Ritten.Tests/Init/ActionsWorkflowTemplateTests.cs new file mode 100644 index 0000000..8250840 --- /dev/null +++ b/tests/Ritten.Tests/Init/ActionsWorkflowTemplateTests.cs @@ -0,0 +1,53 @@ +using NuGet.Versioning; +using Ritten.Engine.Workflows; +using Ritten.GitHub; +using Ritten.Init; +using Ritten.Workflows.DotNet; +using Ritten.Workflows.DotNetTool; + +namespace Ritten.Tests.Init; + +/// +/// The whole of what a repository is handed, as the file itself rather than as assertions about +/// it. These snapshots are here to be read: the derivation is only worth trusting if you can see +/// what it produces. +/// +public class ActionsWorkflowTemplateTests +{ + private static readonly ToolPin Tool = new("ritten", "ritten", NuGetVersion.Parse("1.2.3")); + + [Fact] + public Task WritesTheWorkflowForARepositoryThatShipsATool() => VerifyWorkflow(new DotNetToolWorkflow(), "My.Tool", directory: null); + + [Fact] + public Task WritesTheWorkflowForOneProjectOfSeveral() => + VerifyWorkflow(new DotNetToolWorkflow(), "My.Tool", directory: "services/api"); + + [Fact] + public Task WritesTheWorkflowForARepositoryThatShipsNothing() => VerifyWorkflow(new DotNetWorkflow(), "My.App", directory: null); + + [Fact] + public void LeavesTheJobsSomebodyRunsByHandToThem() + { + // status, build, install, prepare and init are asked for; scaffolding them would be noise. + ActionsWorkflowTemplate.Automated(new DotNetToolWorkflow()).Select(job => job.Name).ShouldBe(["check", "deploy"]); + } + + private static Task VerifyWorkflow(IWorkflow workflow, string name, string? directory) + { + // Composed exactly as the step composes it: an empty document, then each job's triggers + // and the job itself. + var document = ActionsWorkflow.Parse(ActionsWorkflowTemplate.Document(name)); + foreach (var job in ActionsWorkflowTemplate.Automated(workflow)) + { + foreach (var (trigger, block) in ActionsWorkflowTemplate.Triggers(job)) + { + document = document.WithTrigger(trigger, block); + } + + document = document.WithJob(job.Name, ActionsWorkflowTemplate.Job(job, Tool, directory, "global.json")); + } + + return Verify(document.Text, "yml"); + } +} diff --git a/tests/Ritten.Tests/Init/EnsureActionsWorkflowTests.cs b/tests/Ritten.Tests/Init/EnsureActionsWorkflowTests.cs new file mode 100644 index 0000000..28000c4 --- /dev/null +++ b/tests/Ritten.Tests/Init/EnsureActionsWorkflowTests.cs @@ -0,0 +1,254 @@ +using Microsoft.Extensions.Options; +using NuGet.Versioning; +using Ritten.Contracts; +using Ritten.Contracts.FileSystem; +using Ritten.DotNet; +using Ritten.Engine; +using Ritten.Engine.Workflows; +using Ritten.Git; +using Ritten.GitHub; +using Ritten.Init; +using Ritten.Init.Steps; +using Ritten.Reporting; +using Ritten.Tests.Support; + +namespace Ritten.Tests.Init; + +/// +/// The workflow file is the repository's: Ritten finds the jobs it wrote by what they run, so a +/// file that has been renamed is updated rather than duplicated, and two projects in one +/// repository never claim the same file. +/// +public class EnsureActionsWorkflowTests +{ + private readonly IActionsWorkflows _actions = Substitute.For(); + private readonly IGit _git = Substitute.For(); + private readonly IFileSystem _fileSystem = Substitute.For(); + private readonly IDirectory _repository = Directory("/repo"); + private readonly IDirectory _root = Directory("/repo"); + private readonly IDirectory _nested = Directory("/repo/services/api"); + private readonly IFile _globalJson = File("global.json", exists: true); + private readonly List _existing = []; + private readonly Dictionary _files = []; + private readonly IFile _fresh = File("new.yml", exists: false); + private string _written = ""; + private string? _created; + + public EnsureActionsWorkflowTests() + { + _git.RepositoryRoot(Arg.Any()).Returns(_repository); + _fileSystem.ProjectRoot.Returns(_root); + _repository.GetFile(Arg.Any()).Returns(_globalJson); + + _actions.Files(Arg.Any()).Returns(_ => _existing); + _actions.Parse(Arg.Any()).Returns(call => new Result(ActionsWorkflow.Parse(call.Arg()))); + _actions.File(Arg.Any(), Arg.Any()).Returns(call => + { + _created = call.ArgAt(1); + return _files.TryGetValue(_created, out var file) ? file : _fresh; + }); + _actions.Render(Arg.Any()).Returns(call => call.Arg().Text); + _actions.Write(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => + { + _written = call.ArgAt(1).Text; + return Task.CompletedTask; + }); + } + + [Fact] + public async Task NamesTheWorkflowAfterTheProjectItBuilds() + { + // Not after the tool that wrote it: that would be the same name in every repository, and + // the same name twice in a repository of several projects. + var result = await Step().Run(Found("src/My.Tool/My.Tool.csproj"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeFalse(); + _created.ShouldBe("my-tool"); + _written.ShouldContain("name: My.Tool"); + _written.ShouldContain("run: dotnet ritten check"); + _written.ShouldContain("run: dotnet ritten deploy"); + + // Nothing to say: the project is the repository, so every step runs where it lands. + _written.ShouldNotContain("working-directory:"); + } + + [Fact] + public async Task PrefersTheProjectTheRepositoryDeclares() + { + // The first declared project is the face of whatever the repository ships; what's on disk + // only answers for a repository that hasn't declared anything yet. + await Step(declared: "src/My.Package/My.Package.csproj") + .Run(Found("src/Another/Another.csproj"), TestContext.Current.CancellationToken); + + _written.ShouldContain("name: My.Package"); + } + + [Fact] + public async Task GivesANestedProjectItsOwnNameAndDirectory() + { + _fileSystem.ProjectRoot.Returns(_nested); + + await Step().Run(Found("src/My.Tool/My.Tool.csproj"), TestContext.Current.CancellationToken); + + _created.ShouldBe("my-tool"); + _written.ShouldContain("name: My.Tool"); + _written.ShouldContain("working-directory: services/api"); + } + + [Fact] + public async Task NamesTheFileForTheDirectoryWhenAProjectOfTheSameNameGotThereFirst() + { + // Two projects can share a name in one repository even though their paths can't, and + // ensuring one project's jobs must never overwrite another's. + _fileSystem.ProjectRoot.Returns(_nested); + SetWorkflow("my-tool.yml", + """ + name: My.Tool + + jobs: + check: + steps: + - run: dotnet ritten check + working-directory: legacy/api + """); + + await Step().Run(Found("src/My.Tool/My.Tool.csproj"), TestContext.Current.CancellationToken); + + _created.ShouldBe("my-tool-services-api"); + } + + [Fact] + public async Task FindsItsOwnWorkflowHoweverItWasRenamed() + { + var ci = SetWorkflow("ci.yml", + """ + # Ours, hand tended. + name: CI + + on: + push: + branches: [ main ] + + jobs: + check: + runs-on: ubuntu-latest + steps: + - run: dotnet ritten check + """); + + await Step().Run(Found("src/My.Tool/My.Tool.csproj"), TestContext.Current.CancellationToken); + + // Found by what it runs, so the rename is followed rather than duplicated. + _created.ShouldBeNull(); + await _actions.Received().Write(ci, Arg.Any(), Arg.Any()); + _written.ShouldContain("# Ours, hand tended."); + _written.ShouldContain("run: dotnet ritten deploy"); + } + + [Fact] + public async Task LeavesAnotherProjectsWorkflowAlone() + { + // Same jobs, same commands, different project: this file belongs to services/web. + var web = SetWorkflow("my-lib.yml", + """ + name: My.Lib + + on: + push: + branches: [ main ] + + jobs: + check: + steps: + - run: dotnet ritten check + working-directory: services/web + """); + _fileSystem.ProjectRoot.Returns(_nested); + + await Step().Run(Found("src/My.Tool/My.Tool.csproj"), TestContext.Current.CancellationToken); + + _created.ShouldBe("my-tool"); + await _actions.DidNotReceive().Write(web, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task WritesNothingWhenTheWorkflowAlreadyRunsTheJobs() + { + await Step().Run(Found("src/My.Tool/My.Tool.csproj"), TestContext.Current.CancellationToken); + var first = _written; + SetWorkflow("my-tool.yml", first); + _actions.ClearReceivedCalls(); + + var result = await Step().Run(Found("src/My.Tool/My.Tool.csproj"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeFalse(); + await _actions.DidNotReceive().Write(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task RestoresTheJobItOwnsWhenItHasBeenEditedAway() + { + await Step().Run(Found("src/My.Tool/My.Tool.csproj"), TestContext.Current.CancellationToken); + SetWorkflow("my-tool.yml", _written.Replace(" pull-requests: write\n", "")); + + await Step().Run(Found("src/My.Tool/My.Tool.csproj"), TestContext.Current.CancellationToken); + + _written.ShouldContain("pull-requests: write"); + } + + [Fact] + public async Task SaysSoWhenThereIsNoRepositoryToWriteInto() + { + _git.RepositoryRoot(Arg.Any()).Returns((IDirectory?)null); + + var result = await Step().Run(Found("src/My.Tool/My.Tool.csproj"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeFalse(); + await _actions.DidNotReceive().Write(Arg.Any(), Arg.Any(), Arg.Any()); + } + + private static DiscoveredProjects Found(params string[] projects) => new(projects, []); + + private EnsureActionsWorkflow Step(string declared = "") => new( + Substitute.For(), + _actions, + _git, + _fileSystem, + Options.Create(new DotNetOptions { ProjectFile = declared }), + new SelectedWorkflow( + new TestWorkflow("dotnet-tool", [ + new TestJob(name: "build"), + new TestJob(name: "check", kind: JobKind.Check), + new TestJob(name: "deploy", kind: JobKind.Deploy) + ], label: "dotnet tool"), + RittenProject.Synthetic(Path.GetTempPath(), RittenProject.DefaultFileName)), + new ToolPin("ritten", "ritten", NuGetVersion.Parse("1.2.3")) + ); + + private IFile SetWorkflow(string name, string content) + { + var file = File(name, exists: true); + var parsed = new Result(ActionsWorkflow.Parse(content)); + _actions.Read(file, Arg.Any()).Returns(parsed); + _existing.Clear(); + _existing.Add(file); + _files[Path.GetFileNameWithoutExtension(name)] = file; + return file; + } + + private static IDirectory Directory(string path) + { + var directory = Substitute.For(); + directory.AbsolutePath.Returns(path); + return directory; + } + + private static IFile File(string name, bool exists) + { + var file = Substitute.For(); + file.Name.Returns(name); + file.Exists.Returns(exists); + return file; + } +} diff --git a/tests/Ritten.Tests/Init/EnsureChangelogTests.cs b/tests/Ritten.Tests/Init/EnsureChangelogTests.cs new file mode 100644 index 0000000..6a49181 --- /dev/null +++ b/tests/Ritten.Tests/Init/EnsureChangelogTests.cs @@ -0,0 +1,87 @@ +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Ritten.Changelogs; +using Ritten.Contracts.FileSystem; +using Ritten.Init.Steps; +using Ritten.Reporting; +using Ritten.Tests.Engine.Helpers; +using Ritten.Tests.Support; +using Ritten.Workflows; + +namespace Ritten.Tests.Init; + +public class EnsureChangelogTests +{ + private static readonly IChangelog Changelogs = WorkflowRunBuilderHelpers.Create() + .AddChangelogs(new ChangelogSettings()) + .Services.BuildServiceProvider() + .GetRequiredService(); + + private readonly IFileSystem _fileSystem = Substitute.For(); + private readonly ChangelogOptions _options = TestOptions.Changelog(); + private MemoryStream _written = new(); + + [Fact] + public async Task WritesAChangelogWhenThereIsNone() + { + SetChangelog(exists: false); + + var result = await Step().Run(TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeFalse(); + var written = Written(); + written.ShouldContain("# Changelog"); + written.ShouldContain("Keep a Changelog"); + written.ShouldContain("## [Unreleased]"); + } + + [Fact] + public async Task GivesAChangelogSomewhereToWriteTheNextRelease() + { + SetChangelog(exists: true, content: + """ + # Changelog + + ## [1.0.0] - 2026-01-01 + + ### Added + + - **A thing.** It does something. + """); + + await Step().Run(TestContext.Current.CancellationToken); + + // The unreleased notes go above everything already shipped, and nobody's prose is touched. + var written = Written(); + written.IndexOf("## [Unreleased]", StringComparison.Ordinal).ShouldBeLessThan(written.IndexOf("## [1.0.0]", StringComparison.Ordinal)); + written.ShouldContain("- **A thing.** It does something."); + } + + [Fact] + public async Task LeavesAChangelogThatAlreadyHasOne() + { + var file = SetChangelog(exists: true, content: "# Changelog\n\n## [Unreleased]\n"); + + var result = await Step().Run(TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeFalse(); + file.DidNotReceive().OpenWrite(); + } + + private EnsureChangelog Step() => + new(Substitute.For(), Microsoft.Extensions.Options.Options.Create(_options), _fileSystem, Changelogs); + + private string Written() => Encoding.UTF8.GetString(_written.ToArray()); + + private IFile SetChangelog(bool exists, string content = "") + { + _written = new MemoryStream(); + var file = Substitute.For(); + file.Name.Returns(_options.File); + file.Exists.Returns(exists); + file.OpenRead().Returns(_ => new MemoryStream(Encoding.UTF8.GetBytes(content))); + file.OpenWrite().Returns(_ => _written); + _fileSystem.ProjectRoot.GetFile(_options.File).Returns(file); + return file; + } +} diff --git a/tests/Ritten.Tests/Init/EnsureRittenProjectTests.cs b/tests/Ritten.Tests/Init/EnsureRittenProjectTests.cs new file mode 100644 index 0000000..53681ae --- /dev/null +++ b/tests/Ritten.Tests/Init/EnsureRittenProjectTests.cs @@ -0,0 +1,147 @@ +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Ritten.Contracts; +using Ritten.Contracts.FileSystem; +using Ritten.DotNet; +using Ritten.Engine; +using Ritten.Engine.Workflows; +using Ritten.Init.Steps; +using Ritten.Reporting; +using Ritten.Tests.Engine.Helpers; +using Ritten.Tests.Support; + +namespace Ritten.Tests.Init; + +/// +/// Exercises the real document client, so what these tests assert is the file that lands on disk. +/// +public class EnsureRittenProjectTests +{ + private static readonly IProjectFiles Files = WorkflowRunBuilderHelpers.Create() + .Services.BuildServiceProvider() + .GetRequiredService(); + + private readonly IFileSystem _fileSystem = Substitute.For(); + private readonly IWorkflowPrompt _prompt = Substitute.For(); + private MemoryStream _written = new(); + + public EnsureRittenProjectTests() + { + _prompt.IsInteractive.Returns(true); + _prompt.Confirm(Arg.Any(), Arg.Any()).Returns(true); + } + + [Fact] + public async Task WritesTheWorkflowAndTheProjectItBuilds() + { + var file = SetProjectFile(exists: false); + + var result = await Step().Run(Found("src/Thing/Thing.csproj"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeFalse(); + file.Received().OpenWrite(); + Written().ShouldContain("\"workflow\": \"dotnet-tool\""); + Written().ShouldContain("\"project\": \"src/Thing/Thing.csproj\""); + } + + [Fact] + public async Task WritesEveryProjectWhenTheRepositoryShipsSeveral() + { + SetProjectFile(exists: false); + + await Step().Run(Found("src/A/A.csproj", "src/B/B.csproj"), TestContext.Current.CancellationToken); + + // One package is spelled singular and several plural: the same setting, said the way the + // repository would say it. + Written().ShouldContain("\"projects\""); + Written().ShouldContain("src/B/B.csproj"); + } + + [Fact] + public async Task LeavesWhatTheProjectFileAlreadySays() + { + var file = SetProjectFile(exists: true, content: """{ "workflow": "dotnet-tool", "build": { "projects": ["src/Only/Only.csproj"] } }"""); + + var result = await Step().Run(Found("src/Thing/Thing.csproj"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeFalse(); + file.DidNotReceive().OpenWrite(); + } + + [Fact] + public async Task KeepsKeysItHasNeverHeardOf() + { + SetProjectFile(exists: true, content: """{ "somethingNewer": { "keep": "me" } }"""); + + await Step().Run(Found("src/Thing/Thing.csproj"), TestContext.Current.CancellationToken); + + Written().ShouldContain("\"keep\": \"me\""); + Written().ShouldContain("\"workflow\": \"dotnet-tool\""); + } + + [Fact] + public async Task AsksBeforeWritingDownAWorkflowNobodyDeclared() + { + var file = SetProjectFile(exists: false); + _prompt.Confirm(Arg.Any(), Arg.Any()).Returns(false); + + var result = await Step().Run(Found("src/Thing/Thing.csproj"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + file.DidNotReceive().OpenWrite(); + } + + [Fact] + public async Task RefusesToGuessWithNobodyThereToConfirm() + { + // Hanging on a build agent waiting for a person is worse than refusing to start. + SetProjectFile(exists: false); + _prompt.IsInteractive.Returns(false); + + var result = await Step().Run(Found("src/Thing/Thing.csproj"), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeTrue(); + result.Errors.ShouldNotBeNull().ShouldHaveSingleItem().Message.ShouldContain("--workflow"); + } + + [Fact] + public async Task AsksNothingWhenTheProjectDeclaredTheWorkflow() + { + // Only a guess is worth confirming; topping up a repository that already said what it + // runs is not. + SetProjectFile(exists: true, content: """{ "workflow": "dotnet-tool" }"""); + + await Step(recognised: null).Run(Found("src/Thing/Thing.csproj"), TestContext.Current.CancellationToken); + + await _prompt.DidNotReceive().Confirm(Arg.Any(), Arg.Any()); + } + + private static DiscoveredProjects Found(params string[] projects) => new(projects, []); + + private EnsureRittenProject Step(string? recognised = "src/Thing/Thing.csproj packs as a tool") => new( + Substitute.For(), + Files, + _fileSystem, + Project, + new SelectedWorkflow(new TestWorkflow("dotnet-tool", label: "dotnet tool"), Project, recognised), + new WorkflowJob("dotnet tool", "init"), + _prompt + ); + + /// A repository that hasn't written a project file yet. + private static RittenProject Project { get; } = RittenProject.Synthetic(Path.GetTempPath(), RittenProject.DefaultFileName); + + private string Written() => Encoding.UTF8.GetString(_written.ToArray()); + + private IFile SetProjectFile(bool exists, string content = "") + { + _written = new MemoryStream(); + var file = Substitute.For(); + file.Name.Returns(RittenProject.DefaultFileName); + file.Exists.Returns(exists); + file.OpenRead().Returns(_ => new MemoryStream(Encoding.UTF8.GetBytes(content))); + file.OpenWrite().Returns(_ => _written); + _fileSystem.ProjectRoot.GetFile(RittenProject.DefaultFileName).Returns(file); + return file; + } +} diff --git a/tests/Ritten.Tests/Init/EnsureToolManifestTests.cs b/tests/Ritten.Tests/Init/EnsureToolManifestTests.cs new file mode 100644 index 0000000..4c896ca --- /dev/null +++ b/tests/Ritten.Tests/Init/EnsureToolManifestTests.cs @@ -0,0 +1,132 @@ +using NuGet.Versioning; +using Ritten.Contracts.FileSystem; +using Ritten.DotNet; +using Ritten.Git; +using Ritten.Init; +using Ritten.Init.Steps; +using Ritten.Reporting; + +namespace Ritten.Tests.Init; + +/// +/// The manifest's schema is the SDK's, so these tests pin what Ritten asks the SDK for rather +/// than any JSON: a repository that pins other tools keeps them because Ritten never writes the +/// file itself. +/// +public class EnsureToolManifestTests +{ + private static readonly NuGetVersion Version = NuGetVersion.Parse("1.2.3"); + + private readonly IDotNet _dotnet = Substitute.For(); + private readonly IGit _git = Substitute.For(); + private readonly IFileSystem _fileSystem = Substitute.For(); + private readonly IDirectory _repository = Substitute.For(); + private readonly IDirectory _project = Substitute.For(); + + public EnsureToolManifestTests() + { + _fileSystem.ProjectRoot.Returns(_project); + _git.RepositoryRoot(Arg.Any()).Returns(_repository); + SetManifest(exists: false); + } + + [Fact] + public async Task CreatesAManifestWhenTheRepositoryHasNone() + { + var result = await Step().Run(TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeFalse(); + await _dotnet.Received().CreateToolManifest(_repository, Arg.Any()); + await _dotnet.Received().ToolInstall(Arg.Is(a => a.PackageId == "ritten" && a.Version == Version), Arg.Any()); + } + + [Theory] + [InlineData(".config/dotnet-tools.json")] + [InlineData("dotnet-tools.json")] + public async Task LeavesAManifestTheRepositoryAlreadyHas(string path) + { + // Both places are ones the SDK reads, and creating a second manifest beside the first + // would leave the repository pinning two different sets of tools. + SetManifest(exists: true, at: path); + + await Step().Run(TestContext.Current.CancellationToken); + + await _dotnet.DidNotReceive().CreateToolManifest(Arg.Any(), Arg.Any()); + await _dotnet.Received().ToolInstall(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DoesNothingWhenTheVersionIsAlreadyPinned() + { + Pinned(Version); + + var result = await Step().Run(TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeFalse(); + await _dotnet.DidNotReceive().ToolInstall(Arg.Any(), Arg.Any()); + await _dotnet.DidNotReceive().ToolUpdate(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task MovesAPinThatIsBehind() + { + // Install refuses a tool the manifest already pins; update is the SDK's word for moving + // one, and which of the two it is, is decided here rather than hidden in the client. + Pinned(NuGetVersion.Parse("1.0.0")); + + await Step().Run(TestContext.Current.CancellationToken); + + await _dotnet.Received().ToolUpdate(Arg.Is(a => a.Version == Version), Arg.Any()); + await _dotnet.DidNotReceive().ToolInstall(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PinsAtTheRepositoryRootRatherThanTheProject() + { + // One manifest at the root serves every project in a repository of several: the SDK finds + // it by walking up, the same way Ritten finds a project file. + await Step().Run(TestContext.Current.CancellationToken); + + await _dotnet.Received().ToolInstall(Arg.Is(a => a.Scope.Directory == _repository), Arg.Any()); + } + + [Fact] + public async Task FallsBackToTheProjectWhenThereIsNoRepository() + { + _git.RepositoryRoot(Arg.Any()).Returns((IDirectory?)null); + var missing = Missing(); + _project.GetFile(Arg.Any()).Returns(missing); + + await Step().Run(TestContext.Current.CancellationToken); + + await _dotnet.Received().ToolInstall(Arg.Is(a => a.Scope.Directory == _project), Arg.Any()); + } + + /// What the manifest governing the repository already pins. + private void Pinned(NuGetVersion version) => + _dotnet.InstalledToolVersion("ritten", Arg.Is(s => s.Directory == _repository), Arg.Any()).Returns(version); + + private EnsureToolManifest Step() => + new(Substitute.For(), _dotnet, _git, _fileSystem, new ToolPin("ritten", "ritten", Version)); + + private void SetManifest(bool exists, string at = ".config/dotnet-tools.json") + { + var missing = Missing(); + _repository.GetFile(Arg.Any()).Returns(missing); + if (!exists) + { + return; + } + + var file = Substitute.For(); + file.Exists.Returns(true); + _repository.GetFile(at).Returns(file); + } + + private static IFile Missing() + { + var file = Substitute.For(); + file.Exists.Returns(false); + return file; + } +} diff --git a/tests/Ritten.Tests/Init/RepositoryScaffoldTests.cs b/tests/Ritten.Tests/Init/RepositoryScaffoldTests.cs deleted file mode 100644 index 904ba74..0000000 --- a/tests/Ritten.Tests/Init/RepositoryScaffoldTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Text; -using Ritten.Engine.Workflows; -using Ritten.Init; -using Ritten.Workflows.DotNet; -using Ritten.Workflows.DotNetPackage; -using Ritten.Workflows.DotNetTool; - -namespace Ritten.Tests.Init; - -/// -/// The whole of what a repository is handed, as the files themselves rather than as assertions -/// about them. These snapshots are here to be read: the derivation is only worth trusting if you -/// can see what it produces. -/// -public class RepositoryScaffoldTests -{ - [Fact] - public Task ScaffoldsARepositoryThatShipsATool() => VerifyScaffold(new DotNetToolWorkflow()); - - [Fact] - public Task ScaffoldsARepositoryThatShipsAPackage() => VerifyScaffold(new DotNetPackageWorkflow()); - - [Fact] - public Task ScaffoldsARepositoryThatShipsNothing() => VerifyScaffold(new DotNetWorkflow()); - - private static Task VerifyScaffold(IWorkflow workflow) - { - // A fixed version, so a snapshot doesn't move every time Ritten is released. - var files = RepositoryScaffold.For(workflow, "src/My.Tool/My.Tool.csproj", "1.2.3", "ritten.json"); - - var document = new StringBuilder(); - foreach (var file in files) - { - document - .Append("──────── ").Append(file.Path) - .Append(file.Generated ? " (generated)" : " (seed)").Append('\n') - .Append(file.Content) - .Append('\n'); - } - - return Verify(document.ToString()); - } -} diff --git a/tests/Ritten.Tests/Init/ScaffolderTests.cs b/tests/Ritten.Tests/Init/ScaffolderTests.cs deleted file mode 100644 index 276f627..0000000 --- a/tests/Ritten.Tests/Init/ScaffolderTests.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Text; -using Ritten.Contracts.FileSystem; -using Ritten.Init; - -namespace Ritten.Tests.Init; - -public class ScaffolderTests -{ - private readonly IFileSystem _fileSystem = Substitute.For(); - private readonly IDirectory _root = Substitute.For(); - - public ScaffolderTests() => _fileSystem.ProjectRoot.Returns(_root); - - [Fact] - public async Task WritesWhatIsMissing() - { - var written = SetFile("ritten.json", exists: false); - - var outcomes = await Apply(new ScaffoldedFile("ritten.json", "{}\n")); - - outcomes.ShouldHaveSingleItem().Outcome.ShouldBe(ScaffoldOutcome.Written); - Encoding.UTF8.GetString(written.ToArray()).ShouldBe("{}\n"); - } - - [Fact] - public async Task NeverOverwritesWhatIsAlreadyThere() - { - // A repository's files are its own; silently replacing an edited one would be the worst - // possible way to find that out. - var written = SetFile("ritten.json", exists: true, content: "{ \"workflow\": \"mine\" }"); - - var outcomes = await Apply(new ScaffoldedFile("ritten.json", "{}\n")); - - outcomes.ShouldHaveSingleItem().Outcome.ShouldBe(ScaffoldOutcome.Matches); - written.ToArray().ShouldBeEmpty(); - } - - [Fact] - public async Task ChecksGeneratedFilesForDrift() - { - SetFile("ritten.yml", exists: true, content: "hand edited"); - - var outcomes = await Apply(new ScaffoldedFile("ritten.yml", "generated", Generated: true)); - - outcomes.ShouldHaveSingleItem().Outcome.ShouldBe(ScaffoldOutcome.Differs); - } - - [Fact] - public async Task LeavesSeedsAloneEvenWhenTheyDiffer() - { - // A changelog diverges the moment anybody writes an entry, so checking it would report - // drift on every repository, immediately. - SetFile("CHANGELOG.md", exists: true, content: "## [1.0.0]\n\nReal entries.\n"); - - var outcomes = await Apply(new ScaffoldedFile("CHANGELOG.md", "# Changelog\n")); - - outcomes.ShouldHaveSingleItem().Outcome.ShouldBe(ScaffoldOutcome.Matches); - } - - [Fact] - public async Task WritesNothingWhenChecking() - { - var written = SetFile("ritten.json", exists: false); - - var outcomes = await Apply(new ScaffoldedFile("ritten.json", "{}\n"), ScaffoldMode.Check); - - outcomes.ShouldHaveSingleItem().Outcome.ShouldBe(ScaffoldOutcome.Written); - written.ToArray().ShouldBeEmpty(); - } - - [Fact] - public async Task RewritesAGeneratedFileThatHasDrifted() - { - var written = SetFile("ritten.yml", exists: true, content: "hand edited"); - - var outcomes = await Apply(new ScaffoldedFile("ritten.yml", "generated", Generated: true), ScaffoldMode.Rewrite); - - outcomes.ShouldHaveSingleItem().Outcome.ShouldBe(ScaffoldOutcome.Rewritten); - Encoding.UTF8.GetString(written.ToArray()).ShouldBe("generated"); - } - - [Fact] - public async Task LeavesSeedsAloneEvenWhenRewriting() - { - // --force is for what Ritten generates. A changelog is never Ritten's to overwrite. - var written = SetFile("CHANGELOG.md", exists: true, content: "Real entries."); - - var outcomes = await Apply(new ScaffoldedFile("CHANGELOG.md", "# Changelog\n"), ScaffoldMode.Rewrite); - - outcomes.ShouldHaveSingleItem().Outcome.ShouldBe(ScaffoldOutcome.Matches); - written.ToArray().ShouldBeEmpty(); - } - - private MemoryStream SetFile(string path, bool exists, string content = "") - { - var written = new MemoryStream(); - var file = Substitute.For(); - file.Exists.Returns(exists); - file.AbsolutePath.Returns($"/repo/{path}"); - file.OpenRead().Returns(_ => new MemoryStream(Encoding.UTF8.GetBytes(content))); - file.OpenWrite().Returns(_ => written); - _root.GetFile(path).Returns(file); - return written; - } - - private async Task> Apply(ScaffoldedFile file, ScaffoldMode mode = ScaffoldMode.Write) => - await new Scaffolder(_fileSystem).Apply([file], _root, mode, TestContext.Current.CancellationToken); -} diff --git a/tests/Ritten.Tests/Init/Snapshots/RepositoryScaffoldTests.ScaffoldsARepositoryThatShipsATool.verified.txt b/tests/Ritten.Tests/Init/Snapshots/ActionsWorkflowTemplateTests.WritesTheWorkflowForARepositoryThatShipsATool.verified.yml similarity index 70% rename from tests/Ritten.Tests/Init/Snapshots/RepositoryScaffoldTests.ScaffoldsARepositoryThatShipsATool.verified.txt rename to tests/Ritten.Tests/Init/Snapshots/ActionsWorkflowTemplateTests.WritesTheWorkflowForARepositoryThatShipsATool.verified.yml index fe4b75f..3667dfc 100644 --- a/tests/Ritten.Tests/Init/Snapshots/RepositoryScaffoldTests.ScaffoldsARepositoryThatShipsATool.verified.txt +++ b/tests/Ritten.Tests/Init/Snapshots/ActionsWorkflowTemplateTests.WritesTheWorkflowForARepositoryThatShipsATool.verified.yml @@ -1,49 +1,16 @@ -──────── ritten.json (seed) -{ - "workflow": "dotnet-tool", - "build": { - "project": "src/My.Tool/My.Tool.csproj" - } -} - -──────── CHANGELOG.md (seed) -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -──────── .config/dotnet-tools.json (generated) -{ - "version": 1, - "isRoot": true, - "tools": { - "ritten": { - "version": "1.2.3", - "commands": [ - "ritten" - ], - "rollForward": false - } - } -} - -──────── .github/workflows/ritten.yml (generated) -name: Ritten +name: My.Tool on: + pull_request: + branches: [ main ] + push: + branches: [ main ] workflow_dispatch: inputs: dry-run: description: 'Dry Run' type: boolean default: false - pull_request: - branches: [ main ] - push: - branches: [ main ] jobs: check: @@ -55,8 +22,9 @@ jobs: # The report is posted as a pull request comment. pull-requests: write concurrency: - # A newer push to the same branch supersedes any run still going. - group: check-${{ github.ref }} + # A newer push to the same branch supersedes any run still going. The workflow's own + # name keeps one project's runs from cancelling another's. + group: ${{ github.workflow }}-check-${{ github.ref }} cancel-in-progress: true steps: - name: Checkout repository @@ -86,7 +54,7 @@ jobs: id-token: write concurrency: # Releases queue rather than interleave. - group: deploy + group: ${{ github.workflow }}-deploy cancel-in-progress: false steps: - name: Checkout repository @@ -113,4 +81,3 @@ jobs: RITTEN_NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} RITTEN_COMMIT_SHA: ${{ github.sha }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/tests/Ritten.Tests/Init/Snapshots/RepositoryScaffoldTests.ScaffoldsARepositoryThatShipsNothing.verified.txt b/tests/Ritten.Tests/Init/Snapshots/ActionsWorkflowTemplateTests.WritesTheWorkflowForARepositoryThatShipsNothing.verified.yml similarity index 50% rename from tests/Ritten.Tests/Init/Snapshots/RepositoryScaffoldTests.ScaffoldsARepositoryThatShipsNothing.verified.txt rename to tests/Ritten.Tests/Init/Snapshots/ActionsWorkflowTemplateTests.WritesTheWorkflowForARepositoryThatShipsNothing.verified.yml index a3a851e..4416489 100644 --- a/tests/Ritten.Tests/Init/Snapshots/RepositoryScaffoldTests.ScaffoldsARepositoryThatShipsNothing.verified.txt +++ b/tests/Ritten.Tests/Init/Snapshots/ActionsWorkflowTemplateTests.WritesTheWorkflowForARepositoryThatShipsNothing.verified.yml @@ -1,37 +1,4 @@ -──────── ritten.json (seed) -{ - "workflow": "dotnet", - "build": { - "project": "src/My.Tool/My.Tool.csproj" - } -} - -──────── CHANGELOG.md (seed) -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -──────── .config/dotnet-tools.json (generated) -{ - "version": 1, - "isRoot": true, - "tools": { - "ritten": { - "version": "1.2.3", - "commands": [ - "ritten" - ], - "rollForward": false - } - } -} - -──────── .github/workflows/ritten.yml (generated) -name: Ritten +name: My.App on: pull_request: @@ -49,8 +16,9 @@ jobs: # The report is posted as a pull request comment. pull-requests: write concurrency: - # A newer push to the same branch supersedes any run still going. - group: check-${{ github.ref }} + # A newer push to the same branch supersedes any run still going. The workflow's own + # name keeps one project's runs from cancelling another's. + group: ${{ github.workflow }}-check-${{ github.ref }} cancel-in-progress: true steps: - name: Checkout repository @@ -69,4 +37,3 @@ jobs: shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/tests/Ritten.Tests/Init/Snapshots/RepositoryScaffoldTests.ScaffoldsARepositoryThatShipsAPackage.verified.txt b/tests/Ritten.Tests/Init/Snapshots/ActionsWorkflowTemplateTests.WritesTheWorkflowForOneProjectOfSeveral.verified.yml similarity index 70% rename from tests/Ritten.Tests/Init/Snapshots/RepositoryScaffoldTests.ScaffoldsARepositoryThatShipsAPackage.verified.txt rename to tests/Ritten.Tests/Init/Snapshots/ActionsWorkflowTemplateTests.WritesTheWorkflowForOneProjectOfSeveral.verified.yml index 39d8392..3df4b00 100644 --- a/tests/Ritten.Tests/Init/Snapshots/RepositoryScaffoldTests.ScaffoldsARepositoryThatShipsAPackage.verified.txt +++ b/tests/Ritten.Tests/Init/Snapshots/ActionsWorkflowTemplateTests.WritesTheWorkflowForOneProjectOfSeveral.verified.yml @@ -1,49 +1,16 @@ -──────── ritten.json (seed) -{ - "workflow": "dotnet-package", - "build": { - "project": "src/My.Tool/My.Tool.csproj" - } -} - -──────── CHANGELOG.md (seed) -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -──────── .config/dotnet-tools.json (generated) -{ - "version": 1, - "isRoot": true, - "tools": { - "ritten": { - "version": "1.2.3", - "commands": [ - "ritten" - ], - "rollForward": false - } - } -} - -──────── .github/workflows/ritten.yml (generated) -name: Ritten +name: My.Tool on: + pull_request: + branches: [ main ] + push: + branches: [ main ] workflow_dispatch: inputs: dry-run: description: 'Dry Run' type: boolean default: false - pull_request: - branches: [ main ] - push: - branches: [ main ] jobs: check: @@ -55,8 +22,9 @@ jobs: # The report is posted as a pull request comment. pull-requests: write concurrency: - # A newer push to the same branch supersedes any run still going. - group: check-${{ github.ref }} + # A newer push to the same branch supersedes any run still going. The workflow's own + # name keeps one project's runs from cancelling another's. + group: ${{ github.workflow }}-check-${{ github.ref }} cancel-in-progress: true steps: - name: Checkout repository @@ -69,10 +37,12 @@ jobs: - name: Restore tools run: dotnet tool restore + working-directory: services/api - name: Run check run: dotnet ritten check shell: bash + working-directory: services/api env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -86,7 +56,7 @@ jobs: id-token: write concurrency: # Releases queue rather than interleave. - group: deploy + group: ${{ github.workflow }}-deploy cancel-in-progress: false steps: - name: Checkout repository @@ -99,6 +69,7 @@ jobs: - name: Restore tools run: dotnet tool restore + working-directory: services/api - name: NuGet login uses: NuGet/login@v1 @@ -109,8 +80,8 @@ jobs: - name: Run deploy run: dotnet ritten deploy --auto-approve ${{ inputs['dry-run'] && '--dry-run' || '' }} shell: bash + working-directory: services/api env: RITTEN_NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} RITTEN_COMMIT_SHA: ${{ github.sha }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/tests/Ritten.Tests/Init/WorkflowYamlTests.cs b/tests/Ritten.Tests/Init/WorkflowYamlTests.cs deleted file mode 100644 index 18de051..0000000 --- a/tests/Ritten.Tests/Init/WorkflowYamlTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Ritten.Contracts; -using Ritten.Init; -using Ritten.Tests.Support; - -namespace Ritten.Tests.Init; - -/// -/// What runs when, and with what permissions, is read from the job model — so these assert the -/// derivation rather than the exact YAML, which is free to be reworded. -/// -public class WorkflowYamlTests -{ - [Fact] - public void ACheckingJobRunsOnEveryChange() - { - var yaml = WorkflowYaml.Render(new TestWorkflow(jobs: [Job("check", JobKind.Check)])); - - yaml.ShouldContain("pull_request:"); - yaml.ShouldContain("push:"); - yaml.ShouldContain("run: dotnet ritten check"); - - // It posts the report as a comment, so it needs to be able to write one. - yaml.ShouldContain("pull-requests: write"); - - // A newer push supersedes a check still running. - yaml.ShouldContain("cancel-in-progress: true"); - } - - [Fact] - public void ADeployingJobRunsOnlyWhenAsked() - { - var yaml = WorkflowYaml.Render(new TestWorkflow(jobs: [Job("deploy", JobKind.Deploy)])); - - yaml.ShouldContain("workflow_dispatch:"); - yaml.ShouldContain("github.event_name == 'workflow_dispatch'"); - - // Tags and releases are written back, and releases queue rather than interleave. - yaml.ShouldContain("contents: write"); - yaml.ShouldContain("cancel-in-progress: false"); - - // Nobody is at the terminal to confirm a release. - yaml.ShouldContain("dotnet ritten deploy --auto-approve"); - } - - [Fact] - public void AWorkflowThatReleasesNothingGetsNoDeployJob() - { - // The whole point of deriving it: a repository is never offered CI it can't run. - var yaml = WorkflowYaml.Render(new TestWorkflow(jobs: [Job("build", JobKind.Work), Job("check", JobKind.Check)])); - - yaml.ShouldNotContain("workflow_dispatch"); - yaml.ShouldNotContain("contents: write"); - yaml.ShouldNotContain("NUGET"); - } - - [Fact] - public void WorkJobsAreLeftToThePersonWhoWantsThem() - { - // status, build, install and prepare are run by hand; scaffolding them would be noise. - var yaml = WorkflowYaml.Render(new TestWorkflow(jobs: [Job("install", JobKind.Work), Job("prepare", JobKind.Work)])); - - yaml.ShouldNotContain("install"); - yaml.ShouldNotContain("prepare"); - } - - private static TestJob Job(string name, JobKind kind) => new(name, kind: kind); -} diff --git a/tests/Ritten.Tests/Support/TestJob.cs b/tests/Ritten.Tests/Support/TestJob.cs index 4202a51..a2861fa 100644 --- a/tests/Ritten.Tests/Support/TestJob.cs +++ b/tests/Ritten.Tests/Support/TestJob.cs @@ -14,7 +14,8 @@ internal sealed class TestJob( Action>? validate = null, IReadOnlyList? arguments = null, JobKind kind = JobKind.Work, - Action? configure = null + Action? configure = null, + bool requiresProject = true ) : Job { public override string Name => name; @@ -27,6 +28,8 @@ internal sealed class TestJob( public override IReadOnlyList Arguments { get; } = arguments ?? []; + public override bool RequiresProject => requiresProject; + protected override void ValidateSettings(SettingsValidator settings) => validate?.Invoke(settings); protected override void Configure(IWorkflowBuilder builder, DotNetToolSettings settings, JobArguments args) => diff --git a/tests/Ritten.Tests/Support/TestWorkflow.cs b/tests/Ritten.Tests/Support/TestWorkflow.cs index 29fd52a..591a605 100644 --- a/tests/Ritten.Tests/Support/TestWorkflow.cs +++ b/tests/Ritten.Tests/Support/TestWorkflow.cs @@ -1,3 +1,4 @@ +using Ritten.Contracts.FileSystem; using Ritten.Engine.Workflows; namespace Ritten.Tests.Support; @@ -5,11 +6,19 @@ namespace Ritten.Tests.Support; /// /// A workflow declared inline: the jobs a test hands it, nothing more. /// -internal sealed class TestWorkflow(string name = "test", IReadOnlyList? jobs = null) : IWorkflow +internal sealed class TestWorkflow( + string name = "test", + IReadOnlyList? jobs = null, + string? label = null, + string? recognises = null +) : IWorkflow { public string Name => name; - public string Label => "Test"; + public string Label => label ?? "Test"; public IReadOnlyList Jobs { get; } = jobs ?? []; + + public Task IsCompatible(IDirectory repository, CancellationToken cancellationToken = default) => + Task.FromResult(recognises); } diff --git a/tests/Ritten.Tests/Workflows/DotNetPackageWorkflowTests.cs b/tests/Ritten.Tests/Workflows/DotNetPackageWorkflowTests.cs index f2f75d2..255578d 100644 --- a/tests/Ritten.Tests/Workflows/DotNetPackageWorkflowTests.cs +++ b/tests/Ritten.Tests/Workflows/DotNetPackageWorkflowTests.cs @@ -14,6 +14,7 @@ public class DotNetPackageWorkflowTests private const string Complete = """{ "build": { "project": "src/Thing/Thing.csproj" } }"""; [Theory] + [InlineData("init")] [InlineData("status")] [InlineData("build")] [InlineData("prepare")] @@ -28,10 +29,12 @@ public void EveryJobTheCliOffers_Builds(string job) result.Value.Dispose(); } - [Fact] - public void Build_DoesNotRequireAProject() + [Theory] + [InlineData("build")] + [InlineData("init")] + public void JobsThatShipNothing_DoNotRequireAProject(string job) { - var result = Build("build", "{}"); + var result = Build(job, "{}"); result.IsSuccess.ShouldBeTrue(); result.Value.Dispose(); diff --git a/tests/Ritten.Tests/Workflows/DotNetToolWorkflowTests.cs b/tests/Ritten.Tests/Workflows/DotNetToolWorkflowTests.cs index 890752d..3131c63 100644 --- a/tests/Ritten.Tests/Workflows/DotNetToolWorkflowTests.cs +++ b/tests/Ritten.Tests/Workflows/DotNetToolWorkflowTests.cs @@ -16,10 +16,11 @@ public class DotNetToolWorkflowTests [Fact] public void DeclaresTheJobsTheCliOffers() { - new DotNetToolWorkflow().Jobs.Select(j => j.Name).ShouldBe(["status", "build", "install", "prepare", "check", "deploy"]); + new DotNetToolWorkflow().Jobs.Select(j => j.Name).ShouldBe(["init", "status", "build", "install", "prepare", "check", "deploy"]); } [Theory] + [InlineData("init")] [InlineData("status")] [InlineData("build")] [InlineData("install")] @@ -35,10 +36,12 @@ public void EveryJobTheCliOffers_Builds(string job) result.Value.Dispose(); } - [Fact] - public void Build_DoesNotRequireAProject() + [Theory] + [InlineData("build")] + [InlineData("init")] + public void JobsThatShipNothing_DoNotRequireAProject(string job) { - var result = Build("build", "{}"); + var result = Build(job, "{}"); result.IsSuccess.ShouldBeTrue(); result.Value.Dispose(); diff --git a/tests/Ritten.Tests/Workflows/DotNetWorkflowTests.cs b/tests/Ritten.Tests/Workflows/DotNetWorkflowTests.cs index 3aa7b1b..a61bb4a 100644 --- a/tests/Ritten.Tests/Workflows/DotNetWorkflowTests.cs +++ b/tests/Ritten.Tests/Workflows/DotNetWorkflowTests.cs @@ -12,12 +12,13 @@ namespace Ritten.Tests.Workflows; public class DotNetWorkflowTests { [Fact] - public void OffersOnlyBuildAndCheck() + public void OffersInitBuildAndCheck() { - new DotNetWorkflow().Jobs.Select(j => j.Name).ShouldBe(["build", "check"]); + new DotNetWorkflow().Jobs.Select(j => j.Name).ShouldBe(["init", "build", "check"]); } [Theory] + [InlineData("init")] [InlineData("build")] [InlineData("check")] public void EveryJobTheCliOffers_BuildsWithoutAnySettings(string job) diff --git a/tests/Ritten.Tests/Workflows/WorkflowRecognitionTests.cs b/tests/Ritten.Tests/Workflows/WorkflowRecognitionTests.cs new file mode 100644 index 0000000..18ca309 --- /dev/null +++ b/tests/Ritten.Tests/Workflows/WorkflowRecognitionTests.cs @@ -0,0 +1,112 @@ +using Ritten.Engine.FileSystem; +using Ritten.Engine.Workflows; +using Ritten.Workflows.DotNet; +using Ritten.Workflows.DotNetPackage; +using Ritten.Workflows.DotNetTool; + +namespace Ritten.Tests.Workflows; + +/// +/// A repository that hasn't declared a workflow is read the way a person would read it, by each +/// workflow in turn: the registry asks in registration order and the first to recognise it wins, +/// because every tool repository is also a package repository. +/// +public class WorkflowRecognitionTests : IDisposable +{ + private const string Tool = + """ + + + true + + + """; + + private const string Package = + """ + + + My.Package + + + """; + + private const string Nothing = ""; + + private readonly string _root = Path.Combine(Path.GetTempPath(), $"ritten-recognition-{Guid.NewGuid():N}"); + + public void Dispose() + { + GC.SuppressFinalize(this); + if (Directory.Exists(_root)) + { + Directory.Delete(_root, true); + } + } + + [Fact] + public async Task ARepositoryThatPacksAToolIsAToolRepository() + { + Project("src/My.Tool/My.Tool.csproj", Tool); + + var recognised = await Registry().IsCompatible(new PhysicalDirectory(_root), TestContext.Current.CancellationToken); + + recognised.ShouldNotBeNull().Workflow.Name.ShouldBe("dotnet-tool"); + recognised.Reason.ShouldBe("src/My.Tool/My.Tool.csproj packs as a tool"); + } + + [Fact] + public async Task APropertySharedByEveryProjectCounts() + { + // A property true of every project is usually declared once, in the shared build props. + Project("src/My.Tool/My.Tool.csproj", Nothing); + Project("Directory.Build.props", "true"); + + var recognised = await Registry().IsCompatible(new PhysicalDirectory(_root), TestContext.Current.CancellationToken); + + recognised.ShouldNotBeNull().Workflow.Name.ShouldBe("dotnet-tool"); + } + + [Fact] + public async Task ARepositoryThatPacksAPackageIsAPackageRepository() + { + Project("src/My.Package/My.Package.csproj", Package); + + var recognised = await Registry().IsCompatible(new PhysicalDirectory(_root), TestContext.Current.CancellationToken); + + recognised.ShouldNotBeNull().Workflow.Name.ShouldBe("dotnet-package"); + } + + [Fact] + public async Task ARepositoryThatShipsNothingStillBuilds() + { + Project("src/App/App.csproj", Nothing); + + var recognised = await Registry().IsCompatible(new PhysicalDirectory(_root), TestContext.Current.CancellationToken); + + recognised.ShouldNotBeNull().Workflow.Name.ShouldBe("dotnet"); + } + + [Fact] + public async Task ADirectoryWithNoProjectsIsRecognisedByNobody() + { + Directory.CreateDirectory(_root); + + var recognised = await Registry().IsCompatible(new PhysicalDirectory(_root), TestContext.Current.CancellationToken); + + recognised.ShouldBeNull(); + } + + /// The registry as the tool registers it: most specific first. + private static WorkflowRegistry Registry() => new WorkflowRegistry() + .Add() + .Add() + .Add(); + + private void Project(string path, string content) + { + var file = Path.Combine(_root, path); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + File.WriteAllText(file, content); + } +}