(chore): update to net10.0 and refresh docs/release scripts

This commit is contained in:
Maksym Sadovnychyy 2026-02-22 19:43:40 +01:00
parent 43abcba816
commit 91adc78690
30 changed files with 2663 additions and 380 deletions

53
CHANGELOG.md Normal file
View File

@ -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<T>` 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.
<!--
Template for new releases:
## v1.x.x
### Added
- New features
### Changed
- Changes in existing functionality
### Deprecated
- Soon-to-be removed features
### Removed
- Removed features
### Fixed
- Bug fixes
### Security
- Security improvements
-->

117
CONTRIBUTING.md Normal file
View File

@ -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`.

239
README.md
View File

@ -1,182 +1,106 @@
# MaksIT.Results # 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 ## Features
- **Standardized Result Handling**: Represent operation outcomes (success or failure) with appropriate HTTP status codes. - `Result` and `Result<T>` models with status code, success flag, and messages.
- **Seamless Conversion to `IActionResult`**: Convert result objects to HTTP responses (`IActionResult`) with detailed problem descriptions. - Static factory methods for common and extended HTTP status codes (1xx, 2xx, 3xx, 4xx, 5xx).
- **Flexible Result Types**: Supports both generic (`Result<T>`) and non-generic (`Result`) results for handling various scenarios. - Built-in conversion to `IActionResult` via `ToActionResult()`.
- **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.). - RFC 7807-style error payloads for failures (`application/problem+json`).
- Camel-case JSON serialization for response bodies.
## Installation ## Installation
To install `MaksIT.Results`, use the NuGet Package Manager: Package Manager:
```bash ```bash
Install-Package MaksIT.Results 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. ```bash
dotnet add package MaksIT.Results
### 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<Organization?> ReadOrganization(Guid organizationId);
Task<Result> DeleteOrganizationAsync(Guid organizationId);
// Additional method definitions...
}
public class VaultPersistanceService : IVaultPersistanceService
{
// Inject dependencies as needed
public Result<Organization?> 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<Organization?>.NotFound("Organization not found.");
}
var organization = organizationResult.Value;
var applicationDtos = new List<ApplicationDto>();
foreach (var applicationId in organization.Applications)
{
var applicationResult = _applicationDataProvider.GetById(applicationId);
if (!applicationResult.IsSuccess || applicationResult.Value == null)
{
// Transform the result from Result<Application?> to Result<Organization?>
// Ensuring the return type matches the method signature (Result<Organization?>)
return applicationResult.WithNewValue<Organization?>(_ => null);
}
var applicationDto = applicationResult.Value;
applicationDtos.Add(applicationDto);
}
// Return the final result with all applications loaded
return Result<Organization>.Ok(organization);
}
public async Task<Result> DeleteOrganizationAsync(Guid organizationId)
{
var organizationResult = await _organizationDataProvider.GetByIdAsync(organizationId);
if (!organizationResult.IsSuccess || organizationResult.Value == null)
{
// Convert Result<Organization?> 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;
}
}
``` ```
**Key Points to Note:** ## Target Framework
1. **Handling Different Result Types:** - `.NET 10` (`net10.0`)
- The `ReadOrganization` method demonstrates handling a `Result<Organization?>` and transforming other types as needed using `WithNewValue<T>`. This ensures the method always returns the correct type.
2. **Casting from `Result<T>` to `Result`:** ## Quick Start
- In `DeleteOrganizationAsync`, we cast `Result<Organization?>` to `Result` using `(Result)organizationResult`. This cast standardizes the result type, making it suitable for scenarios where only success or failure matters.
Ensure this service is registered in your dependency injection container: ### Create results
```csharp ```csharp
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<IVaultPersistanceService, VaultPersistanceService>();
// 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; using MaksIT.Results;
public class OrganizationController : ControllerBase Result ok = Result.Ok("Operation completed");
{ Result failed = Result.BadRequest("Validation failed");
private readonly IVaultPersistanceService _vaultPersistanceService;
public OrganizationController(IVaultPersistanceService vaultPersistanceService) Result<int> okWithValue = Result<int>.Ok(42, "Answer generated");
{ Result<string?> notFound = Result<string?>.NotFound(null, "Entity not found");
_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<IActionResult> DeleteOrganization(Guid organizationId)
{
var result = await _vaultPersistanceService.DeleteOrganizationAsync(organizationId);
// Convert the Result to IActionResult using ToActionResult()
return result.ToActionResult();
}
// Additional actions...
}
``` ```
### Transforming Results ### Convert between result types
You can also transform the result within the controller or service to adjust the output type as needed:
```csharp ```csharp
public IActionResult TransformResultExample() using MaksIT.Results;
{
var result = _vaultPersistanceService.ReadOrganization(Guid.NewGuid());
// Transform the result to a different type if needed Result<int> source = Result<int>.Ok(42, "Value loaded");
var transformedResult = result.WithNewValue<string>(org => (org?.Name ?? "").ToTitle());
return transformedResult.ToActionResult(); // Result<T> -> Result<U>
} Result<string?> mapped = source.ToResultOfType(v => v?.ToString());
// Result<T> -> Result
Result nonGeneric = source.ToResult();
``` ```
### Predefined Results for All Standard HTTP Status Codes ### Use in an ASP.NET Core controller
`MaksIT.Results` provides methods to easily create results for all standard HTTP status codes, simplifying the handling of responses:
```csharp ```csharp
return Result.Ok<string?>("Success").ToActionResult(); // 200 OK using MaksIT.Results;
return Result.NotFound<string?>("Resource not found").ToActionResult(); // 404 Not Found using Microsoft.AspNetCore.Mvc;
return Result.InternalServerError<string?>("An unexpected error occurred").ToActionResult(); // 500 Internal Server Error
public sealed class UsersController : ControllerBase {
[HttpGet("{id:guid}")]
public IActionResult GetUser(Guid id) {
Result<UserDto?> result = id == Guid.Empty
? Result<UserDto?>.BadRequest(null, "Invalid id")
: Result<UserDto?>.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<T>` 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<T>`, for example `Result<MyDto>.Ok(value, "message")`.
## Contributing
See `CONTRIBUTING.md`.
## Contact ## 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) - **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) - **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 ## License
This project is licensed under the MIT License. See the full license text below. See `LICENSE.md`.
---
### 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).

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 61.1%">
<title>Branch Coverage: 61.1%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="150" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="42.5" height="20" fill="#97ca00"/>
<rect width="150" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">61.1%</text>
<text x="128.75" y="14" fill="#fff">61.1%</text>
</g>
</svg>

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 20.3%">
<title>Line Coverage: 20.3%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="137" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="94.5" height="20" fill="#555"/>
<rect x="94.5" width="42.5" height="20" fill="#dfb317"/>
<rect width="137" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">20.3%</text>
<text x="115.75" y="14" fill="#fff">20.3%</text>
</g>
</svg>

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 20.7%">
<title>Method Coverage: 20.7%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="150" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="42.5" height="20" fill="#dfb317"/>
<rect width="150" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">20.7%</text>
<text x="128.75" y="14" fill="#fff">20.7%</text>
</g>
</svg>

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@ -10,17 +10,17 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"> <PackageReference Include="coverlet.collector" Version="8.0.0">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -1,7 +1,6 @@
 Microsoft Visual Studio Solution File, Format Version 12.00
Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18
# Visual Studio Version 17 VisualStudioVersion = 18.0.11222.15
VisualStudioVersion = 17.9.34902.65
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.Results", "MaksIT.Results\MaksIT.Results.csproj", "{E947F5FC-8FD9-4F1E-AA5F-29FED95B5A2D}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.Results", "MaksIT.Results\MaksIT.Results.csproj", "{E947F5FC-8FD9-4F1E-AA5F-29FED95B5A2D}"
EndProject EndProject

View File

@ -1,14 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace> <RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<!-- NuGet package metadata --> <!-- NuGet package metadata -->
<PackageId>MaksIT.Results</PackageId> <PackageId>MaksIT.Results</PackageId>
<Version>1.1.1</Version> <Version>2.0.0</Version>
<Authors>Maksym Sadovnychyy</Authors> <Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company> <Company>MAKS-IT</Company>
<Product>MaksIT.Results</Product> <Product>MaksIT.Results</Product>
@ -22,11 +22,16 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<None Include="../../README.md" Pack="true" PackagePath="" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.3.9" />
<None Include="../../LICENSE.md" Pack="true" PackagePath="" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.3.0" /> <None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Link="LICENSE.md" />
<None Include="..\..\README.md" Pack="true" PackagePath="\" Link="README.md" />
<None Include="..\..\CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
<None Include="..\..\assets\badges\**\*" Link="assets\badges\%(RecursiveDir)%(Filename)%(Extension)">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,11 +1,68 @@
namespace MaksIT.Results.Mvc; using System.Text.Json.Serialization;
namespace MaksIT.Results.Mvc;
/// <summary>
/// A machine-readable format for specifying errors in HTTP API responses based on <see href="https://tools.ietf.org/html/rfc7807"/>.
/// </summary>
public class ProblemDetails { public class ProblemDetails {
/// <summary>
/// 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".
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-5)]
[JsonPropertyName("type")]
public string? Type { get; set; } public string? Type { get; set; }
/// <summary>
/// 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).
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-4)]
[JsonPropertyName("title")]
public string? Title { get; set; } public string? Title { get; set; }
/// <summary>
/// The HTTP status code([RFC7231], Section 6) generated by the origin server for this occurrence of the problem.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-3)]
[JsonPropertyName("status")]
public int? Status { get; set; } public int? Status { get; set; }
/// <summary>
/// A human-readable explanation specific to this occurrence of the problem.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-2)]
[JsonPropertyName("detail")]
public string? Detail { get; set; } public string? Detail { get; set; }
/// <summary>
/// A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-1)]
[JsonPropertyName("instance")]
public string? Instance { get; set; } public string? Instance { get; set; }
public IDictionary<string, object?> Extensions { get; set; } = new Dictionary<string, object?>();
/// <summary>
/// Gets the <see cref="IDictionary{TKey, TValue}"/> for extension members.
/// <para>
/// 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.
/// </para>
/// </summary>
/// <remarks>
/// The round-tripping behavior for <see cref="Extensions"/> 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.
/// </remarks>
[JsonExtensionData]
public IDictionary<string, object?> Extensions { get; set; } = new Dictionary<string, object?>(StringComparer.Ordinal);
} }

View File

@ -1,10 +1,12 @@
using System.Net; using System.Net;
namespace MaksIT.Results; namespace MaksIT.Results;
public partial class Result { public partial class Result {
#region Common Client Errors
/// <summary> /// <summary>
/// Returns a result indicating that the server could not understand the request due to invalid syntax. /// Returns a result indicating that the server could not understand the request due to invalid syntax.
/// Corresponds to HTTP status code 400 Bad Request. /// 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 return new Result(false, [..messages], (HttpStatusCode)410); // 410 Gone
} }
/// <summary>
/// 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.
/// </summary>
public static Result LengthRequired(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)411); // 411 Length Required
}
/// <summary>
/// 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.
/// </summary>
public static Result PayloadTooLarge(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)413); // 413 Payload Too Large
}
/// <summary>
/// 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.
/// </summary>
public static Result UriTooLong(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)414); // 414 URI Too Long
}
/// <summary>
/// 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.
/// </summary>
public static Result UnsupportedMediaType(params string [] messages) {
return new Result(false, [..messages], HttpStatusCode.UnsupportedMediaType);
}
#endregion
#region Extended Or Less Common Client Errors
/// <summary>
/// Returns a result indicating that payment is required to access the requested resource.
/// Corresponds to HTTP status code 402 Payment Required.
/// </summary>
public static Result PaymentRequired(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)402); // 402 Payment Required
}
/// <summary>
/// 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.
/// </summary>
public static Result MethodNotAllowed(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)405); // 405 Method Not Allowed
}
/// <summary>
/// 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.
/// </summary>
public static Result NotAcceptable(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)406); // 406 Not Acceptable
}
/// <summary>
/// Returns a result indicating that authentication with a proxy is required.
/// Corresponds to HTTP status code 407 Proxy Authentication Required.
/// </summary>
public static Result ProxyAuthenticationRequired(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)407); // 407 Proxy Authentication Required
}
/// <summary>
/// Returns a result indicating that the server timed out waiting for the request.
/// Corresponds to HTTP status code 408 Request Timeout.
/// </summary>
public static Result RequestTimeout(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)408); // 408 Request Timeout
}
/// <summary>
/// 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.
/// </summary>
public static Result PreconditionFailed(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)412); // 412 Precondition Failed
}
/// <summary>
/// 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.
/// </summary>
public static Result RangeNotSatisfiable(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)416); // 416 Range Not Satisfiable
}
/// <summary>
/// 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.
/// </summary>
public static Result ExpectationFailed(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)417); // 417 Expectation Failed
}
/// <summary>
/// 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.
/// </summary>
public static Result ImATeapot(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)418); // 418 I'm a teapot
}
/// <summary>
/// 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.
/// </summary>
public static Result MisdirectedRequest(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)421); // 421 Misdirected Request
}
/// <summary>
/// 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.
/// </summary>
public static Result UnprocessableEntity(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)422); // 422 Unprocessable Entity
}
/// <summary>
/// Returns a result indicating that access to the target resource is denied because the resource is locked.
/// Corresponds to HTTP status code 423 Locked.
/// </summary>
public static Result Locked(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)423); // 423 Locked
}
/// <summary> /// <summary>
/// Returns a result indicating that the request failed because it depended on another request and that request failed. /// 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. /// 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 return new Result(false, [..messages], (HttpStatusCode)424); // 424 Failed Dependency
} }
/// <summary>
/// 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.
/// </summary>
public static Result TooEarly(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)425); // 425 Too Early
}
/// <summary>
/// Returns a result indicating that the server refuses to perform the request using the current protocol.
/// Corresponds to HTTP status code 426 Upgrade Required.
/// </summary>
public static Result UpgradeRequired(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)426); // 426 Upgrade Required
}
/// <summary> /// <summary>
/// Returns a result indicating that the server requires the request to be conditional. /// Returns a result indicating that the server requires the request to be conditional.
/// Corresponds to HTTP status code 428 Precondition Required. /// Corresponds to HTTP status code 428 Precondition Required.
@ -86,48 +236,20 @@ public partial class Result {
} }
/// <summary> /// <summary>
/// Returns a result indicating that the server cannot process the request entity because it is too large. /// Returns a result indicating that access to the requested resource is denied for legal reasons.
/// Corresponds to HTTP status code 413 Payload Too Large. /// Corresponds to HTTP status code 451 Unavailable For Legal Reasons.
/// </summary> /// </summary>
public static Result PayloadTooLarge(params string [] messages) { public static Result UnavailableForLegalReasons(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)413); // 413 Payload Too Large return new Result(false, [..messages], (HttpStatusCode)451); // 451 Unavailable For Legal Reasons
} }
/// <summary> #endregion
/// 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.
/// </summary>
public static Result UriTooLong(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)414); // 414 URI Too Long
}
/// <summary>
/// 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.
/// </summary>
public static Result UnsupportedMediaType(params string [] messages) {
return new Result(false, [..messages], HttpStatusCode.UnsupportedMediaType);
}
/// <summary>
/// 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.
/// </summary>
public static Result LengthRequired(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)411); // 411 Length Required
}
/// <summary>
/// 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.
/// </summary>
public static Result UnprocessableEntity(params string [] messages) {
return new Result(false, [..messages], (HttpStatusCode)422); // 422 Unprocessable Entity
}
} }
public partial class Result<T> : Result { public partial class Result<T> : Result {
#region Common Client Errors
/// <summary> /// <summary>
/// Returns a result indicating that the server could not understand the request due to invalid syntax. /// Returns a result indicating that the server could not understand the request due to invalid syntax.
/// Corresponds to HTTP status code 400 Bad Request. /// Corresponds to HTTP status code 400 Bad Request.
@ -176,6 +298,138 @@ public partial class Result<T> : Result {
return new Result<T>(value, false, [..messages], (HttpStatusCode)410); // 410 Gone return new Result<T>(value, false, [..messages], (HttpStatusCode)410); // 410 Gone
} }
/// <summary>
/// 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.
/// </summary>
public static Result<T> LengthRequired(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)411); // 411 Length Required
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> PayloadTooLarge(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)413); // 413 Payload Too Large
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> UriTooLong(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)414); // 414 URI Too Long
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> UnsupportedMediaType(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], HttpStatusCode.UnsupportedMediaType);
}
#endregion
#region Extended Or Less Common Client Errors
/// <summary>
/// Returns a result indicating that payment is required to access the requested resource.
/// Corresponds to HTTP status code 402 Payment Required.
/// </summary>
public static Result<T> PaymentRequired(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)402); // 402 Payment Required
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> MethodNotAllowed(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)405); // 405 Method Not Allowed
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> NotAcceptable(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)406); // 406 Not Acceptable
}
/// <summary>
/// Returns a result indicating that authentication with a proxy is required.
/// Corresponds to HTTP status code 407 Proxy Authentication Required.
/// </summary>
public static Result<T> ProxyAuthenticationRequired(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)407); // 407 Proxy Authentication Required
}
/// <summary>
/// Returns a result indicating that the server timed out waiting for the request.
/// Corresponds to HTTP status code 408 Request Timeout.
/// </summary>
public static Result<T> RequestTimeout(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)408); // 408 Request Timeout
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> PreconditionFailed(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)412); // 412 Precondition Failed
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> RangeNotSatisfiable(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)416); // 416 Range Not Satisfiable
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> ExpectationFailed(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)417); // 417 Expectation Failed
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> ImATeapot(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)418); // 418 I'm a teapot
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> MisdirectedRequest(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)421); // 421 Misdirected Request
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> UnprocessableEntity(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)422); // 422 Unprocessable Entity
}
/// <summary>
/// Returns a result indicating that access to the target resource is denied because the resource is locked.
/// Corresponds to HTTP status code 423 Locked.
/// </summary>
public static Result<T> Locked(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)423); // 423 Locked
}
/// <summary> /// <summary>
/// Returns a result indicating that the request failed because it depended on another request and that request failed. /// 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. /// Corresponds to HTTP status code 424 Failed Dependency.
@ -184,6 +438,22 @@ public partial class Result<T> : Result {
return new Result<T>(value, false, [..messages], (HttpStatusCode)424); // 424 Failed Dependency return new Result<T>(value, false, [..messages], (HttpStatusCode)424); // 424 Failed Dependency
} }
/// <summary>
/// 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.
/// </summary>
public static Result<T> TooEarly(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)425); // 425 Too Early
}
/// <summary>
/// Returns a result indicating that the server refuses to perform the request using the current protocol.
/// Corresponds to HTTP status code 426 Upgrade Required.
/// </summary>
public static Result<T> UpgradeRequired(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)426); // 426 Upgrade Required
}
/// <summary> /// <summary>
/// Returns a result indicating that the server requires the request to be conditional. /// Returns a result indicating that the server requires the request to be conditional.
/// Corresponds to HTTP status code 428 Precondition Required. /// Corresponds to HTTP status code 428 Precondition Required.
@ -209,42 +479,12 @@ public partial class Result<T> : Result {
} }
/// <summary> /// <summary>
/// Returns a result indicating that the server cannot process the request entity because it is too large. /// Returns a result indicating that access to the requested resource is denied for legal reasons.
/// Corresponds to HTTP status code 413 Payload Too Large. /// Corresponds to HTTP status code 451 Unavailable For Legal Reasons.
/// </summary> /// </summary>
public static Result<T> PayloadTooLarge(T? value, params string [] messages) { public static Result<T> UnavailableForLegalReasons(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)413); // 413 Payload Too Large return new Result<T>(value, false, [..messages], (HttpStatusCode)451); // 451 Unavailable For Legal Reasons
} }
/// <summary> #endregion
/// 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.
/// </summary>
public static Result<T> UriTooLong(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)414); // 414 URI Too Long
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> UnsupportedMediaType(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], HttpStatusCode.UnsupportedMediaType);
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> LengthRequired(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)411); // 411 Length Required
}
/// <summary>
/// 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.
/// </summary>
public static Result<T> UnprocessableEntity(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], (HttpStatusCode)422); // 422 Unprocessable Entity
}
} }

View File

@ -1,10 +1,12 @@
using System.Net; using System.Net;
namespace MaksIT.Results; namespace MaksIT.Results;
public partial class Result { public partial class Result {
#region Common Informational Responses
/// <summary> /// <summary>
/// Returns a result indicating that the initial part of a request has been received and the client should continue with the request. /// 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. /// Corresponds to HTTP status code 100 Continue.
@ -21,6 +23,10 @@ public partial class Result {
return new Result(true, [..messages], HttpStatusCode.SwitchingProtocols); return new Result(true, [..messages], HttpStatusCode.SwitchingProtocols);
} }
#endregion
#region Extended Or Less Common Informational Responses
/// <summary> /// <summary>
/// Returns a result indicating that the server has received and is processing the request, but no response is available yet. /// 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. /// Corresponds to HTTP status code 102 Processing.
@ -36,10 +42,14 @@ public partial class Result {
public static Result EarlyHints(params string [] messages) { 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 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<T> : Result { public partial class Result<T> : Result {
#region Common Informational Responses
/// <summary> /// <summary>
/// Returns a result indicating that the initial part of a request has been received and the client should continue with the request. /// 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. /// Corresponds to HTTP status code 100 Continue.
@ -56,6 +66,10 @@ public partial class Result<T> : Result {
return new Result<T>(value, true, [..messages], HttpStatusCode.SwitchingProtocols); return new Result<T>(value, true, [..messages], HttpStatusCode.SwitchingProtocols);
} }
#endregion
#region Extended Or Less Common Informational Responses
/// <summary> /// <summary>
/// Returns a result indicating that the server has received and is processing the request, but no response is available yet. /// 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. /// Corresponds to HTTP status code 102 Processing.
@ -71,5 +85,6 @@ public partial class Result<T> : Result {
public static Result<T> EarlyHints(T? value, params string [] messages) { public static Result<T> EarlyHints(T? value, params string [] messages) {
return new Result<T>(value, true, [..messages], (HttpStatusCode)103); // Early Hints is not defined in HttpStatusCode enum, 103 is the official code return new Result<T>(value, true, [..messages], (HttpStatusCode)103); // Early Hints is not defined in HttpStatusCode enum, 103 is the official code
} }
}
#endregion
}

View File

@ -1,9 +1,11 @@
using System.Net; using System.Net;
namespace MaksIT.Results; namespace MaksIT.Results;
public partial class Result { public partial class Result {
#region Common Server Errors
/// <summary> /// <summary>
/// Returns a result indicating the server encountered an unexpected condition that prevented it from fulfilling the request. /// 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. /// Corresponds to HTTP status code 500 Internal Server Error.
@ -52,6 +54,10 @@ public partial class Result {
return new Result(false, [..messages], HttpStatusCode.HttpVersionNotSupported); return new Result(false, [..messages], HttpStatusCode.HttpVersionNotSupported);
} }
#endregion
#region Extended Or Less Common Server Errors
/// <summary> /// <summary>
/// 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. /// 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. /// Corresponds to HTTP status code 506 Variant Also Negotiates.
@ -91,10 +97,14 @@ public partial class Result {
public static Result NetworkAuthenticationRequired(params string [] messages) { public static Result NetworkAuthenticationRequired(params string [] messages) {
return new Result(false, [..messages], HttpStatusCode.NetworkAuthenticationRequired); return new Result(false, [..messages], HttpStatusCode.NetworkAuthenticationRequired);
} }
#endregion
} }
public partial class Result<T> : Result { public partial class Result<T> : Result {
#region Common Server Errors
/// <summary> /// <summary>
/// Returns a result indicating the server encountered an unexpected condition that prevented it from fulfilling the request. /// 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. /// Corresponds to HTTP status code 500 Internal Server Error.
@ -143,6 +153,10 @@ public partial class Result<T> : Result {
return new Result<T>(value, false, [..messages], HttpStatusCode.HttpVersionNotSupported); return new Result<T>(value, false, [..messages], HttpStatusCode.HttpVersionNotSupported);
} }
#endregion
#region Extended Or Less Common Server Errors
/// <summary> /// <summary>
/// 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. /// 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. /// Corresponds to HTTP status code 506 Variant Also Negotiates.
@ -182,4 +196,6 @@ public partial class Result<T> : Result {
public static Result<T> NetworkAuthenticationRequired(T? value, params string [] messages) { public static Result<T> NetworkAuthenticationRequired(T? value, params string [] messages) {
return new Result<T>(value, false, [..messages], HttpStatusCode.NetworkAuthenticationRequired); return new Result<T>(value, false, [..messages], HttpStatusCode.NetworkAuthenticationRequired);
} }
#endregion
} }

View File

@ -1,10 +1,12 @@
using System.Net; using System.Net;
namespace MaksIT.Results; namespace MaksIT.Results;
public partial class Result { public partial class Result {
#region Common Success Responses
/// <summary> /// <summary>
/// Returns a result indicating the request was successful and the server returned the requested data. /// Returns a result indicating the request was successful and the server returned the requested data.
/// Corresponds to HTTP status code 200 OK. /// Corresponds to HTTP status code 200 OK.
@ -61,6 +63,10 @@ public partial class Result {
return new Result(true, [..messages], HttpStatusCode.PartialContent); return new Result(true, [..messages], HttpStatusCode.PartialContent);
} }
#endregion
#region Extended Or Less Common Success Responses
/// <summary> /// <summary>
/// Returns a result indicating the request was successful and the response contains multiple status codes, typically used for WebDAV. /// 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. /// Corresponds to HTTP status code 207 Multi-Status.
@ -84,9 +90,13 @@ public partial class Result {
public static Result IMUsed(params string[] messages) { public static Result IMUsed(params string[] messages) {
return new Result(true, [..messages], (HttpStatusCode)226); // 226 is the official status code for IM Used return new Result(true, [..messages], (HttpStatusCode)226); // 226 is the official status code for IM Used
} }
#endregion
} }
public partial class Result<T> : Result { public partial class Result<T> : Result {
#region Common Success Responses
/// <summary> /// <summary>
/// Returns a result indicating the request was successful and the server returned the requested data. /// Returns a result indicating the request was successful and the server returned the requested data.
/// Corresponds to HTTP status code 200 OK. /// Corresponds to HTTP status code 200 OK.
@ -143,6 +153,10 @@ public partial class Result<T> : Result {
return new Result<T>(value, true, [..messages], HttpStatusCode.PartialContent); return new Result<T>(value, true, [..messages], HttpStatusCode.PartialContent);
} }
#endregion
#region Extended Or Less Common Success Responses
/// <summary> /// <summary>
/// Returns a result indicating the request was successful and the response contains multiple status codes, typically used for WebDAV. /// 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. /// Corresponds to HTTP status code 207 Multi-Status.
@ -166,4 +180,6 @@ public partial class Result<T> : Result {
public static Result<T> IMUsed(T? value, params string[] messages) { public static Result<T> IMUsed(T? value, params string[] messages) {
return new Result<T>(value, true, [..messages], (HttpStatusCode)226); // 226 is the official status code for IM Used return new Result<T>(value, true, [..messages], (HttpStatusCode)226); // 226 is the official status code for IM Used
} }
#endregion
} }

View File

@ -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"

View File

@ -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
}

View File

@ -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

View File

@ -0,0 +1,3 @@
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
pause

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -0,0 +1,3 @@
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1"
pause

View File

@ -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 @"
<svg xmlns="http://www.w3.org/2000/svg" width="$totalWidth" height="20" role="img" aria-label="$label`: $value">
<title>$label`: $value</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="$totalWidth" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="$labelWidth" height="20" fill="#555"/>
<rect x="$labelWidth" width="$valueWidth" height="20" fill="$hexColor"/>
<rect width="$totalWidth" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="$labelX" y="15" fill="#010101" fill-opacity=".3">$label</text>
<text x="$labelX" y="14" fill="#fff">$label</text>
<text aria-hidden="true" x="$valueX" y="15" fill="#010101" fill-opacity=".3">$value</text>
<text x="$valueX" y="14" fill="#fff">$value</text>
</g>
</svg>
"@
}
#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

View File

@ -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."
}
}

265
utils/GitTools.psm1 Normal file
View File

@ -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

67
utils/Logging.psm1 Normal file
View File

@ -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

View File

@ -0,0 +1,3 @@
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-NuGetPackage.ps1"
pause

View File

@ -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/(?<owner>[^/]+)/(?<repo>[^/]+?)(?:\.git)?/?$') {
return "$($Matches['owner'])/$($Matches['repo'])"
}
if ($value -match '^(?<owner>[^/]+)/(?<repo>[^/]+)$') {
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 "[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.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

View File

@ -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."
}
}
}

32
utils/ScriptConfig.psm1 Normal file
View File

@ -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

199
utils/TestRunner.psm1 Normal file
View File

@ -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