diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..85a4443 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# 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). + +## v2.0.0 - 2026-02-22 + +### Added +- Added contribution guidelines and documented release workflow. +- Added shared PowerShell utility modules for configuration, logging, git operations, and test execution. +- Added automation scripts for release publishing, force-amending tagged commits, and coverage badge generation. +- Added generated coverage badge assets. +- Added missing client-error factory methods for standard 4xx status codes in `Result` and `Result` APIs. + +### Changed +- Updated core and test dependencies, including migration to `xunit.v3`. +- Included `README.md`, `LICENSE.md`, and `CHANGELOG.md` in NuGet package content. +- Added coverage badges to the README and linked badge assets from `assets/badges/`. +- Updated solution metadata for newer Visual Studio format. +- Organized HTTP status factory methods into `Common` and `Extended Or Less Common` regions across informational, success, client-error, and server-error result files for improved readability. +- Updated package target framework to `.NET 10` (`net10.0`). + +### Fixed +- Improved RFC 7807 `ProblemDetails` JSON serialization with explicit field names, deterministic output order, null omission for optional fields, and extension-data serialization. + +### Removed +- Removed `.NET 8` target support; package now targets `.NET 10` only. + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..268d486 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,117 @@ +# Contributing to MaksIT.Results + +Thank you for your interest in contributing to `MaksIT.Results`. + +## Getting Started + +1. Fork the repository. +2. Clone your fork locally. +3. Create a feature branch. +4. Implement and test your changes. +5. Submit a pull request to `main`. + +## Development Setup + +### Prerequisites + +- .NET 8 SDK or later +- Git +- PowerShell 7+ (recommended for utility scripts) + +### Build + +```bash +cd src +dotnet build MaksIT.Results.sln +``` + +### Test + +```bash +cd src +dotnet test MaksIT.Results.Tests +``` + +## Commit Message Format + +Use: + +```text +(type): description +``` + +### Commit Types + +| Type | Description | +|------|-------------| +| `(feature):` | New feature or enhancement | +| `(bugfix):` | Bug fix | +| `(refactor):` | Refactoring without behavior change | +| `(chore):` | Maintenance tasks (dependencies, tooling, docs) | + +### Guidelines + +- Use lowercase in the description. +- Keep it concise and specific. +- Do not end with a period. + +## Pull Request Checklist + +1. Ensure build and tests pass. +2. Update `README.md` if behavior or usage changed. +3. Update `CHANGELOG.md` under the target version. +4. Keep changes scoped and explain rationale in the PR description. + +## Versioning + +This project follows [Semantic Versioning](https://semver.org/): + +- **MAJOR**: breaking API changes +- **MINOR**: backward-compatible features +- **PATCH**: backward-compatible fixes + +## Utility Scripts + +Scripts are located under `utils/`. + +### Generate Coverage Badges + +Runs tests with coverage and generates SVG badges in `assets/badges/`. + +```powershell +.\utils\Generate-CoverageBadges\Generate-CoverageBadges.ps1 +``` + +Configuration: `utils/Generate-CoverageBadges/scriptsettings.json` + +### Release NuGet Package + +Builds, tests, packs, and publishes to NuGet and GitHub release flows. + +```powershell +.\utils\Release-NuGetPackage\Release-NuGetPackage.ps1 +``` + +Prerequisites: + +- Docker Desktop (for Linux test validation) +- GitHub CLI (`gh`) +- environment variable `NUGET_MAKS_IT` +- environment variable `GITHUB_MAKS_IT_COM` + +Configuration: `utils/Release-NuGetPackage/scriptsettings.json` + +### Force Amend Tagged Commit + +Amends the latest tagged commit and force-pushes updated branch and tag. + +```powershell +.\utils\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1 +.\utils\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1 -DryRun +``` + +Warning: this rewrites git history. + +## License + +By contributing, you agree that your contributions are licensed under the terms in `LICENSE.md`. diff --git a/README.md b/README.md index 3261ee9..eb166b1 100644 --- a/README.md +++ b/README.md @@ -1,182 +1,106 @@ # MaksIT.Results -`MaksIT.Results` is a powerful library designed to streamline the creation and management of result objects in your ASP.NET Core applications. It provides a standardized way to handle method results and easily convert them to `IActionResult` for HTTP responses, ensuring consistent and clear API responses. +![Line Coverage](assets/badges/coverage-lines.svg) ![Branch Coverage](assets/badges/coverage-branches.svg) ![Method Coverage](assets/badges/coverage-methods.svg) + +`MaksIT.Results` is a .NET library for modeling operation outcomes as HTTP-aware result objects and converting them to `IActionResult` in ASP.NET Core. ## Features -- **Standardized Result Handling**: Represent operation outcomes (success or failure) with appropriate HTTP status codes. -- **Seamless Conversion to `IActionResult`**: Convert result objects to HTTP responses (`IActionResult`) with detailed problem descriptions. -- **Flexible Result Types**: Supports both generic (`Result`) and non-generic (`Result`) results for handling various scenarios. -- **Predefined Results for All Standard HTTP Status Codes**: Includes predefined static methods to create results for all standard HTTP status codes (e.g., 200 OK, 404 Not Found, 500 Internal Server Error, etc.). +- `Result` and `Result` models with status code, success flag, and messages. +- Static factory methods for common and extended HTTP status codes (1xx, 2xx, 3xx, 4xx, 5xx). +- Built-in conversion to `IActionResult` via `ToActionResult()`. +- RFC 7807-style error payloads for failures (`application/problem+json`). +- Camel-case JSON serialization for response bodies. ## Installation -To install `MaksIT.Results`, use the NuGet Package Manager: +Package Manager: ```bash Install-Package MaksIT.Results ``` -## Usage example +`dotnet` CLI: -Below is an example demonstrating how to use `MaksIT.Results` in a typical ASP.NET Core application where a controller interacts with a service. - -### Step 1: Define and Register the Service - -Define a service that uses `MaksIT.Results` to return operation results, handling different result types with proper casting and conversion. - -```csharp -public interface IVaultPersistanceService -{ - Result ReadOrganization(Guid organizationId); - Task DeleteOrganizationAsync(Guid organizationId); - // Additional method definitions... -} - -public class VaultPersistanceService : IVaultPersistanceService -{ - // Inject dependencies as needed - - public Result ReadOrganization(Guid organizationId) - { - var organizationResult = _organizationDataProvider.GetById(organizationId); - if (!organizationResult.IsSuccess || organizationResult.Value == null) - { - // Return a NotFound result when the organization isn't found - return Result.NotFound("Organization not found."); - } - - var organization = organizationResult.Value; - var applicationDtos = new List(); - - foreach (var applicationId in organization.Applications) - { - var applicationResult = _applicationDataProvider.GetById(applicationId); - if (!applicationResult.IsSuccess || applicationResult.Value == null) - { - // Transform the result from Result to Result - // Ensuring the return type matches the method signature (Result) - return applicationResult.WithNewValue(_ => null); - } - - var applicationDto = applicationResult.Value; - applicationDtos.Add(applicationDto); - } - - // Return the final result with all applications loaded - return Result.Ok(organization); - } - - public async Task DeleteOrganizationAsync(Guid organizationId) - { - var organizationResult = await _organizationDataProvider.GetByIdAsync(organizationId); - - if (!organizationResult.IsSuccess || organizationResult.Value == null) - { - // Convert Result to a non-generic Result - // The cast to (Result) allows for standardized response type - return (Result)organizationResult; - } - - // Proceed with the deletion if the organization is found - var deleteResult = await _organizationDataProvider.DeleteByIdAsync(organizationId); - - // Return the result of the delete operation directly - return deleteResult; - } -} +```bash +dotnet add package MaksIT.Results ``` -**Key Points to Note:** +## Target Framework -1. **Handling Different Result Types:** - - The `ReadOrganization` method demonstrates handling a `Result` and transforming other types as needed using `WithNewValue`. This ensures the method always returns the correct type. - -2. **Casting from `Result` to `Result`:** - - In `DeleteOrganizationAsync`, we cast `Result` to `Result` using `(Result)organizationResult`. This cast standardizes the result type, making it suitable for scenarios where only success or failure matters. +- `.NET 10` (`net10.0`) -Ensure this service is registered in your dependency injection container: +## Quick Start + +### Create results ```csharp -public void ConfigureServices(IServiceCollection services) -{ - services.AddScoped(); - // Other service registrations... -} -``` - -### Step 2: Use the Service in the Controller - -Inject the service into your controller and utilize `MaksIT.Results` to handle results efficiently: - -```csharp -using Microsoft.AspNetCore.Mvc; using MaksIT.Results; -public class OrganizationController : ControllerBase -{ - private readonly IVaultPersistanceService _vaultPersistanceService; +Result ok = Result.Ok("Operation completed"); +Result failed = Result.BadRequest("Validation failed"); - public OrganizationController(IVaultPersistanceService vaultPersistanceService) - { - _vaultPersistanceService = vaultPersistanceService; - } - - [HttpGet("{organizationId}")] - public IActionResult GetOrganization(Guid organizationId) - { - var result = _vaultPersistanceService.ReadOrganization(organizationId); - - // Convert the Result to IActionResult using ToActionResult() - return result.ToActionResult(); - } - - [HttpDelete("{organizationId}")] - public async Task DeleteOrganization(Guid organizationId) - { - var result = await _vaultPersistanceService.DeleteOrganizationAsync(organizationId); - - // Convert the Result to IActionResult using ToActionResult() - return result.ToActionResult(); - } - - // Additional actions... -} +Result okWithValue = Result.Ok(42, "Answer generated"); +Result notFound = Result.NotFound(null, "Entity not found"); ``` -### Transforming Results - -You can also transform the result within the controller or service to adjust the output type as needed: +### Convert between result types ```csharp -public IActionResult TransformResultExample() -{ - var result = _vaultPersistanceService.ReadOrganization(Guid.NewGuid()); +using MaksIT.Results; - // Transform the result to a different type if needed - var transformedResult = result.WithNewValue(org => (org?.Name ?? "").ToTitle()); +Result source = Result.Ok(42, "Value loaded"); - return transformedResult.ToActionResult(); -} +// Result -> Result +Result mapped = source.ToResultOfType(v => v?.ToString()); + +// Result -> Result +Result nonGeneric = source.ToResult(); ``` -### Predefined Results for All Standard HTTP Status Codes - -`MaksIT.Results` provides methods to easily create results for all standard HTTP status codes, simplifying the handling of responses: +### Use in an ASP.NET Core controller ```csharp -return Result.Ok("Success").ToActionResult(); // 200 OK -return Result.NotFound("Resource not found").ToActionResult(); // 404 Not Found -return Result.InternalServerError("An unexpected error occurred").ToActionResult(); // 500 Internal Server Error +using MaksIT.Results; +using Microsoft.AspNetCore.Mvc; + +public sealed class UsersController : ControllerBase { + [HttpGet("{id:guid}")] + public IActionResult GetUser(Guid id) { + Result result = id == Guid.Empty + ? Result.BadRequest(null, "Invalid id") + : Result.Ok(new UserDto(id, "maks"), "User loaded"); + + return result.ToActionResult(); + } +} + +public sealed record UserDto(Guid Id, string Name); ``` -### Conclusion +## `ToActionResult()` Behavior -`MaksIT.Results` is a powerful tool for simplifying the handling of operation results in ASP.NET Core applications. It provides a robust framework for standardized result handling, seamless conversion to `IActionResult`, and flexible result types to handle various scenarios. By adopting this library, developers can create more maintainable and readable code, ensuring consistent and clear HTTP responses. +- `Result` success: returns status-code-only response. +- `Result` success with non-null `Value`: returns JSON body + status code. +- Any failure: returns RFC 7807-style `ProblemDetails` JSON with: + - `status` = result status code + - `title` = `"An error occurred"` + - `detail` = joined `Messages` + - content type `application/problem+json` -## Contribution +## Status Code Factories -Contributions to this project are welcome! Please fork the repository and submit a pull request with your changes. If you encounter any issues or have feature requests, feel free to open an issue on GitHub. +- Informational: `Result.Continue(...)`, `Result.SwitchingProtocols(...)`, `Result.Processing(...)`, etc. +- Success: `Result.Ok(...)`, `Result.Created(...)`, `Result.NoContent(...)`, etc. +- Redirection: `Result.Found(...)`, `Result.PermanentRedirect(...)`, etc. +- Client error: `Result.BadRequest(...)`, `Result.NotFound(...)`, `Result.TooManyRequests(...)`, etc. +- Server error: `Result.InternalServerError(...)`, `Result.ServiceUnavailable(...)`, etc. + +Generic equivalents are available via `Result`, for example `Result.Ok(value, "message")`. + +## Contributing + +See `CONTRIBUTING.md`. ## Contact @@ -184,39 +108,6 @@ If you have any questions or need further assistance, feel free to reach out: - **Email**: [maksym.sadovnychyy@gmail.com](mailto:maksym.sadovnychyy@gmail.com) - **Reddit**: [MaksIT.Results: Streamline Your ASP.NET Core API Response Handling](https://www.reddit.com/r/MaksIT/comments/1f89ifn/maksitresults_streamline_your_aspnet_core_api/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button) - ## License -This project is licensed under the MIT License. See the full license text below. - ---- - -### MIT License - -``` -MIT License - -Copyright (c) 2024 Maksym Sadovnychyy (MAKS-IT) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - -## Contact - -For any questions or inquiries, please reach out via GitHub or [email](mailto:maksym.sadovnychyy@gmail.com). \ No newline at end of file +See `LICENSE.md`. \ No newline at end of file diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg new file mode 100644 index 0000000..f3d1ba7 --- /dev/null +++ b/assets/badges/coverage-branches.svg @@ -0,0 +1,21 @@ + + Branch Coverage: 61.1% + + + + + + + + + + + + + + + Branch Coverage + + 61.1% + + diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg new file mode 100644 index 0000000..b0e0a49 --- /dev/null +++ b/assets/badges/coverage-lines.svg @@ -0,0 +1,21 @@ + + Line Coverage: 20.3% + + + + + + + + + + + + + + + Line Coverage + + 20.3% + + diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg new file mode 100644 index 0000000..cd2961c --- /dev/null +++ b/assets/badges/coverage-methods.svg @@ -0,0 +1,21 @@ + + Method Coverage: 20.7% + + + + + + + + + + + + + + + Method Coverage + + 20.7% + + diff --git a/src/MaksIT.Results.Tests/MaksIT.Results.Tests.csproj b/src/MaksIT.Results.Tests/MaksIT.Results.Tests.csproj index 069255b..17768d2 100644 --- a/src/MaksIT.Results.Tests/MaksIT.Results.Tests.csproj +++ b/src/MaksIT.Results.Tests/MaksIT.Results.Tests.csproj @@ -1,7 +1,7 @@ - + - net8.0 + net10.0 enable enable @@ -10,17 +10,17 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/MaksIT.Results.sln b/src/MaksIT.Results.sln index 9950a1d..4f2c276 100644 --- a/src/MaksIT.Results.sln +++ b/src/MaksIT.Results.sln @@ -1,7 +1,6 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.9.34902.65 +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11222.15 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.Results", "MaksIT.Results\MaksIT.Results.csproj", "{E947F5FC-8FD9-4F1E-AA5F-29FED95B5A2D}" EndProject diff --git a/src/MaksIT.Results/MaksIT.Results.csproj b/src/MaksIT.Results/MaksIT.Results.csproj index 555ac63..d9d01b6 100644 --- a/src/MaksIT.Results/MaksIT.Results.csproj +++ b/src/MaksIT.Results/MaksIT.Results.csproj @@ -1,14 +1,14 @@  - net8.0 + net10.0 enable enable $(MSBuildProjectName.Replace(" ", "_")) MaksIT.Results - 1.1.1 + 2.0.0 Maksym Sadovnychyy MAKS-IT MaksIT.Results @@ -22,11 +22,16 @@ - - + - + + + + + + PreserveNewest + diff --git a/src/MaksIT.Results/Mvc/ProblemDetails.cs b/src/MaksIT.Results/Mvc/ProblemDetails.cs index b6bf09d..c9f9409 100644 --- a/src/MaksIT.Results/Mvc/ProblemDetails.cs +++ b/src/MaksIT.Results/Mvc/ProblemDetails.cs @@ -1,11 +1,68 @@ -namespace MaksIT.Results.Mvc; +using System.Text.Json.Serialization; + +namespace MaksIT.Results.Mvc; +/// +/// A machine-readable format for specifying errors in HTTP API responses based on . +/// public class ProblemDetails { + /// + /// A URI reference [RFC3986] that identifies the problem type. This specification encourages that, when + /// dereferenced, it provide human-readable documentation for the problem type + /// (e.g., using HTML [W3C.REC-html5-20141028]). When this member is not present, its value is assumed to be + /// "about:blank". + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-5)] + [JsonPropertyName("type")] public string? Type { get; set; } + + /// + /// A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence + /// of the problem, except for purposes of localization(e.g., using proactive content negotiation; + /// see[RFC7231], Section 3.4). + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-4)] + [JsonPropertyName("title")] public string? Title { get; set; } + + /// + /// The HTTP status code([RFC7231], Section 6) generated by the origin server for this occurrence of the problem. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-3)] + [JsonPropertyName("status")] public int? Status { get; set; } + + /// + /// A human-readable explanation specific to this occurrence of the problem. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-2)] + [JsonPropertyName("detail")] public string? Detail { get; set; } + + /// + /// A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-1)] + [JsonPropertyName("instance")] public string? Instance { get; set; } - public IDictionary Extensions { get; set; } = new Dictionary(); -} \ No newline at end of file + + /// + /// Gets the for extension members. + /// + /// Problem type definitions MAY extend the problem details object with additional members. Extension members appear in the same namespace as + /// other members of a problem type. + /// + /// + /// + /// The round-tripping behavior for is determined by the implementation of the Input \ Output formatters. + /// In particular, complex types or collection types may not round-trip to the original type when using the built-in JSON or XML formatters. + /// + [JsonExtensionData] + public IDictionary Extensions { get; set; } = new Dictionary(StringComparer.Ordinal); +} diff --git a/src/MaksIT.Results/Result.ClientError.cs b/src/MaksIT.Results/Result.ClientError.cs index db8aa86..3111d6a 100644 --- a/src/MaksIT.Results/Result.ClientError.cs +++ b/src/MaksIT.Results/Result.ClientError.cs @@ -1,10 +1,12 @@ -using System.Net; +using System.Net; namespace MaksIT.Results; public partial class Result { + #region Common Client Errors + /// /// Returns a result indicating that the server could not understand the request due to invalid syntax. /// Corresponds to HTTP status code 400 Bad Request. @@ -53,6 +55,138 @@ public partial class Result { return new Result(false, [..messages], (HttpStatusCode)410); // 410 Gone } + /// + /// Returns a result indicating that the server cannot process the request because it expects the request to have a defined Content-Length header. + /// Corresponds to HTTP status code 411 Length Required. + /// + public static Result LengthRequired(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)411); // 411 Length Required + } + + /// + /// Returns a result indicating that the server cannot process the request entity because it is too large. + /// Corresponds to HTTP status code 413 Payload Too Large. + /// + public static Result PayloadTooLarge(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)413); // 413 Payload Too Large + } + + /// + /// Returns a result indicating that the server cannot process the request because the URI is too long. + /// Corresponds to HTTP status code 414 URI Too Long. + /// + public static Result UriTooLong(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)414); // 414 URI Too Long + } + + /// + /// Returns a result indicating that the server cannot process the request because the media type is unsupported. + /// Corresponds to HTTP status code 415 Unsupported Media Type. + /// + public static Result UnsupportedMediaType(params string [] messages) { + return new Result(false, [..messages], HttpStatusCode.UnsupportedMediaType); + } + + #endregion + + #region Extended Or Less Common Client Errors + + /// + /// Returns a result indicating that payment is required to access the requested resource. + /// Corresponds to HTTP status code 402 Payment Required. + /// + public static Result PaymentRequired(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)402); // 402 Payment Required + } + + /// + /// Returns a result indicating that the request method is known by the server but is not supported by the target resource. + /// Corresponds to HTTP status code 405 Method Not Allowed. + /// + public static Result MethodNotAllowed(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)405); // 405 Method Not Allowed + } + + /// + /// Returns a result indicating that the server cannot produce a response matching the list of acceptable values defined in the request headers. + /// Corresponds to HTTP status code 406 Not Acceptable. + /// + public static Result NotAcceptable(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)406); // 406 Not Acceptable + } + + /// + /// Returns a result indicating that authentication with a proxy is required. + /// Corresponds to HTTP status code 407 Proxy Authentication Required. + /// + public static Result ProxyAuthenticationRequired(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)407); // 407 Proxy Authentication Required + } + + /// + /// Returns a result indicating that the server timed out waiting for the request. + /// Corresponds to HTTP status code 408 Request Timeout. + /// + public static Result RequestTimeout(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)408); // 408 Request Timeout + } + + /// + /// Returns a result indicating that one or more conditions given in the request header fields evaluated to false. + /// Corresponds to HTTP status code 412 Precondition Failed. + /// + public static Result PreconditionFailed(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)412); // 412 Precondition Failed + } + + /// + /// Returns a result indicating that the range specified by the Range header field cannot be fulfilled. + /// Corresponds to HTTP status code 416 Range Not Satisfiable. + /// + public static Result RangeNotSatisfiable(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)416); // 416 Range Not Satisfiable + } + + /// + /// Returns a result indicating that the expectation given in the request's Expect header could not be met. + /// Corresponds to HTTP status code 417 Expectation Failed. + /// + public static Result ExpectationFailed(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)417); // 417 Expectation Failed + } + + /// + /// Returns a result indicating that the server refuses to brew coffee because it is, permanently, a teapot. + /// Corresponds to HTTP status code 418 I'm a teapot. + /// + public static Result ImATeapot(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)418); // 418 I'm a teapot + } + + /// + /// Returns a result indicating that the request was directed at a server that is not able to produce a response. + /// Corresponds to HTTP status code 421 Misdirected Request. + /// + public static Result MisdirectedRequest(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)421); // 421 Misdirected Request + } + + /// + /// Returns a result indicating that the server cannot process the request due to an illegal request entity. + /// Corresponds to HTTP status code 422 Unprocessable Entity. + /// + public static Result UnprocessableEntity(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)422); // 422 Unprocessable Entity + } + + /// + /// Returns a result indicating that access to the target resource is denied because the resource is locked. + /// Corresponds to HTTP status code 423 Locked. + /// + public static Result Locked(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)423); // 423 Locked + } + /// /// Returns a result indicating that the request failed because it depended on another request and that request failed. /// Corresponds to HTTP status code 424 Failed Dependency. @@ -61,6 +195,22 @@ public partial class Result { return new Result(false, [..messages], (HttpStatusCode)424); // 424 Failed Dependency } + /// + /// Returns a result indicating that the server is unwilling to risk processing a request that might be replayed. + /// Corresponds to HTTP status code 425 Too Early. + /// + public static Result TooEarly(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)425); // 425 Too Early + } + + /// + /// Returns a result indicating that the server refuses to perform the request using the current protocol. + /// Corresponds to HTTP status code 426 Upgrade Required. + /// + public static Result UpgradeRequired(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)426); // 426 Upgrade Required + } + /// /// Returns a result indicating that the server requires the request to be conditional. /// Corresponds to HTTP status code 428 Precondition Required. @@ -86,48 +236,20 @@ public partial class Result { } /// - /// Returns a result indicating that the server cannot process the request entity because it is too large. - /// Corresponds to HTTP status code 413 Payload Too Large. + /// Returns a result indicating that access to the requested resource is denied for legal reasons. + /// Corresponds to HTTP status code 451 Unavailable For Legal Reasons. /// - public static Result PayloadTooLarge(params string [] messages) { - return new Result(false, [..messages], (HttpStatusCode)413); // 413 Payload Too Large + public static Result UnavailableForLegalReasons(params string [] messages) { + return new Result(false, [..messages], (HttpStatusCode)451); // 451 Unavailable For Legal Reasons } - /// - /// Returns a result indicating that the server cannot process the request because the URI is too long. - /// Corresponds to HTTP status code 414 URI Too Long. - /// - public static Result UriTooLong(params string [] messages) { - return new Result(false, [..messages], (HttpStatusCode)414); // 414 URI Too Long - } - - /// - /// Returns a result indicating that the server cannot process the request because the media type is unsupported. - /// Corresponds to HTTP status code 415 Unsupported Media Type. - /// - public static Result UnsupportedMediaType(params string [] messages) { - return new Result(false, [..messages], HttpStatusCode.UnsupportedMediaType); - } - - /// - /// Returns a result indicating that the server cannot process the request because it expects the request to have a defined Content-Length header. - /// Corresponds to HTTP status code 411 Length Required. - /// - public static Result LengthRequired(params string [] messages) { - return new Result(false, [..messages], (HttpStatusCode)411); // 411 Length Required - } - - /// - /// Returns a result indicating that the server cannot process the request due to an illegal request entity. - /// Corresponds to HTTP status code 422 Unprocessable Entity. - /// - public static Result UnprocessableEntity(params string [] messages) { - return new Result(false, [..messages], (HttpStatusCode)422); // 422 Unprocessable Entity - } + #endregion } public partial class Result : Result { + #region Common Client Errors + /// /// Returns a result indicating that the server could not understand the request due to invalid syntax. /// Corresponds to HTTP status code 400 Bad Request. @@ -176,6 +298,138 @@ public partial class Result : Result { return new Result(value, false, [..messages], (HttpStatusCode)410); // 410 Gone } + /// + /// Returns a result indicating that the server cannot process the request because it expects the request to have a defined Content-Length header. + /// Corresponds to HTTP status code 411 Length Required. + /// + public static Result LengthRequired(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)411); // 411 Length Required + } + + /// + /// Returns a result indicating that the server cannot process the request entity because it is too large. + /// Corresponds to HTTP status code 413 Payload Too Large. + /// + public static Result PayloadTooLarge(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)413); // 413 Payload Too Large + } + + /// + /// Returns a result indicating that the server cannot process the request because the URI is too long. + /// Corresponds to HTTP status code 414 URI Too Long. + /// + public static Result UriTooLong(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)414); // 414 URI Too Long + } + + /// + /// Returns a result indicating that the server cannot process the request because the media type is unsupported. + /// Corresponds to HTTP status code 415 Unsupported Media Type. + /// + public static Result UnsupportedMediaType(T? value, params string [] messages) { + return new Result(value, false, [..messages], HttpStatusCode.UnsupportedMediaType); + } + + #endregion + + #region Extended Or Less Common Client Errors + + /// + /// Returns a result indicating that payment is required to access the requested resource. + /// Corresponds to HTTP status code 402 Payment Required. + /// + public static Result PaymentRequired(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)402); // 402 Payment Required + } + + /// + /// Returns a result indicating that the request method is known by the server but is not supported by the target resource. + /// Corresponds to HTTP status code 405 Method Not Allowed. + /// + public static Result MethodNotAllowed(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)405); // 405 Method Not Allowed + } + + /// + /// Returns a result indicating that the server cannot produce a response matching the list of acceptable values defined in the request headers. + /// Corresponds to HTTP status code 406 Not Acceptable. + /// + public static Result NotAcceptable(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)406); // 406 Not Acceptable + } + + /// + /// Returns a result indicating that authentication with a proxy is required. + /// Corresponds to HTTP status code 407 Proxy Authentication Required. + /// + public static Result ProxyAuthenticationRequired(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)407); // 407 Proxy Authentication Required + } + + /// + /// Returns a result indicating that the server timed out waiting for the request. + /// Corresponds to HTTP status code 408 Request Timeout. + /// + public static Result RequestTimeout(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)408); // 408 Request Timeout + } + + /// + /// Returns a result indicating that one or more conditions given in the request header fields evaluated to false. + /// Corresponds to HTTP status code 412 Precondition Failed. + /// + public static Result PreconditionFailed(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)412); // 412 Precondition Failed + } + + /// + /// Returns a result indicating that the range specified by the Range header field cannot be fulfilled. + /// Corresponds to HTTP status code 416 Range Not Satisfiable. + /// + public static Result RangeNotSatisfiable(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)416); // 416 Range Not Satisfiable + } + + /// + /// Returns a result indicating that the expectation given in the request's Expect header could not be met. + /// Corresponds to HTTP status code 417 Expectation Failed. + /// + public static Result ExpectationFailed(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)417); // 417 Expectation Failed + } + + /// + /// Returns a result indicating that the server refuses to brew coffee because it is, permanently, a teapot. + /// Corresponds to HTTP status code 418 I'm a teapot. + /// + public static Result ImATeapot(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)418); // 418 I'm a teapot + } + + /// + /// Returns a result indicating that the request was directed at a server that is not able to produce a response. + /// Corresponds to HTTP status code 421 Misdirected Request. + /// + public static Result MisdirectedRequest(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)421); // 421 Misdirected Request + } + + /// + /// Returns a result indicating that the server cannot process the request due to an illegal request entity. + /// Corresponds to HTTP status code 422 Unprocessable Entity. + /// + public static Result UnprocessableEntity(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)422); // 422 Unprocessable Entity + } + + /// + /// Returns a result indicating that access to the target resource is denied because the resource is locked. + /// Corresponds to HTTP status code 423 Locked. + /// + public static Result Locked(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)423); // 423 Locked + } + /// /// Returns a result indicating that the request failed because it depended on another request and that request failed. /// Corresponds to HTTP status code 424 Failed Dependency. @@ -184,6 +438,22 @@ public partial class Result : Result { return new Result(value, false, [..messages], (HttpStatusCode)424); // 424 Failed Dependency } + /// + /// Returns a result indicating that the server is unwilling to risk processing a request that might be replayed. + /// Corresponds to HTTP status code 425 Too Early. + /// + public static Result TooEarly(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)425); // 425 Too Early + } + + /// + /// Returns a result indicating that the server refuses to perform the request using the current protocol. + /// Corresponds to HTTP status code 426 Upgrade Required. + /// + public static Result UpgradeRequired(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)426); // 426 Upgrade Required + } + /// /// Returns a result indicating that the server requires the request to be conditional. /// Corresponds to HTTP status code 428 Precondition Required. @@ -209,42 +479,12 @@ public partial class Result : Result { } /// - /// Returns a result indicating that the server cannot process the request entity because it is too large. - /// Corresponds to HTTP status code 413 Payload Too Large. + /// Returns a result indicating that access to the requested resource is denied for legal reasons. + /// Corresponds to HTTP status code 451 Unavailable For Legal Reasons. /// - public static Result PayloadTooLarge(T? value, params string [] messages) { - return new Result(value, false, [..messages], (HttpStatusCode)413); // 413 Payload Too Large + public static Result UnavailableForLegalReasons(T? value, params string [] messages) { + return new Result(value, false, [..messages], (HttpStatusCode)451); // 451 Unavailable For Legal Reasons } - /// - /// Returns a result indicating that the server cannot process the request because the URI is too long. - /// Corresponds to HTTP status code 414 URI Too Long. - /// - public static Result UriTooLong(T? value, params string [] messages) { - return new Result(value, false, [..messages], (HttpStatusCode)414); // 414 URI Too Long - } - - /// - /// Returns a result indicating that the server cannot process the request because the media type is unsupported. - /// Corresponds to HTTP status code 415 Unsupported Media Type. - /// - public static Result UnsupportedMediaType(T? value, params string [] messages) { - return new Result(value, false, [..messages], HttpStatusCode.UnsupportedMediaType); - } - - /// - /// Returns a result indicating that the server cannot process the request because it expects the request to have a defined Content-Length header. - /// Corresponds to HTTP status code 411 Length Required. - /// - public static Result LengthRequired(T? value, params string [] messages) { - return new Result(value, false, [..messages], (HttpStatusCode)411); // 411 Length Required - } - - /// - /// Returns a result indicating that the server cannot process the request due to an illegal request entity. - /// Corresponds to HTTP status code 422 Unprocessable Entity. - /// - public static Result UnprocessableEntity(T? value, params string [] messages) { - return new Result(value, false, [..messages], (HttpStatusCode)422); // 422 Unprocessable Entity - } + #endregion } diff --git a/src/MaksIT.Results/Result.Information.cs b/src/MaksIT.Results/Result.Information.cs index 7e26094..ccf1d56 100644 --- a/src/MaksIT.Results/Result.Information.cs +++ b/src/MaksIT.Results/Result.Information.cs @@ -1,10 +1,12 @@ -using System.Net; +using System.Net; namespace MaksIT.Results; public partial class Result { + #region Common Informational Responses + /// /// Returns a result indicating that the initial part of a request has been received and the client should continue with the request. /// Corresponds to HTTP status code 100 Continue. @@ -21,6 +23,10 @@ public partial class Result { return new Result(true, [..messages], HttpStatusCode.SwitchingProtocols); } + #endregion + + #region Extended Or Less Common Informational Responses + /// /// Returns a result indicating that the server has received and is processing the request, but no response is available yet. /// Corresponds to HTTP status code 102 Processing. @@ -36,10 +42,14 @@ public partial class Result { public static Result EarlyHints(params string [] messages) { return new Result(true, [..messages], (HttpStatusCode)103); // Early Hints is not defined in HttpStatusCode enum, 103 is the official code } + + #endregion } public partial class Result : Result { + #region Common Informational Responses + /// /// Returns a result indicating that the initial part of a request has been received and the client should continue with the request. /// Corresponds to HTTP status code 100 Continue. @@ -56,6 +66,10 @@ public partial class Result : Result { return new Result(value, true, [..messages], HttpStatusCode.SwitchingProtocols); } + #endregion + + #region Extended Or Less Common Informational Responses + /// /// Returns a result indicating that the server has received and is processing the request, but no response is available yet. /// Corresponds to HTTP status code 102 Processing. @@ -71,5 +85,6 @@ public partial class Result : Result { public static Result EarlyHints(T? value, params string [] messages) { return new Result(value, true, [..messages], (HttpStatusCode)103); // Early Hints is not defined in HttpStatusCode enum, 103 is the official code } -} + #endregion +} diff --git a/src/MaksIT.Results/Result.ServerError.cs b/src/MaksIT.Results/Result.ServerError.cs index 3e2b0df..bc2e47d 100644 --- a/src/MaksIT.Results/Result.ServerError.cs +++ b/src/MaksIT.Results/Result.ServerError.cs @@ -1,9 +1,11 @@ -using System.Net; +using System.Net; namespace MaksIT.Results; public partial class Result { + #region Common Server Errors + /// /// Returns a result indicating the server encountered an unexpected condition that prevented it from fulfilling the request. /// Corresponds to HTTP status code 500 Internal Server Error. @@ -52,6 +54,10 @@ public partial class Result { return new Result(false, [..messages], HttpStatusCode.HttpVersionNotSupported); } + #endregion + + #region Extended Or Less Common Server Errors + /// /// Returns a result indicating the server has an internal configuration error: the chosen variant resource is configured to engage in transparent content negotiation itself, and is therefore not a proper end point in the negotiation process. /// Corresponds to HTTP status code 506 Variant Also Negotiates. @@ -91,10 +97,14 @@ public partial class Result { public static Result NetworkAuthenticationRequired(params string [] messages) { return new Result(false, [..messages], HttpStatusCode.NetworkAuthenticationRequired); } + + #endregion } public partial class Result : Result { + #region Common Server Errors + /// /// Returns a result indicating the server encountered an unexpected condition that prevented it from fulfilling the request. /// Corresponds to HTTP status code 500 Internal Server Error. @@ -143,6 +153,10 @@ public partial class Result : Result { return new Result(value, false, [..messages], HttpStatusCode.HttpVersionNotSupported); } + #endregion + + #region Extended Or Less Common Server Errors + /// /// Returns a result indicating the server has an internal configuration error: the chosen variant resource is configured to engage in transparent content negotiation itself, and is therefore not a proper end point in the negotiation process. /// Corresponds to HTTP status code 506 Variant Also Negotiates. @@ -182,4 +196,6 @@ public partial class Result : Result { public static Result NetworkAuthenticationRequired(T? value, params string [] messages) { return new Result(value, false, [..messages], HttpStatusCode.NetworkAuthenticationRequired); } + + #endregion } diff --git a/src/MaksIT.Results/Result.Succes.cs b/src/MaksIT.Results/Result.Succes.cs index a6b1a7c..85e9aee 100644 --- a/src/MaksIT.Results/Result.Succes.cs +++ b/src/MaksIT.Results/Result.Succes.cs @@ -1,10 +1,12 @@ -using System.Net; +using System.Net; namespace MaksIT.Results; public partial class Result { + #region Common Success Responses + /// /// Returns a result indicating the request was successful and the server returned the requested data. /// Corresponds to HTTP status code 200 OK. @@ -61,6 +63,10 @@ public partial class Result { return new Result(true, [..messages], HttpStatusCode.PartialContent); } + #endregion + + #region Extended Or Less Common Success Responses + /// /// Returns a result indicating the request was successful and the response contains multiple status codes, typically used for WebDAV. /// Corresponds to HTTP status code 207 Multi-Status. @@ -84,9 +90,13 @@ public partial class Result { public static Result IMUsed(params string[] messages) { return new Result(true, [..messages], (HttpStatusCode)226); // 226 is the official status code for IM Used } + + #endregion } public partial class Result : Result { + #region Common Success Responses + /// /// Returns a result indicating the request was successful and the server returned the requested data. /// Corresponds to HTTP status code 200 OK. @@ -143,6 +153,10 @@ public partial class Result : Result { return new Result(value, true, [..messages], HttpStatusCode.PartialContent); } + #endregion + + #region Extended Or Less Common Success Responses + /// /// Returns a result indicating the request was successful and the response contains multiple status codes, typically used for WebDAV. /// Corresponds to HTTP status code 207 Multi-Status. @@ -166,4 +180,6 @@ public partial class Result : Result { public static Result IMUsed(T? value, params string[] messages) { return new Result(value, true, [..messages], (HttpStatusCode)226); // 226 is the official status code for IM Used } + + #endregion } diff --git a/src/Release-NuGetPackage.bat b/src/Release-NuGetPackage.bat deleted file mode 100644 index ba9cefe..0000000 --- a/src/Release-NuGetPackage.bat +++ /dev/null @@ -1,7 +0,0 @@ -@echo off - -REM Change directory to the location of the script -cd /d %~dp0 - -REM Invoke the PowerShell script (Release-NuGetPackage.ps1) in the same directory -powershell -ExecutionPolicy Bypass -File "%~dp0Release-NuGetPackage.ps1" diff --git a/src/Release-NuGetPackage.ps1 b/src/Release-NuGetPackage.ps1 deleted file mode 100644 index c2c894e..0000000 --- a/src/Release-NuGetPackage.ps1 +++ /dev/null @@ -1,56 +0,0 @@ -# Retrieve the API key from the environment variable -$apiKey = $env:NUGET_MAKS_IT -if (-not $apiKey) { - Write-Host "Error: API key not found in environment variable NUGET_MAKS_IT." - exit 1 -} - -# NuGet source -$nugetSource = "https://api.nuget.org/v3/index.json" - -# Define paths -$solutionDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$projectDir = "$solutionDir\MaksIT.Results" -$outputDir = "$projectDir\bin\Release" -$testProjectDir = "$solutionDir\MaksIT.Results.Tests" - -# Clean previous builds -Write-Host "Cleaning previous builds..." -dotnet clean $projectDir -c Release -dotnet clean $testProjectDir -c Release - -# Run tests -Write-Host "Running tests..." -dotnet test $testProjectDir -c Release -if ($LASTEXITCODE -ne 0) { - Write-Host "Tests failed. Aborting release process." - exit 1 -} - -# Build the project -Write-Host "Building the project..." -dotnet build $projectDir -c Release - -# Pack the NuGet package -Write-Host "Packing the project..." -dotnet pack $projectDir -c Release --no-build - -# Look for the .nupkg file -$packageFile = Get-ChildItem -Path $outputDir -Filter "*.nupkg" -Recurse | Sort-Object LastWriteTime -Descending | Select-Object -First 1 - -if ($packageFile) { - Write-Host "Package created successfully: $($packageFile.FullName)" - - # Push the package to NuGet - Write-Host "Pushing the package to NuGet..." - dotnet nuget push $packageFile.FullName -k $apiKey -s $nugetSource --skip-duplicate - - if ($LASTEXITCODE -eq 0) { - Write-Host "Package pushed successfully." - } else { - Write-Host "Failed to push the package." - } -} else { - Write-Host "Package creation failed. No .nupkg file found." - exit 1 -} diff --git a/src/Release-NuGetPackage.sh b/src/Release-NuGetPackage.sh deleted file mode 100644 index 832322d..0000000 --- a/src/Release-NuGetPackage.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/sh - -# Retrieve the API key from the environment variable -apiKey=$NUGET_MAKS_IT -if [ -z "$apiKey" ]; then - echo "Error: API key not found in environment variable NUGET_MAKS_IT." - exit 1 -fi - -# NuGet source -nugetSource="https://api.nuget.org/v3/index.json" - -# Define paths -scriptDir=$(dirname "$0") -solutionDir=$(realpath "$scriptDir") -projectDir="$solutionDir/MaksIT.Results" -outputDir="$projectDir/bin/Release" - -# Clean previous builds -echo "Cleaning previous builds..." -dotnet clean "$projectDir" -c Release - -# Build the project -echo "Building the project..." -dotnet build "$projectDir" -c Release - -# Pack the NuGet package -echo "Packing the project..." -dotnet pack "$projectDir" -c Release --no-build - -# Look for the .nupkg file -packageFile=$(find "$outputDir" -name "*.nupkg" -print0 | xargs -0 ls -t | head -n 1) - -if [ -n "$packageFile" ]; then - echo "Package created successfully: $packageFile" - - # Push the package to NuGet - echo "Pushing the package to NuGet..." - dotnet nuget push "$packageFile" -k "$apiKey" -s "$nugetSource" --skip-duplicate - - if [ $? -eq 0 ]; then - echo "Package pushed successfully." - else - echo "Failed to push the package." - fi -else - echo "Package creation failed. No .nupkg file found." - exit 1 -fi diff --git a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat new file mode 100644 index 0000000..a2c4bda --- /dev/null +++ b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.bat @@ -0,0 +1,3 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1" +pause \ No newline at end of file diff --git a/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 new file mode 100644 index 0000000..3f1e001 --- /dev/null +++ b/utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 @@ -0,0 +1,220 @@ +<# +.SYNOPSIS + Amends the latest tagged commit and force-pushes updated branch and tag. + +.DESCRIPTION + This script performs the following operations: + 1. Gets the last commit and verifies it has an associated tag + 2. Stages all pending changes + 3. Amends the latest commit (keeps existing message) + 4. Deletes and recreates the tag on the amended commit + 5. Force pushes the branch and tag to remote + + All configuration is in scriptsettings.json. + +.PARAMETER DryRun + If specified, shows what would be done without making changes. + +.EXAMPLE + .\Force-AmendTaggedCommit.ps1 + +.EXAMPLE + .\Force-AmendTaggedCommit.ps1 -DryRun + +.NOTES + CONFIGURATION (scriptsettings.json): + - git.remote: Remote name to push to (default: "origin") + - git.confirmBeforeAmend: Prompt before amending (default: true) + - git.confirmWhenNoChanges: Prompt if no pending changes (default: true) +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [switch]$DryRun +) + +# Get the directory of the current script (for loading settings and relative paths) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$utilsDir = Split-Path $scriptDir -Parent + +#region Import Modules + +# Import shared ScriptConfig module (settings loading + dependency checks) +$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1" +if (-not (Test-Path $scriptConfigModulePath)) { + Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" + exit 1 +} + +# Import shared GitTools module (git operations used by this script) +$gitToolsModulePath = Join-Path $utilsDir "GitTools.psm1" +if (-not (Test-Path $gitToolsModulePath)) { + Write-Error "GitTools module not found at: $gitToolsModulePath" + exit 1 +} + +$loggingModulePath = Join-Path $utilsDir "Logging.psm1" +if (-not (Test-Path $loggingModulePath)) { + Write-Error "Logging module not found at: $loggingModulePath" + exit 1 +} + +Import-Module $scriptConfigModulePath -Force +Import-Module $loggingModulePath -Force +Import-Module $gitToolsModulePath -Force + +#endregion + +#region Load Settings + +$settings = Get-ScriptSettings -ScriptDir $scriptDir + +#endregion + +#region Configuration + +# Git configuration with safe defaults when settings are omitted +$Remote = if ($settings.git.remote) { $settings.git.remote } else { "origin" } +$ConfirmBeforeAmend = if ($null -ne $settings.git.confirmBeforeAmend) { $settings.git.confirmBeforeAmend } else { $true } +$ConfirmWhenNoChanges = if ($null -ne $settings.git.confirmWhenNoChanges) { $settings.git.confirmWhenNoChanges } else { $true } + +#endregion + +#region Validate CLI Dependencies + +Assert-Command git + +#endregion + +#region Main + +Write-Log -Level "INFO" -Message "========================================" +Write-Log -Level "INFO" -Message "Force Amend Tagged Commit Script" +Write-Log -Level "INFO" -Message "========================================" + +if ($DryRun) { + Write-Log -Level "WARN" -Message "*** DRY RUN MODE - No changes will be made ***" +} + +#region Preflight + +# 1. Detect current branch +$Branch = Get-CurrentBranch + +# 2. Read HEAD commit details +Write-LogStep "Getting last commit..." +$CommitMessage = Get-HeadCommitMessage +$CommitHash = Get-HeadCommitHash -Short +Write-Log -Level "INFO" -Message "Commit: $CommitHash - $CommitMessage" + +# 3. Ensure HEAD has at least one tag +Write-LogStep "Finding tag on last commit..." +$tags = @(Get-HeadTags) +if ($tags.Count -eq 0) { + Write-Error "No tag found on the last commit ($CommitHash). This script requires the last commit to have an associated tag." + exit 1 +} + +# If multiple tags exist, use the first one returned by git. +$TagName = $tags[0] +Write-Log -Level "OK" -Message "Found tag: $TagName" + +# 4. Inspect pending changes before amend +Write-LogStep "Checking pending changes..." +$Status = Get-GitStatusShort +if (-not [string]::IsNullOrWhiteSpace($Status)) { + Write-Log -Level "INFO" -Message "Pending changes:" + $Status -split "`r?`n" | ForEach-Object { Write-Log -Level "INFO" -Message " $_" } +} +else { + Write-Log -Level "WARN" -Message "No pending changes found" + if ($ConfirmWhenNoChanges -and -not $DryRun) { + $confirm = Read-Host "`n No changes to amend. Continue to recreate tag and force push? (y/N)" + if ($confirm -ne 'y' -and $confirm -ne 'Y') { + Write-Log -Level "WARN" -Message "Aborted by user" + exit 0 + } + } +} + +# 5. Show operation summary and request explicit confirmation +Write-Log -Level "INFO" -Message "----------------------------------------" +Write-Log -Level "INFO" -Message "Summary of operations:" +Write-Log -Level "INFO" -Message "----------------------------------------" +Write-Log -Level "INFO" -Message "Branch: $Branch" +Write-Log -Level "INFO" -Message "Commit: $CommitHash" +Write-Log -Level "INFO" -Message "Tag: $TagName" +Write-Log -Level "INFO" -Message "Remote: $Remote" +Write-Log -Level "INFO" -Message "----------------------------------------" + +if ($ConfirmBeforeAmend -and -not $DryRun) { + $confirm = Read-Host " Proceed with amend and force push? (y/N)" + if ($confirm -ne 'y' -and $confirm -ne 'Y') { + Write-Log -Level "WARN" -Message "Aborted by user" + exit 0 + } +} + +#endregion + +#region Amend And Push + +# 6. Stage all changes to include them in amended commit +Write-LogStep "Staging all changes..." +if (-not $DryRun) { + Add-AllChanges +} +Write-Log -Level "OK" -Message "All changes staged" + +# 7. Amend HEAD commit while preserving commit message +Write-LogStep "Amending commit..." +if (-not $DryRun) { + Update-HeadCommitNoEdit +} +Write-Log -Level "OK" -Message "Commit amended" + +# 8. Move existing local tag to the amended commit +Write-LogStep "Deleting local tag '$TagName'..." +if (-not $DryRun) { + Remove-LocalTag -Tag $TagName +} +Write-Log -Level "OK" -Message "Local tag deleted" + +# 9. Recreate the same tag on new HEAD +Write-LogStep "Recreating tag '$TagName' on amended commit..." +if (-not $DryRun) { + New-LocalTag -Tag $TagName +} +Write-Log -Level "OK" -Message "Tag recreated" + +# 10. Force push updated branch history +Write-LogStep "Force pushing branch '$Branch' to $Remote..." +if (-not $DryRun) { + Push-BranchToRemote -Branch $Branch -Remote $Remote -Force +} +Write-Log -Level "OK" -Message "Branch force pushed" + +# 11. Force push moved tag +Write-LogStep "Force pushing tag '$TagName' to $Remote..." +if (-not $DryRun) { + Push-TagToRemote -Tag $TagName -Remote $Remote -Force +} +Write-Log -Level "OK" -Message "Tag force pushed" + +#endregion + +#region Summary + +Write-Log -Level "OK" -Message "========================================" +Write-Log -Level "OK" -Message "Operation completed successfully!" +Write-Log -Level "OK" -Message "========================================" + +# Show resulting HEAD commit after amend +Write-Log -Level "INFO" -Message "Final state:" +$finalLog = Get-HeadCommitOneLine +Write-Log -Level "INFO" -Message $finalLog + +#endregion + +#endregion diff --git a/utils/Force-AmendTaggedCommit/scriptsettings.json b/utils/Force-AmendTaggedCommit/scriptsettings.json new file mode 100644 index 0000000..df73911 --- /dev/null +++ b/utils/Force-AmendTaggedCommit/scriptsettings.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "Configuration for Force-AmendTaggedCommit.ps1", + + "git": { + "remote": "origin", + "confirmBeforeAmend": true, + "confirmWhenNoChanges": true + }, + + "_comments": { + "git": { + "remote": "Remote name used for force-pushing branch and tag", + "confirmBeforeAmend": "Ask for confirmation before amend + force-push operations", + "confirmWhenNoChanges": "Ask for confirmation when there are no pending changes to amend" + } + } +} diff --git a/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat b/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat new file mode 100644 index 0000000..4569dab --- /dev/null +++ b/utils/Generate-CoverageBadges/Generate-CoverageBadges.bat @@ -0,0 +1,3 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1" +pause diff --git a/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 b/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 new file mode 100644 index 0000000..5c4bdde --- /dev/null +++ b/utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1 @@ -0,0 +1,232 @@ +<# +.SYNOPSIS + Runs tests, collects coverage, and generates SVG badges for README. + +.DESCRIPTION + This script runs unit tests via TestRunner.psm1, then generates shields.io-style + SVG badges for line, branch, and method coverage. + Optional HTML report generation is controlled by scriptsettings.json (openReport). + + Configuration is stored in scriptsettings.json: + - openReport : Generate and open full HTML report (true/false) + - paths.testProject : Relative path to test project + - paths.badgesDir : Relative path to badges output directory + - badges : Array of badges to generate (name, label, metric) + - colorThresholds : Coverage percentages for badge colors + + Badge colors based on coverage: + - brightgreen (>=80%), green (>=60%), yellowgreen (>=40%) + - yellow (>=20%), orange (>=10%), red (<10%) + If openReport is true, ReportGenerator is required: + dotnet tool install -g dotnet-reportgenerator-globaltool + +.EXAMPLE + .\Generate-CoverageBadges.ps1 + Runs tests and generates coverage badges (and optionally HTML report if configured). + +.OUTPUTS + SVG badge files in the configured badges directory. + +.NOTES + Author: MaksIT + Requires: .NET SDK, Coverlet (included in test project) +#> + +$ErrorActionPreference = "Stop" + +# Get the directory of the current script (for loading settings and relative paths) +$ScriptDir = $PSScriptRoot +$UtilsDir = Split-Path $ScriptDir -Parent + +#region Import Modules + +# Import TestRunner module (executes tests and collects coverage metrics) +$testRunnerModulePath = Join-Path $UtilsDir "TestRunner.psm1" +if (-not (Test-Path $testRunnerModulePath)) { + Write-Error "TestRunner module not found at: $testRunnerModulePath" + exit 1 +} +Import-Module $testRunnerModulePath -Force + +# Import shared ScriptConfig module (settings + command validation helpers) +$scriptConfigModulePath = Join-Path $UtilsDir "ScriptConfig.psm1" +if (-not (Test-Path $scriptConfigModulePath)) { + Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" + exit 1 +} +Import-Module $scriptConfigModulePath -Force + +# Import shared Logging module (timestamped/aligned output) +$loggingModulePath = Join-Path $UtilsDir "Logging.psm1" +if (-not (Test-Path $loggingModulePath)) { + Write-Error "Logging module not found at: $loggingModulePath" + exit 1 +} +Import-Module $loggingModulePath -Force + +#endregion + +#region Load Settings + +$Settings = Get-ScriptSettings -ScriptDir $ScriptDir + +$thresholds = $Settings.colorThresholds + +#endregion + +#region Configuration + +# Runtime options from settings +$OpenReport = if ($null -ne $Settings.openReport) { [bool]$Settings.openReport } else { $false } + +# Resolve configured paths to absolute paths +$TestProjectPath = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.testProject)) +$BadgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.badgesDir)) + +# Ensure badges directory exists +if (-not (Test-Path $BadgesDir)) { + New-Item -ItemType Directory -Path $BadgesDir | Out-Null +} + +#endregion + +#region Helpers + +# Maps a coverage percentage to a shields.io color using configured thresholds. +function Get-BadgeColor { + param([double]$percentage) + + if ($percentage -ge $thresholds.brightgreen) { return "brightgreen" } + if ($percentage -ge $thresholds.green) { return "green" } + if ($percentage -ge $thresholds.yellowgreen) { return "yellowgreen" } + if ($percentage -ge $thresholds.yellow) { return "yellow" } + if ($percentage -ge $thresholds.orange) { return "orange" } + return "red" +} + +# Builds a shields.io-like SVG badge string for one metric. +function New-Badge { + param( + [string]$label, + [string]$value, + [string]$color + ) + + # Calculate widths (approximate character width of 6.5px for the font) + $labelWidth = [math]::Max(($label.Length * 6.5) + 10, 50) + $valueWidth = [math]::Max(($value.Length * 6.5) + 10, 40) + $totalWidth = $labelWidth + $valueWidth + $labelX = $labelWidth / 2 + $valueX = $labelWidth + ($valueWidth / 2) + + $colorMap = @{ + "brightgreen" = "#4c1" + "green" = "#97ca00" + "yellowgreen" = "#a4a61d" + "yellow" = "#dfb317" + "orange" = "#fe7d37" + "red" = "#e05d44" + } + $hexColor = $colorMap[$color] + if (-not $hexColor) { $hexColor = "#9f9f9f" } + + return @" + + $label`: $value + + + + + + + + + + + + + + + $label + + $value + + +"@ +} + +#endregion + +#region Main + +#region Test And Coverage + +$coverage = Invoke-TestsWithCoverage -TestProjectPath $TestProjectPath -KeepResults:$OpenReport +if (-not $coverage.Success) { + Write-Error "Tests failed: $($coverage.Error)" + exit 1 +} + +Write-Log -Level "OK" -Message "Tests passed!" + +$metrics = @{ + "line" = $coverage.LineRate + "branch" = $coverage.BranchRate + "method" = $coverage.MethodRate +} + +#endregion + +#region Generate Badges + +Write-LogStep -Message "Generating coverage badges..." + +foreach ($badge in $Settings.badges) { + $metricValue = $metrics[$badge.metric] + $color = Get-BadgeColor $metricValue + $svg = New-Badge -label $badge.label -value "$metricValue%" -color $color + $path = Join-Path $BadgesDir $badge.name + $svg | Out-File -FilePath $path -Encoding utf8 + Write-Log -Level "OK" -Message "$($badge.name): $($badge.label) = $metricValue%" +} + +#endregion + +#region Summary + +Write-Log -Level "INFO" -Message "Coverage Summary:" +Write-Log -Level "INFO" -Message "Line Coverage: $($coverage.LineRate)%" +Write-Log -Level "INFO" -Message "Branch Coverage: $($coverage.BranchRate)%" +Write-Log -Level "INFO" -Message "Method Coverage: $($coverage.MethodRate)% ($($coverage.CoveredMethods) of $($coverage.TotalMethods) methods)" +Write-Log -Level "OK" -Message "Badges generated in: $BadgesDir" +Write-Log -Level "STEP" -Message "Commit the badges/ folder to update README." + +#endregion + +#region Optional Html Report + +if ($OpenReport -and $coverage.CoverageFile) { + Write-LogStep -Message "Generating HTML report..." + Assert-Command reportgenerator + + $ResultsDir = Split-Path (Split-Path $coverage.CoverageFile -Parent) -Parent + $ReportDir = Join-Path $ResultsDir "report" + + $reportGenArgs = @( + "-reports:$($coverage.CoverageFile)" + "-targetdir:$ReportDir" + "-reporttypes:Html" + ) + & reportgenerator @reportGenArgs + + $IndexFile = Join-Path $ReportDir "index.html" + if (Test-Path $IndexFile) { + Start-Process $IndexFile + } + + Write-Log -Level "INFO" -Message "TestResults kept for HTML report viewing." +} + +#endregion + +#endregion diff --git a/utils/Generate-CoverageBadges/scriptsettings.json b/utils/Generate-CoverageBadges/scriptsettings.json new file mode 100644 index 0000000..120a1a5 --- /dev/null +++ b/utils/Generate-CoverageBadges/scriptsettings.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Generate Coverage Badges Script Settings", + "description": "Configuration for Generate-CoverageBadges.ps1 script", + "openReport": false, + "paths": { + "testProject": "..\\..\\src\\MaksIT.Results.Tests", + "badgesDir": "..\\..\\assets\\badges" + }, + "badges": [ + { + "name": "coverage-lines.svg", + "label": "Line Coverage", + "metric": "line" + }, + { + "name": "coverage-branches.svg", + "label": "Branch Coverage", + "metric": "branch" + }, + { + "name": "coverage-methods.svg", + "label": "Method Coverage", + "metric": "method" + } + ], + "colorThresholds": { + "brightgreen": 80, + "green": 60, + "yellowgreen": 40, + "yellow": 20, + "orange": 10, + "red": 0 + }, + "_comments": { + "openReport": "If true, generate and open full HTML coverage report (requires reportgenerator tool).", + "paths": { + "testProject": "Relative path to test project used by TestRunner.", + "badgesDir": "Relative path where SVG coverage badges are written." + }, + "badges": "List of output badges. Each entry maps a metric key (line|branch|method) to filename and label.", + "colorThresholds": "Coverage percentage thresholds used to pick badge colors." + } +} diff --git a/utils/GitTools.psm1 b/utils/GitTools.psm1 new file mode 100644 index 0000000..5b795c9 --- /dev/null +++ b/utils/GitTools.psm1 @@ -0,0 +1,265 @@ +# +# Shared Git helpers for utility scripts. +# + +function Import-LoggingModuleInternal { + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + return + } + + $modulePath = Join-Path $PSScriptRoot "Logging.psm1" + if (Test-Path $modulePath) { + Import-Module $modulePath -Force + } +} + +function Write-GitToolsLogInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [Parameter(Mandatory = $false)] + [ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")] + [string]$Level = "INFO" + ) + + Import-LoggingModuleInternal + + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + Write-Log -Level $Level -Message $Message + return + } + + Write-Host $Message -ForegroundColor Gray +} + +# Internal: +# Purpose: +# - Execute a git command and enforce fail-fast error handling. +function Invoke-GitInternal { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + + [Parameter(Mandatory = $false)] + [switch]$CaptureOutput, + + [Parameter(Mandatory = $false)] + [string]$ErrorMessage = "Git command failed" + ) + + if ($CaptureOutput) { + $output = & git @Arguments 2>&1 + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + Write-Error "$ErrorMessage (exit code: $exitCode)" + exit 1 + } + + if ($null -eq $output) { + return "" + } + + return ($output -join "`n").Trim() + } + + & git @Arguments + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + Write-Error "$ErrorMessage (exit code: $exitCode)" + exit 1 + } +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Resolve and print the current branch name. +function Get-CurrentBranch { + Write-GitToolsLogInternal -Level "STEP" -Message "Detecting current branch..." + + $branch = Invoke-GitInternal -Arguments @("rev-parse", "--abbrev-ref", "HEAD") -CaptureOutput -ErrorMessage "Could not determine current branch" + Write-GitToolsLogInternal -Level "OK" -Message "Branch: $branch" + return $branch +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Return `git status --short` output for pending-change checks. +function Get-GitStatusShort { + return Invoke-GitInternal -Arguments @("status", "--short") -CaptureOutput -ErrorMessage "Failed to get git status" +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# Purpose: +# - Get exact tag name attached to HEAD (release flow). +function Get-CurrentCommitTag { + param( + [Parameter(Mandatory = $true)] + [string]$Version + ) + + Write-GitToolsLogInternal -Level "STEP" -Message "Checking for tag on current commit..." + $tag = Invoke-GitInternal -Arguments @("describe", "--tags", "--exact-match", "HEAD") -CaptureOutput -ErrorMessage "No tag found on current commit. Create a tag: git tag v$Version" + return $tag +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Get all tag names pointing at HEAD. +function Get-HeadTags { + $tagsRaw = Invoke-GitInternal -Arguments @("tag", "--points-at", "HEAD") -CaptureOutput -ErrorMessage "Failed to list tags on HEAD" + + if ([string]::IsNullOrWhiteSpace($tagsRaw)) { + return @() + } + + return @($tagsRaw -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# Purpose: +# - Check whether a given tag exists on the remote. +function Test-RemoteTagExists { + param( + [Parameter(Mandatory = $true)] + [string]$Tag, + + [Parameter(Mandatory = $false)] + [string]$Remote = "origin" + ) + + $remoteTag = Invoke-GitInternal -Arguments @("ls-remote", "--tags", $Remote, $Tag) -CaptureOutput -ErrorMessage "Failed to check remote tag existence" + return [bool]$remoteTag +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Push tag to remote (optionally with `--force`). +function Push-TagToRemote { + param( + [Parameter(Mandatory = $true)] + [string]$Tag, + + [Parameter(Mandatory = $false)] + [string]$Remote = "origin", + + [Parameter(Mandatory = $false)] + [switch]$Force + ) + + $pushArgs = @("push") + if ($Force) { + $pushArgs += "--force" + } + $pushArgs += @($Remote, $Tag) + + Invoke-GitInternal -Arguments $pushArgs -ErrorMessage "Failed to push tag $Tag to remote $Remote" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Push branch to remote (optionally with `--force`). +function Push-BranchToRemote { + param( + [Parameter(Mandatory = $true)] + [string]$Branch, + + [Parameter(Mandatory = $false)] + [string]$Remote = "origin", + + [Parameter(Mandatory = $false)] + [switch]$Force + ) + + $pushArgs = @("push") + if ($Force) { + $pushArgs += "--force" + } + $pushArgs += @($Remote, $Branch) + + Invoke-GitInternal -Arguments $pushArgs -ErrorMessage "Failed to push branch $Branch to remote $Remote" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Get HEAD commit hash. +function Get-HeadCommitHash { + param( + [Parameter(Mandatory = $false)] + [switch]$Short + ) + + $format = if ($Short) { "--format=%h" } else { "--format=%H" } + return Invoke-GitInternal -Arguments @("log", "-1", $format) -CaptureOutput -ErrorMessage "Failed to get HEAD commit hash" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Get HEAD commit subject line. +function Get-HeadCommitMessage { + return Invoke-GitInternal -Arguments @("log", "-1", "--format=%s") -CaptureOutput -ErrorMessage "Failed to get HEAD commit message" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Stage all changes (tracked, untracked, deletions). +function Add-AllChanges { + Invoke-GitInternal -Arguments @("add", "-A") -ErrorMessage "Failed to stage changes" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Amend HEAD commit and keep existing commit message. +function Update-HeadCommitNoEdit { + Invoke-GitInternal -Arguments @("commit", "--amend", "--no-edit") -ErrorMessage "Failed to amend commit" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Delete local tag. +function Remove-LocalTag { + param( + [Parameter(Mandatory = $true)] + [string]$Tag + ) + + Invoke-GitInternal -Arguments @("tag", "-d", $Tag) -ErrorMessage "Failed to delete local tag" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Create local tag. +function New-LocalTag { + param( + [Parameter(Mandatory = $true)] + [string]$Tag + ) + + Invoke-GitInternal -Arguments @("tag", $Tag) -ErrorMessage "Failed to create tag" +} + +# Used by: +# - utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Get HEAD one-line commit info. +function Get-HeadCommitOneLine { + return Invoke-GitInternal -Arguments @("log", "-1", "--oneline") -CaptureOutput -ErrorMessage "Failed to read final commit state" +} + +Export-ModuleMember -Function Get-CurrentBranch, Get-GitStatusShort, Get-CurrentCommitTag, Get-HeadTags, Test-RemoteTagExists, Push-TagToRemote, Push-BranchToRemote, Get-HeadCommitHash, Get-HeadCommitMessage, Add-AllChanges, Update-HeadCommitNoEdit, Remove-LocalTag, New-LocalTag, Get-HeadCommitOneLine diff --git a/utils/Logging.psm1 b/utils/Logging.psm1 new file mode 100644 index 0000000..28be784 --- /dev/null +++ b/utils/Logging.psm1 @@ -0,0 +1,67 @@ +function Get-LogTimestampInternal { + return (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") +} + +function Get-LogColorInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Level + ) + + switch ($Level.ToUpperInvariant()) { + "OK" { return "Green" } + "INFO" { return "Gray" } + "WARN" { return "Yellow" } + "ERROR" { return "Red" } + "STEP" { return "Cyan" } + "DEBUG" { return "DarkGray" } + default { return "White" } + } +} + +function Write-Log { + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [Parameter(Mandatory = $false)] + [ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")] + [string]$Level = "INFO", + + [Parameter(Mandatory = $false)] + [switch]$NoTimestamp + ) + + $levelToken = "[$($Level.ToUpperInvariant())]" + $padding = " " * [Math]::Max(1, (10 - $levelToken.Length)) + $prefix = if ($NoTimestamp) { "" } else { "[$(Get-LogTimestampInternal)] " } + $line = "$prefix$levelToken$padding$Message" + + Write-Host $line -ForegroundColor (Get-LogColorInternal -Level $Level) +} + +function Write-LogStep { + param( + [Parameter(Mandatory = $true)] + [string]$Message + ) + + Write-Log -Level "STEP" -Message $Message +} + +function Write-LogStepResult { + param( + [Parameter(Mandatory = $true)] + [ValidateSet("OK", "FAIL")] + [string]$Status, + + [Parameter(Mandatory = $false)] + [string]$Message + ) + + $level = if ($Status -eq "FAIL") { "ERROR" } else { "OK" } + $text = if ([string]::IsNullOrWhiteSpace($Message)) { $Status } else { $Message } + Write-Log -Level $level -Message $text +} + +Export-ModuleMember -Function Write-Log, Write-LogStep, Write-LogStepResult diff --git a/utils/Release-NuGetPackage/Release-NuGetPackage.bat b/utils/Release-NuGetPackage/Release-NuGetPackage.bat new file mode 100644 index 0000000..7fa08e9 --- /dev/null +++ b/utils/Release-NuGetPackage/Release-NuGetPackage.bat @@ -0,0 +1,3 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-NuGetPackage.ps1" +pause \ No newline at end of file diff --git a/utils/Release-NuGetPackage/Release-NuGetPackage.ps1 b/utils/Release-NuGetPackage/Release-NuGetPackage.ps1 new file mode 100644 index 0000000..8ed7082 --- /dev/null +++ b/utils/Release-NuGetPackage/Release-NuGetPackage.ps1 @@ -0,0 +1,770 @@ +<# +.SYNOPSIS + Builds, tests, packs, and publishes MaksIT.Core to NuGet and GitHub releases. + +.DESCRIPTION + This script automates the release process for MaksIT.Core library. + The script is IDEMPOTENT - you can safely re-run it if any step fails. + It will skip already-completed steps (NuGet and GitHub) and only create what's missing. + GitHub repository target can be configured explicitly in scriptsettings.json. + + Features: + - Validates environment and prerequisites + - Checks if version already exists on NuGet.org (skips if released) + - Checks if GitHub release exists (skips if released) + - Scans for vulnerable packages (security check) + - Builds and tests the project (Windows + Linux via Docker) + - Collects code coverage with Coverlet (threshold enforcement optional) + - Generates test result artifacts (TRX format) and coverage reports + - Displays test results with pass/fail counts and coverage percentage + - Publishes to NuGet.org + - Creates a GitHub release with changelog and package assets + - Shows timing summary for all steps + +.REQUIREMENTS + Environment Variables: + - NUGET_MAKS_IT : NuGet.org API key for publishing packages + - GITHUB_MAKS_IT_COM : GitHub Personal Access Token (needs 'repo' scope) + + Tools (Required): + - dotnet CLI : For building, testing, and packing + - git : For version control operations + - gh (GitHub CLI) : For creating GitHub releases + - docker : For cross-platform Linux testing + +.WORKFLOW + 1. VALIDATION PHASE + - Check required environment variables (NuGet key, GitHub token) + - Check required tools are installed (dotnet, git, gh, docker) + - Verify no uncommitted changes in working directory + - Authenticate GitHub CLI + + 2. VERSION & RELEASE CHECK PHASE (Idempotent) + - Read latest version from CHANGELOG.md + - Find commit with matching version tag + - Validate tag is on configured release branch (from scriptsettings.json) + - Check if already released on NuGet.org (mark for skip if yes) + - Check if GitHub release exists (mark for skip if yes) + - Read target framework from MaksIT.Core.csproj + - Extract release notes from CHANGELOG.md for current version + + 3. SECURITY SCAN + - Check for vulnerable packages (dotnet list package --vulnerable) + - Fail or warn based on $failOnVulnerabilities setting + + 4. BUILD & TEST PHASE + - Clean previous builds (delete bin/obj folders) + - Restore NuGet packages + - Windows: Build main project -> Build test project -> Run tests with coverage + - Analyze code coverage (fail if below threshold when configured) + - Linux (Docker): Build main project -> Build test project -> Run tests (TRX report) + - Rebuild for Windows (Docker may overwrite bin/obj) + - Create NuGet package (.nupkg) and symbols (.snupkg) + - All steps are timed for performance tracking + + 5. CONFIRMATION PHASE + - Display release summary + - Prompt user for confirmation before proceeding + + 6. NUGET RELEASE PHASE (Idempotent) + - Skip if version already exists on NuGet.org + - Otherwise, push package to NuGet.org + + 7. GITHUB RELEASE PHASE (Idempotent) + - Skip if release already exists + - Push tag to remote if not already there + - Create GitHub release with: + * Release notes from CHANGELOG.md + * .nupkg and .snupkg as downloadable assets + + 8. COMPLETION PHASE + - Display timing summary for all steps + - Display test results summary + - Display success summary with links + - Open NuGet and GitHub release pages in browser + - TODO: Email notification (template provided) + - TODO: Package signing (template provided) + +.USAGE + Before running: + 1. Ensure Docker Desktop is running (for Linux tests) + 2. Update version in MaksIT.Core.csproj + 3. Run .\Generate-Changelog.ps1 to update CHANGELOG.md and LICENSE.md + 4. Review and commit all changes + 5. Create version tag: git tag v1.x.x + 6. Run: .\Release-NuGetPackage.ps1 + + Note: The script finds the commit with the tag matching CHANGELOG.md version. + You can run it from any branch/commit - it releases the tagged commit. + + Re-run release (idempotent - skips NuGet/GitHub if already released): + .\Release-NuGetPackage.ps1 + + Generate changelog and update LICENSE year: + .\Generate-Changelog.ps1 + +.CONFIGURATION + All settings are stored in scriptsettings.json: + - packageSigning: Code signing certificate configuration + - emailNotification: SMTP settings for release notifications + +.NOTES + Author: Maksym Sadovnychyy (MAKS-IT) + Repository: https://github.com/MAKS-IT-COM/maksit-core +#> + +# No parameters - behavior is controlled by current branch (configured in scriptsettings.json): +# - dev branch -> Local build only (no tag required, uncommitted changes allowed) +# - release branch -> Full release to GitHub (tag required, clean working directory) + +# Get the directory of the current script (for loading settings and relative paths) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +#region Import Modules + +# Import TestRunner module +$utilsDir = Split-Path $scriptDir -Parent + +$testRunnerModulePath = Join-Path $utilsDir "TestRunner.psm1" +if (-not (Test-Path $testRunnerModulePath)) { + Write-Error "TestRunner module not found at: $testRunnerModulePath" + exit 1 +} + +Import-Module $testRunnerModulePath -Force + +# Import ScriptConfig module +$scriptConfigModulePath = Join-Path $utilsDir "ScriptConfig.psm1" +if (-not (Test-Path $scriptConfigModulePath)) { + Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" + exit 1 +} + +Import-Module $scriptConfigModulePath -Force + +# Import Logging module +$loggingModulePath = Join-Path $utilsDir "Logging.psm1" +if (-not (Test-Path $loggingModulePath)) { + Write-Error "Logging module not found at: $loggingModulePath" + exit 1 +} + +Import-Module $loggingModulePath -Force + + +# Import GitTools module +$gitToolsModulePath = Join-Path $utilsDir "GitTools.psm1" +if (-not (Test-Path $gitToolsModulePath)) { + Write-Error "GitTools module not found at: $gitToolsModulePath" + exit 1 +} + +Import-Module $gitToolsModulePath -Force + +#endregion + +#region Load Settings +$settings = Get-ScriptSettings -ScriptDir $scriptDir + +#endregion + +#region Configuration + +# GitHub configuration +$githubReleseEnabled = $settings.github.enabled +$githubTokenEnvVar = $settings.github.githubToken +$githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar) +$githubRepositorySetting = $settings.github.repository + +# NuGet configuration +$nugetReleseEnabled = $settings.nuget.enabled +$nugetApiKeyEnvVar = $settings.nuget.nugetApiKey +$nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar) +$nugetSource = if ($settings.nuget.source) { $settings.nuget.source } else { "https://api.nuget.org/v3/index.json" } + +# Paths from settings (resolve relative to script directory) +$csprojPaths = @() +$rawCsprojPaths = @() + +if ($settings.paths.csprojPaths) { + if ($settings.paths.csprojPaths -is [System.Collections.IEnumerable] -and -not ($settings.paths.csprojPaths -is [string])) { + $rawCsprojPaths += $settings.paths.csprojPaths + } + else { + $rawCsprojPaths += $settings.paths.csprojPaths + } +} +else { + Write-Error "No csproj path configured. Set 'paths.csprojPaths' (preferred) or 'paths.csprojPath' in scriptsettings.json." + exit 1 +} + +foreach ($path in $rawCsprojPaths) { + if ([string]::IsNullOrWhiteSpace($path)) { + continue + } + + $resolvedPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $path)) + $csprojPaths += $resolvedPath +} + +if ($csprojPaths.Count -eq 0) { + Write-Error "No valid csproj paths configured in scriptsettings.json." + exit 1 +} + +$testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testResultsDir)) +$stagingDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.stagingDir)) +$releaseDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.releaseDir)) +$changelogPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.changelogPath)) +$testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testProject)) + +# Release naming patterns +$zipNamePattern = $settings.release.zipNamePattern +$releaseTitlePattern = $settings.release.releaseTitlePattern + +# Branch configuration +$releaseBranch = $settings.branches.release +$devBranch = $settings.branches.dev + +#endregion + +#region Helpers + +# Helper: extract a csproj property (first match) +function Get-CsprojPropertyValue { + param( + [Parameter(Mandatory=$true)][xml]$csproj, + [Parameter(Mandatory=$true)][string]$propertyName + ) + + $propNode = $csproj.Project.PropertyGroup | + Where-Object { $_.$propertyName } | + Select-Object -First 1 + + if ($propNode) { + return $propNode.$propertyName + } + + return $null +} + +# Helper: resolve output assembly name for published exe +function Resolve-ProjectExeName { + param( + [Parameter(Mandatory=$true)][string]$projPath + ) + + [xml]$csproj = Get-Content $projPath + $assemblyName = Get-CsprojPropertyValue -csproj $csproj -propertyName "AssemblyName" + if ($assemblyName) { + return $assemblyName + } + + return [System.IO.Path]::GetFileNameWithoutExtension($projPath) +} + +# Helper: check for uncommitted changes +function Assert-WorkingTreeClean { + param( + [Parameter(Mandatory = $true)] + [bool]$IsReleaseBranch + ) + + $gitStatus = Get-GitStatusShort + if ($gitStatus) { + if ($IsReleaseBranch) { + Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing." + Write-Log -Level "WARN" -Message "Uncommitted files:" + $gitStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } + exit 1 + } + else { + Write-Log -Level "WARN" -Message " Uncommitted changes detected (allowed on dev branch)." + } + } + else { + Write-Log -Level "OK" -Message " Working directory is clean." + } +} + +# Helper: read versions from csproj files +function Get-CsprojVersions { + param( + [Parameter(Mandatory = $true)] + [string[]]$CsprojPaths + ) + + Write-Log -Level "INFO" -Message "Reading version(s) from csproj(s)..." + $projectVersions = @{} + + foreach ($projPath in $CsprojPaths) { + if (-not (Test-Path $projPath -PathType Leaf)) { + Write-Error "Csproj file not found at: $projPath" + exit 1 + } + + if ([System.IO.Path]::GetExtension($projPath) -ne ".csproj") { + Write-Error "Configured path is not a .csproj file: $projPath" + exit 1 + } + + [xml]$csproj = Get-Content $projPath + $version = Get-CsprojPropertyValue -csproj $csproj -propertyName "Version" + + if (-not $version) { + Write-Error "Version not found in $projPath" + exit 1 + } + + $projectVersions[$projPath] = $version + Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projPath)): $version" + } + + return $projectVersions +} + +# Helper: resolve GitHub repository (owner/repo) from settings override or remote URL +function Resolve-GitHubRepository { + param( + [Parameter(Mandatory = $false)] + [string]$RepositorySetting + ) + + if (-not [string]::IsNullOrWhiteSpace($RepositorySetting)) { + $value = $RepositorySetting.Trim() + + if ($value -match '^https?://github\.com/(?[^/]+)/(?[^/]+?)(?:\.git)?/?$') { + return "$($Matches['owner'])/$($Matches['repo'])" + } + + if ($value -match '^(?[^/]+)/(?[^/]+)$') { + return "$($Matches['owner'])/$($Matches['repo'])" + } + + Write-Error "Invalid github.repository format '$value'. Use 'owner/repo' or 'https://github.com/owner/repo'." + exit 1 + } + + $remoteUrl = git config --get remote.origin.url + if ($LASTEXITCODE -ne 0 -or -not $remoteUrl) { + Write-Error "Could not determine git remote origin URL. Configure github.repository in scriptsettings.json." + exit 1 + } + + if ($remoteUrl -match "[:/](?[^/]+)/(?[^/.]+)(\.git)?$") { + return "$($Matches['owner'])/$($Matches['repo'])" + } + + Write-Error "Could not parse repository from remote URL: $remoteUrl. Configure github.repository in scriptsettings.json." + exit 1 +} + +#endregion + +#region Validate CLI Dependencies + +Assert-Command dotnet +Assert-Command git +Assert-Command docker +# gh command check deferred until after branch detection (only needed on release branch) + +#endregion + +#region Main + +Write-Log -Level "STEP" -Message "==================================================" +Write-Log -Level "STEP" -Message "RELEASE BUILD" +Write-Log -Level "STEP" -Message "==================================================" + +#region Preflight + +$isDevBranch = $false +$isReleaseBranch = $false + +# 1. Detect current branch and determine release mode +$currentBranch = Get-CurrentBranch + +$isDevBranch = $currentBranch -eq $devBranch +$isReleaseBranch = $currentBranch -eq $releaseBranch + +if (-not $isDevBranch -and -not $isReleaseBranch) { + Write-Error "Releases can only be created from '$releaseBranch' or '$devBranch' branches. Current branch: $currentBranch" + exit 1 +} + +# 2. Check for uncommitted changes (required on release branch, allowed on dev) +Assert-WorkingTreeClean -IsReleaseBranch:$isReleaseBranch + +# 3. Get version from csproj (source of truth) +$projectVersions = Get-CsprojVersions -CsprojPaths $csprojPaths + +# Use the first project's version as the release version +$version = $projectVersions[$csprojPaths[0]] + +# 4. Handle tag based on branch +if ($isReleaseBranch) { + # Release branch: tag is required and must match version + $tag = Get-CurrentCommitTag -Version $version + + if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') { + Write-Error "Tag '$tag' does not match expected format 'vX.Y.Z' (e.g., v$version)." + exit 1 + } + + $tagVersion = $Matches[1] + + if ($tagVersion -ne $version) { + Write-Error "Tag version ($tagVersion) does not match csproj version ($version)." + Write-Log -Level "WARN" -Message " Either update the tag or the csproj version." + exit 1 + } + + Write-Log -Level "OK" -Message " Tag found: $tag (matches csproj)" +} +else { + # Dev branch: no tag required, use version from csproj + $tag = "v$version" + Write-Log -Level "INFO" -Message " Using version from csproj (no tag required on dev)." +} + +# 5. Verify CHANGELOG.md has matching version entry +Write-Log -Level "INFO" -Message "Verifying CHANGELOG.md..." +if (-not (Test-Path $changelogPath)) { + Write-Error "CHANGELOG.md not found at: $changelogPath" + exit 1 +} + +$changelog = Get-Content $changelogPath -Raw + +if ($changelog -notmatch '##\s+v(\d+\.\d+\.\d+)') { + Write-Error "No version entry found in CHANGELOG.md" + exit 1 +} + +$changelogVersion = $Matches[1] + +if ($changelogVersion -ne $version) { + Write-Error "Csproj version ($version) does not match latest CHANGELOG.md version ($changelogVersion)." + Write-Log -Level "WARN" -Message " Update CHANGELOG.md or the csproj version." + exit 1 +} + +Write-Log -Level "OK" -Message " CHANGELOG.md version matches: v$changelogVersion" + + + +Write-Log -Level "OK" -Message "All pre-flight checks passed!" + +#endregion + +#region Test + +Write-Log -Level "STEP" -Message "Running tests..." + +# Run tests using TestRunner module +$testResult = Invoke-TestsWithCoverage -TestProjectPath $testProjectPath -ResultsDirectory $testResultsDir -Silent + +if (-not $testResult.Success) { + Write-Error "Tests failed. Release aborted." + Write-Log -Level "ERROR" -Message " Error: $($testResult.Error)" + exit 1 +} + +Write-Log -Level "OK" -Message " All tests passed!" +Write-Log -Level "INFO" -Message " Line Coverage: $($testResult.LineRate)%" +Write-Log -Level "INFO" -Message " Branch Coverage: $($testResult.BranchRate)%" +Write-Log -Level "INFO" -Message " Method Coverage: $($testResult.MethodRate)%" + +#endregion + +#region Build And Publish + +# 7. Prepare staging directory +Write-Log -Level "STEP" -Message "Preparing staging directory..." +if (Test-Path $stagingDir) { + Remove-Item $stagingDir -Recurse -Force +} + +New-Item -ItemType Directory -Path $stagingDir | Out-Null + +$binDir = Join-Path $stagingDir "bin" + +# 8. Publish the project to staging/bin + +Write-Log -Level "STEP" -Message "Publishing projects to bin folder..." +$publishSuccess = $true +$publishedProjects = @() + +foreach ($projPath in $csprojPaths) { + $projName = [System.IO.Path]::GetFileNameWithoutExtension($projPath) + $projBinDir = Join-Path $binDir $projName + + dotnet publish $projPath -c Release -o $projBinDir + if ($LASTEXITCODE -ne 0) { + Write-Error "dotnet publish failed for $projName." + $publishSuccess = $false + } + else { + $exeBaseName = Resolve-ProjectExeName -projPath $projPath + $publishedProjects += [PSCustomObject]@{ + ProjPath = $projPath + ProjName = $projName + BinDir = $projBinDir + ExeBaseName = $exeBaseName + } + + Write-Log -Level "OK" -Message " Published $projName successfully to: $projBinDir" + } +} + +if (-not $publishSuccess) { + exit 1 +} + + +# 12. Prepare release directory +if (!(Test-Path $releaseDir)) { + New-Item -ItemType Directory -Path $releaseDir | Out-Null +} + + +# 13. Create zip file +$zipName = $zipNamePattern +$zipName = $zipName -replace '\{version\}', $version +$zipPath = Join-Path $releaseDir $zipName + +if (Test-Path $zipPath) { + Remove-Item $zipPath -Force +} + +Write-Log -Level "STEP" -Message "Creating archive $zipName..." +Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath -Force + +if (-not (Test-Path $zipPath)) { + Write-Error "Failed to create archive $zipPath" + exit 1 +} + +Write-Log -Level "OK" -Message " Archive created: $zipPath" + +# 14. Pack NuGet package and resolve produced .nupkg file +$packageProjectPath = $csprojPaths[0] +Write-Log -Level "STEP" -Message "Packing NuGet package..." +dotnet pack $packageProjectPath -c Release -o $releaseDir --nologo +if ($LASTEXITCODE -ne 0) { + Write-Error "dotnet pack failed for $packageProjectPath." + exit 1 +} + +$packageFile = Get-ChildItem -Path $releaseDir -Filter "*.nupkg" | + Where-Object { + $_.Name -like "*$version*.nupkg" -and + $_.Name -notlike "*.symbols.nupkg" -and + $_.Name -notlike "*.snupkg" + } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + +if (-not $packageFile) { + Write-Error "Could not locate generated NuGet package for version $version in: $releaseDir" + exit 1 +} + +Write-Log -Level "OK" -Message " Package ready: $($packageFile.FullName)" + +# 15. Extract release notes from CHANGELOG.md +Write-Log -Level "STEP" -Message "Extracting release notes..." +$pattern = "(?ms)^##\s+v$([regex]::Escape($version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)" +$match = [regex]::Match($changelog, $pattern) + +if (-not $match.Success) { + Write-Error "Changelog entry for version $version not found." + exit 1 +} + +$releaseNotes = $match.Value.Trim() +Write-Log -Level "OK" -Message " Release notes extracted." + +# 16. Resolve repository info for GitHub release +$repo = Resolve-GitHubRepository -RepositorySetting $githubRepositorySetting + +$releaseName = $releaseTitlePattern -replace '\{version\}', $version + +Write-Log -Level "STEP" -Message "Release Summary:" +Write-Log -Level "INFO" -Message " Repository: $repo" +Write-Log -Level "INFO" -Message " Tag: $tag" +Write-Log -Level "INFO" -Message " Title: $releaseName" + +# 17. Check if tag is pushed to remote (skip on dev branch) + +if (-not $isDevBranch) { + + Write-Log -Level "STEP" -Message "Verifying tag is pushed to remote..." + $remoteTagExists = Test-RemoteTagExists -Tag $tag -Remote "origin" + if (-not $remoteTagExists) { + Write-Log -Level "WARN" -Message " Tag $tag not found on remote. Pushing..." + Push-TagToRemote -Tag $tag -Remote "origin" + } + else { + Write-Log -Level "OK" -Message " Tag exists on remote." + } + + + + # Release to GitHub + if ($githubReleseEnabled) { + + Write-Log -Level "STEP" -Message " Release branch ($releaseBranch) - will publish to GitHub." + Assert-Command gh + + # 6. Check GitHub authentication + + Write-Log -Level "INFO" -Message "Checking GitHub authentication..." + if (-not $githubToken) { + Write-Error "GitHub token is not set. Set '$githubTokenEnvVar' and rerun." + exit 1 + } + + # gh release subcommands do not support custom auth headers. + # Scope GH_TOKEN to this block so all gh commands authenticate with the configured token. + $previousGhToken = $env:GH_TOKEN + $env:GH_TOKEN = $githubToken + + try { + $authTest = & gh api user 2>$null + + if ($LASTEXITCODE -ne 0 -or -not $authTest) { + Write-Error "GitHub CLI authentication failed. GitHub token may be invalid or missing repo scope." + exit 1 + } + Write-Log -Level "OK" -Message " GitHub CLI authenticated." + + # 18. Create or update GitHub release + Write-Log -Level "STEP" -Message "Creating GitHub release..." + + # Check if release already exists + $releaseViewArgs = @( + "release", "view", $tag, + "--repo", $repo + ) + & gh @releaseViewArgs 2>$null + + if ($LASTEXITCODE -eq 0) { + Write-Log -Level "WARN" -Message " Release $tag already exists. Deleting..." + $releaseDeleteArgs = @("release", "delete", $tag, "--repo", $repo, "--yes") + & gh @releaseDeleteArgs + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to delete existing release $tag." + exit 1 + } + } + + # Create release using the existing tag + # Write release notes to a temp file to avoid shell interpretation issues with special characters + $notesFilePath = Join-Path $releaseDir "release-notes-temp.md" + [System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false)) + + $createReleaseArgs = @( + "release", "create", $tag, $zipPath + "--repo", $repo + "--title", $releaseName + "--notes-file", $notesFilePath + ) + & gh @createReleaseArgs + + $ghExitCode = $LASTEXITCODE + + # Cleanup temp notes file + if (Test-Path $notesFilePath) { + Remove-Item $notesFilePath -Force + } + + if ($ghExitCode -ne 0) { + Write-Error "Failed to create GitHub release for tag $tag." + exit 1 + } + } + finally { + if ($null -ne $previousGhToken) { + $env:GH_TOKEN = $previousGhToken + } + else { + Remove-Item Env:GH_TOKEN -ErrorAction SilentlyContinue + } + } + + Write-Log -Level "OK" -Message " GitHub release created successfully." + } + else { + Write-Log -Level "WARN" -Message "Skipping GitHub release (disabled)." + } + + + # Release to NuGet + + if ($nugetReleseEnabled) { + Write-Log -Level "STEP" -Message "Pushing to NuGet.org..." + dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate + + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to push the package to NuGet." + exit 1 + } + + Write-Log -Level "OK" -Message " NuGet push completed." + } + else { + Write-Log -Level "WARN" -Message "Skipping NuGet publish (disabled)." + } + +} +else { + Write-Log -Level "WARN" -Message "Skipping remote tag verification and GitHub release (dev branch)." +} + +#endregion + +#region Cleanup +if (Test-Path $stagingDir) { + Remove-Item $stagingDir -Recurse -Force + Write-Log -Level "INFO" -Message " Cleaned up staging directory." +} + +if (Test-Path $testResultsDir) { + Remove-Item $testResultsDir -Recurse -Force + Write-Log -Level "INFO" -Message " Cleaned up test results directory." +} + +Get-ChildItem -Path $releaseDir -File | + Where-Object { $_.Name -like "*$version*.nupkg" -or $_.Name -like "*$version*.snupkg" } | + Remove-Item -Force -ErrorAction SilentlyContinue +#endregion + +#region Summary +Write-Log -Level "OK" -Message "==================================================" +if ($isDevBranch) { + Write-Log -Level "OK" -Message "DEV BUILD COMPLETE" +} +else { + Write-Log -Level "OK" -Message "RELEASE COMPLETE" +} +Write-Log -Level "OK" -Message "==================================================" + +if (-not $isDevBranch) { + Write-Log -Level "STEP" -Message "Release URL: https://github.com/$repo/releases/tag/$tag" +} + +Write-Log -Level "INFO" -Message "Artifacts location: $releaseDir" + +if ($isDevBranch) { + Write-Log -Level "WARN" -Message "To publish to GitHub, switch to '$releaseBranch', merge dev, tag, and run this script again:" + Write-Log -Level "WARN" -Message " git checkout $releaseBranch" + Write-Log -Level "WARN" -Message " git merge dev" + Write-Log -Level "WARN" -Message " git tag v$version" + Write-Log -Level "WARN" -Message " .\Release-NuGetPackage.ps1" +} + +#endregion + +#endregion diff --git a/utils/Release-NuGetPackage/scriptsettings.json b/utils/Release-NuGetPackage/scriptsettings.json new file mode 100644 index 0000000..cdb3111 --- /dev/null +++ b/utils/Release-NuGetPackage/scriptsettings.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Release NuGet Package Script Settings", + "description": "Configuration file for Release-NuGetPackage.ps1 script.", + + "github": { + "enabled": true, + "githubToken": "GITHUB_MAKS_IT_COM", + "repository": "https://github.com/MAKS-IT-COM/maksit-results" + }, + + "nuget": { + "enabled": true, + "nugetApiKey": "NUGET_MAKS_IT", + "source": "https://api.nuget.org/v3/index.json" + }, + + "branches": { + "release": "main", + "dev": "dev" + }, + + "paths": { + "csprojPaths": [ + "..\\..\\src\\MaksIT.Results\\MaksIT.Results.csproj" + ], + "testResultsDir": "..\\..\\testResults", + "stagingDir": "..\\..\\staging", + "releaseDir": "..\\..\\release", + "changelogPath": "..\\..\\CHANGELOG.md", + "testProject": "..\\..\\src\\MaksIT.Results.Tests" + }, + + "release": { + "zipNamePattern": "maksit.results-{version}.zip", + "releaseTitlePattern": "Release {version}" + }, + + "_comments": { + "github": { + "enabled": "Enable/disable GitHub release creation.", + "githubToken": "Environment variable name containing GitHub token used by gh CLI.", + "repository": "GitHub repository override used for releases (supports owner/repo or full GitHub URL)." + }, + "nuget": { + "enabled": "Enable/disable NuGet publish step.", + "nugetApiKey": "Environment variable name containing NuGet API key.", + "source": "NuGet feed URL passed to dotnet nuget push." + }, + "branches": { + "release": "Branch that requires tag and allows full publish flow.", + "dev": "Branch for local/dev build flow (no tag required)." + }, + "paths": { + "csprojPaths": "List of project files used for version discovery and publish output.", + "testResultsDir": "Directory where test artifacts are written.", + "stagingDir": "Temporary staging directory before archive creation.", + "releaseDir": "Output directory for release archives and artifacts.", + "changelogPath": "Path to CHANGELOG.md used for version and release notes extraction.", + "testProject": "Test project path used by TestRunner." + }, + "release": { + "zipNamePattern": "Archive name pattern. Supports {version} placeholder.", + "releaseTitlePattern": "GitHub release title pattern. Supports {version} placeholder." + } + } +} diff --git a/utils/ScriptConfig.psm1 b/utils/ScriptConfig.psm1 new file mode 100644 index 0000000..8b93dfc --- /dev/null +++ b/utils/ScriptConfig.psm1 @@ -0,0 +1,32 @@ +function Get-ScriptSettings { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + + [Parameter(Mandatory = $false)] + [string]$SettingsFileName = "scriptsettings.json" + ) + + $settingsPath = Join-Path $ScriptDir $SettingsFileName + + if (-not (Test-Path $settingsPath -PathType Leaf)) { + Write-Error "Settings file not found: $settingsPath" + exit 1 + } + + return Get-Content $settingsPath -Raw | ConvertFrom-Json +} + +function Assert-Command { + param( + [Parameter(Mandatory = $true)] + [string]$Command + ) + + if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) { + Write-Error "Required command '$Command' is missing. Aborting." + exit 1 + } +} + +Export-ModuleMember -Function Get-ScriptSettings, Assert-Command diff --git a/utils/TestRunner.psm1 b/utils/TestRunner.psm1 new file mode 100644 index 0000000..5de475a --- /dev/null +++ b/utils/TestRunner.psm1 @@ -0,0 +1,199 @@ +<# +.SYNOPSIS + PowerShell module for running tests with code coverage. + +.DESCRIPTION + Provides the Invoke-TestsWithCoverage function for running .NET tests + with Coverlet code coverage collection and parsing results. + +.NOTES + Author: MaksIT + Usage: Import-Module .\TestRunner.psm1 +#> + +function Import-LoggingModuleInternal { + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + return + } + + $modulePath = Join-Path $PSScriptRoot "Logging.psm1" + if (Test-Path $modulePath) { + Import-Module $modulePath -Force + } +} + +function Write-TestRunnerLogInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [Parameter(Mandatory = $false)] + [ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")] + [string]$Level = "INFO" + ) + + Import-LoggingModuleInternal + + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + Write-Log -Level $Level -Message $Message + return + } + + Write-Host $Message -ForegroundColor Gray +} + +function Invoke-TestsWithCoverage { + <# + .SYNOPSIS + Runs unit tests with code coverage and returns coverage metrics. + + .PARAMETER TestProjectPath + Path to the test project directory. + + .PARAMETER Silent + Suppress console output (for JSON consumption). + + .PARAMETER ResultsDirectory + Optional fixed directory where test result files are written. + + .PARAMETER KeepResults + Keep the TestResults folder after execution. + + .OUTPUTS + PSCustomObject with properties: + - Success: bool + - Error: string (if failed) + - LineRate: double + - BranchRate: double + - MethodRate: double + - TotalMethods: int + - CoveredMethods: int + - CoverageFile: string + + .EXAMPLE + $result = Invoke-TestsWithCoverage -TestProjectPath ".\Tests" + if ($result.Success) { Write-TestRunnerLogInternal -Level "INFO" -Message "Line coverage: $($result.LineRate)%" } + #> + param( + [Parameter(Mandatory = $true)] + [string]$TestProjectPath, + + [switch]$Silent, + + [string]$ResultsDirectory, + + [switch]$KeepResults + ) + + $ErrorActionPreference = "Stop" + + # Resolve path + $TestProjectDir = Resolve-Path $TestProjectPath -ErrorAction SilentlyContinue + if (-not $TestProjectDir) { + return [PSCustomObject]@{ + Success = $false + Error = "Test project not found at: $TestProjectPath" + } + } + + if ([string]::IsNullOrWhiteSpace($ResultsDirectory)) { + $ResultsDir = Join-Path $TestProjectDir "TestResults" + } + else { + $ResultsDir = [System.IO.Path]::GetFullPath($ResultsDirectory) + } + + # Clean previous results + if (Test-Path $ResultsDir) { + Remove-Item -Recurse -Force $ResultsDir + } + + if (-not $Silent) { + Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..." + Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $TestProjectDir" + } + + # Run tests with coverage collection + Push-Location $TestProjectDir + try { + $dotnetArgs = @( + "test" + "--collect:XPlat Code Coverage" + "--results-directory", $ResultsDir + "--verbosity", $(if ($Silent) { "quiet" } else { "normal" }) + ) + + if ($Silent) { + $null = & dotnet @dotnetArgs 2>&1 + } else { + & dotnet @dotnetArgs + } + + $testExitCode = $LASTEXITCODE + if ($testExitCode -ne 0) { + return [PSCustomObject]@{ + Success = $false + Error = "Tests failed with exit code $testExitCode" + } + } + } + finally { + Pop-Location + } + + # Find the coverage file + $CoverageFile = Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Select-Object -First 1 + + if (-not $CoverageFile) { + return [PSCustomObject]@{ + Success = $false + Error = "Coverage file not found" + } + } + + if (-not $Silent) { + Write-TestRunnerLogInternal -Level "OK" -Message "Coverage file found: $($CoverageFile.FullName)" + Write-TestRunnerLogInternal -Level "STEP" -Message "Parsing coverage data..." + } + + # Parse coverage data from Cobertura XML + [xml]$coverageXml = Get-Content $CoverageFile.FullName + + $lineRate = [math]::Round([double]$coverageXml.coverage.'line-rate' * 100, 1) + $branchRate = [math]::Round([double]$coverageXml.coverage.'branch-rate' * 100, 1) + + # Calculate method coverage from packages + $totalMethods = 0 + $coveredMethods = 0 + foreach ($package in $coverageXml.coverage.packages.package) { + foreach ($class in $package.classes.class) { + foreach ($method in $class.methods.method) { + $totalMethods++ + if ([double]$method.'line-rate' -gt 0) { + $coveredMethods++ + } + } + } + } + $methodRate = if ($totalMethods -gt 0) { [math]::Round(($coveredMethods / $totalMethods) * 100, 1) } else { 0 } + + # Cleanup unless KeepResults is specified + if (-not $KeepResults) { + if (Test-Path $ResultsDir) { + Remove-Item -Recurse -Force $ResultsDir + } + } + + # Return results + return [PSCustomObject]@{ + Success = $true + LineRate = $lineRate + BranchRate = $branchRate + MethodRate = $methodRate + TotalMethods = $totalMethods + CoveredMethods = $coveredMethods + CoverageFile = $CoverageFile.FullName + } +} + +Export-ModuleMember -Function Invoke-TestsWithCoverage