(chore): update to net10.0 and refresh docs/release scripts
This commit is contained in:
parent
43abcba816
commit
91adc78690
53
CHANGELOG.md
Normal file
53
CHANGELOG.md
Normal 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
117
CONTRIBUTING.md
Normal 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`.
|
||||
241
README.md
241
README.md
@ -1,182 +1,106 @@
|
||||
# MaksIT.Results
|
||||
|
||||
`MaksIT.Results` is a powerful library designed to streamline the creation and management of result objects in your ASP.NET Core applications. It provides a standardized way to handle method results and easily convert them to `IActionResult` for HTTP responses, ensuring consistent and clear API responses.
|
||||
  
|
||||
|
||||
`MaksIT.Results` is a .NET library for modeling operation outcomes as HTTP-aware result objects and converting them to `IActionResult` in ASP.NET Core.
|
||||
|
||||
## Features
|
||||
|
||||
- **Standardized Result Handling**: Represent operation outcomes (success or failure) with appropriate HTTP status codes.
|
||||
- **Seamless Conversion to `IActionResult`**: Convert result objects to HTTP responses (`IActionResult`) with detailed problem descriptions.
|
||||
- **Flexible Result Types**: Supports both generic (`Result<T>`) and non-generic (`Result`) results for handling various scenarios.
|
||||
- **Predefined Results for All Standard HTTP Status Codes**: Includes predefined static methods to create results for all standard HTTP status codes (e.g., 200 OK, 404 Not Found, 500 Internal Server Error, etc.).
|
||||
- `Result` and `Result<T>` models with status code, success flag, and messages.
|
||||
- Static factory methods for common and extended HTTP status codes (1xx, 2xx, 3xx, 4xx, 5xx).
|
||||
- Built-in conversion to `IActionResult` via `ToActionResult()`.
|
||||
- RFC 7807-style error payloads for failures (`application/problem+json`).
|
||||
- Camel-case JSON serialization for response bodies.
|
||||
|
||||
## Installation
|
||||
|
||||
To install `MaksIT.Results`, use the NuGet Package Manager:
|
||||
Package Manager:
|
||||
|
||||
```bash
|
||||
Install-Package MaksIT.Results
|
||||
```
|
||||
|
||||
## Usage example
|
||||
`dotnet` CLI:
|
||||
|
||||
Below is an example demonstrating how to use `MaksIT.Results` in a typical ASP.NET Core application where a controller interacts with a service.
|
||||
|
||||
### Step 1: Define and Register the Service
|
||||
|
||||
Define a service that uses `MaksIT.Results` to return operation results, handling different result types with proper casting and conversion.
|
||||
|
||||
```csharp
|
||||
public interface IVaultPersistanceService
|
||||
{
|
||||
Result<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;
|
||||
}
|
||||
}
|
||||
```bash
|
||||
dotnet add package MaksIT.Results
|
||||
```
|
||||
|
||||
**Key Points to Note:**
|
||||
## Target Framework
|
||||
|
||||
1. **Handling Different Result Types:**
|
||||
- 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`:**
|
||||
- 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.
|
||||
- `.NET 10` (`net10.0`)
|
||||
|
||||
Ensure this service is registered in your dependency injection container:
|
||||
## Quick Start
|
||||
|
||||
### Create results
|
||||
|
||||
```csharp
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<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;
|
||||
|
||||
public class OrganizationController : ControllerBase
|
||||
{
|
||||
private readonly IVaultPersistanceService _vaultPersistanceService;
|
||||
Result ok = Result.Ok("Operation completed");
|
||||
Result failed = Result.BadRequest("Validation failed");
|
||||
|
||||
public OrganizationController(IVaultPersistanceService vaultPersistanceService)
|
||||
{
|
||||
_vaultPersistanceService = vaultPersistanceService;
|
||||
}
|
||||
|
||||
[HttpGet("{organizationId}")]
|
||||
public IActionResult GetOrganization(Guid organizationId)
|
||||
{
|
||||
var result = _vaultPersistanceService.ReadOrganization(organizationId);
|
||||
|
||||
// Convert the Result to IActionResult using ToActionResult()
|
||||
return result.ToActionResult();
|
||||
}
|
||||
|
||||
[HttpDelete("{organizationId}")]
|
||||
public async Task<IActionResult> DeleteOrganization(Guid organizationId)
|
||||
{
|
||||
var result = await _vaultPersistanceService.DeleteOrganizationAsync(organizationId);
|
||||
|
||||
// Convert the Result to IActionResult using ToActionResult()
|
||||
return result.ToActionResult();
|
||||
}
|
||||
|
||||
// Additional actions...
|
||||
}
|
||||
Result<int> okWithValue = Result<int>.Ok(42, "Answer generated");
|
||||
Result<string?> notFound = Result<string?>.NotFound(null, "Entity not found");
|
||||
```
|
||||
|
||||
### Transforming Results
|
||||
|
||||
You can also transform the result within the controller or service to adjust the output type as needed:
|
||||
### Convert between result types
|
||||
|
||||
```csharp
|
||||
public IActionResult TransformResultExample()
|
||||
{
|
||||
var result = _vaultPersistanceService.ReadOrganization(Guid.NewGuid());
|
||||
using MaksIT.Results;
|
||||
|
||||
// Transform the result to a different type if needed
|
||||
var transformedResult = result.WithNewValue<string>(org => (org?.Name ?? "").ToTitle());
|
||||
Result<int> source = Result<int>.Ok(42, "Value loaded");
|
||||
|
||||
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
|
||||
|
||||
`MaksIT.Results` provides methods to easily create results for all standard HTTP status codes, simplifying the handling of responses:
|
||||
### Use in an ASP.NET Core controller
|
||||
|
||||
```csharp
|
||||
return Result.Ok<string?>("Success").ToActionResult(); // 200 OK
|
||||
return Result.NotFound<string?>("Resource not found").ToActionResult(); // 404 Not Found
|
||||
return Result.InternalServerError<string?>("An unexpected error occurred").ToActionResult(); // 500 Internal Server Error
|
||||
using MaksIT.Results;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
public sealed class UsersController : ControllerBase {
|
||||
[HttpGet("{id:guid}")]
|
||||
public IActionResult GetUser(Guid id) {
|
||||
Result<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
|
||||
|
||||
@ -184,39 +108,6 @@ If you have any questions or need further assistance, feel free to reach out:
|
||||
|
||||
- **Email**: [maksym.sadovnychyy@gmail.com](mailto:maksym.sadovnychyy@gmail.com)
|
||||
- **Reddit**: [MaksIT.Results: Streamline Your ASP.NET Core API Response Handling](https://www.reddit.com/r/MaksIT/comments/1f89ifn/maksitresults_streamline_your_aspnet_core_api/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button)
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the full license text below.
|
||||
|
||||
---
|
||||
|
||||
### MIT License
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Maksym Sadovnychyy (MAKS-IT)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## Contact
|
||||
|
||||
For any questions or inquiries, please reach out via GitHub or [email](mailto:maksym.sadovnychyy@gmail.com).
|
||||
See `LICENSE.md`.
|
||||
21
assets/badges/coverage-branches.svg
Normal file
21
assets/badges/coverage-branches.svg
Normal 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>
|
||||
21
assets/badges/coverage-lines.svg
Normal file
21
assets/badges/coverage-lines.svg
Normal 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>
|
||||
21
assets/badges/coverage-methods.svg
Normal file
21
assets/badges/coverage-methods.svg
Normal 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>
|
||||
@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@ -10,17 +10,17 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PackageReference Include="coverlet.collector" Version="8.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.9" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.9.34902.65
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 18
|
||||
VisualStudioVersion = 18.0.11222.15
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.Results", "MaksIT.Results\MaksIT.Results.csproj", "{E947F5FC-8FD9-4F1E-AA5F-29FED95B5A2D}"
|
||||
EndProject
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PackageId>MaksIT.Results</PackageId>
|
||||
<Version>1.1.1</Version>
|
||||
<Version>2.0.0</Version>
|
||||
<Authors>Maksym Sadovnychyy</Authors>
|
||||
<Company>MAKS-IT</Company>
|
||||
<Product>MaksIT.Results</Product>
|
||||
@ -22,11 +22,16 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="../../README.md" Pack="true" PackagePath="" />
|
||||
<None Include="../../LICENSE.md" Pack="true" PackagePath="" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.3.9" />
|
||||
</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>
|
||||
</Project>
|
||||
|
||||
@ -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 {
|
||||
/// <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; }
|
||||
|
||||
/// <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; }
|
||||
|
||||
/// <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; }
|
||||
|
||||
/// <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; }
|
||||
|
||||
/// <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 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);
|
||||
}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
using System.Net;
|
||||
using System.Net;
|
||||
|
||||
|
||||
namespace MaksIT.Results;
|
||||
|
||||
public partial class Result {
|
||||
|
||||
#region Common Client Errors
|
||||
|
||||
/// <summary>
|
||||
/// Returns a result indicating that the server could not understand the request due to invalid syntax.
|
||||
/// Corresponds to HTTP status code 400 Bad Request.
|
||||
@ -53,6 +55,138 @@ public partial class Result {
|
||||
return new Result(false, [..messages], (HttpStatusCode)410); // 410 Gone
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Returns a result indicating that the request failed because it depended on another request and that request failed.
|
||||
/// Corresponds to HTTP status code 424 Failed Dependency.
|
||||
@ -61,6 +195,22 @@ public partial class Result {
|
||||
return new Result(false, [..messages], (HttpStatusCode)424); // 424 Failed Dependency
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Returns a result indicating that the server requires the request to be conditional.
|
||||
/// Corresponds to HTTP status code 428 Precondition Required.
|
||||
@ -86,48 +236,20 @@ public partial class Result {
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Returns a result indicating that access to the requested resource is denied for legal reasons.
|
||||
/// Corresponds to HTTP status code 451 Unavailable For Legal Reasons.
|
||||
/// </summary>
|
||||
public static Result PayloadTooLarge(params string [] messages) {
|
||||
return new Result(false, [..messages], (HttpStatusCode)413); // 413 Payload Too Large
|
||||
public static Result UnavailableForLegalReasons(params string [] messages) {
|
||||
return new Result(false, [..messages], (HttpStatusCode)451); // 451 Unavailable For Legal Reasons
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
/// <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
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
public partial class Result<T> : Result {
|
||||
|
||||
#region Common Client Errors
|
||||
|
||||
/// <summary>
|
||||
/// Returns a result indicating that the server could not understand the request due to invalid syntax.
|
||||
/// Corresponds to HTTP status code 400 Bad Request.
|
||||
@ -176,6 +298,138 @@ public partial class Result<T> : Result {
|
||||
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>
|
||||
/// Returns a result indicating that the request failed because it depended on another request and that request failed.
|
||||
/// Corresponds to HTTP status code 424 Failed Dependency.
|
||||
@ -184,6 +438,22 @@ public partial class Result<T> : Result {
|
||||
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>
|
||||
/// Returns a result indicating that the server requires the request to be conditional.
|
||||
/// Corresponds to HTTP status code 428 Precondition Required.
|
||||
@ -209,42 +479,12 @@ public partial class Result<T> : Result {
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Returns a result indicating that access to the requested resource is denied for legal reasons.
|
||||
/// Corresponds to HTTP status code 451 Unavailable For Legal Reasons.
|
||||
/// </summary>
|
||||
public static Result<T> PayloadTooLarge(T? value, params string [] messages) {
|
||||
return new Result<T>(value, false, [..messages], (HttpStatusCode)413); // 413 Payload Too Large
|
||||
public static Result<T> UnavailableForLegalReasons(T? value, params string [] messages) {
|
||||
return new Result<T>(value, false, [..messages], (HttpStatusCode)451); // 451 Unavailable For Legal Reasons
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
/// <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
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
using System.Net;
|
||||
using System.Net;
|
||||
|
||||
|
||||
namespace MaksIT.Results;
|
||||
|
||||
public partial class Result {
|
||||
|
||||
#region Common Informational Responses
|
||||
|
||||
/// <summary>
|
||||
/// Returns a result indicating that the initial part of a request has been received and the client should continue with the request.
|
||||
/// Corresponds to HTTP status code 100 Continue.
|
||||
@ -21,6 +23,10 @@ public partial class Result {
|
||||
return new Result(true, [..messages], HttpStatusCode.SwitchingProtocols);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extended Or Less Common Informational Responses
|
||||
|
||||
/// <summary>
|
||||
/// Returns a result indicating that the server has received and is processing the request, but no response is available yet.
|
||||
/// Corresponds to HTTP status code 102 Processing.
|
||||
@ -36,10 +42,14 @@ public partial class Result {
|
||||
public static Result EarlyHints(params string [] messages) {
|
||||
return new Result(true, [..messages], (HttpStatusCode)103); // Early Hints is not defined in HttpStatusCode enum, 103 is the official code
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public partial class Result<T> : Result {
|
||||
|
||||
#region Common Informational Responses
|
||||
|
||||
/// <summary>
|
||||
/// Returns a result indicating that the initial part of a request has been received and the client should continue with the request.
|
||||
/// Corresponds to HTTP status code 100 Continue.
|
||||
@ -56,6 +66,10 @@ public partial class Result<T> : Result {
|
||||
return new Result<T>(value, true, [..messages], HttpStatusCode.SwitchingProtocols);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extended Or Less Common Informational Responses
|
||||
|
||||
/// <summary>
|
||||
/// Returns a result indicating that the server has received and is processing the request, but no response is available yet.
|
||||
/// Corresponds to HTTP status code 102 Processing.
|
||||
@ -71,5 +85,6 @@ public partial class Result<T> : Result {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
using System.Net;
|
||||
using System.Net;
|
||||
|
||||
namespace MaksIT.Results;
|
||||
|
||||
public partial class Result {
|
||||
|
||||
#region Common Server Errors
|
||||
|
||||
/// <summary>
|
||||
/// Returns a result indicating the server encountered an unexpected condition that prevented it from fulfilling the request.
|
||||
/// Corresponds to HTTP status code 500 Internal Server Error.
|
||||
@ -52,6 +54,10 @@ public partial class Result {
|
||||
return new Result(false, [..messages], HttpStatusCode.HttpVersionNotSupported);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extended Or Less Common Server Errors
|
||||
|
||||
/// <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.
|
||||
/// Corresponds to HTTP status code 506 Variant Also Negotiates.
|
||||
@ -91,10 +97,14 @@ public partial class Result {
|
||||
public static Result NetworkAuthenticationRequired(params string [] messages) {
|
||||
return new Result(false, [..messages], HttpStatusCode.NetworkAuthenticationRequired);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public partial class Result<T> : Result {
|
||||
|
||||
#region Common Server Errors
|
||||
|
||||
/// <summary>
|
||||
/// Returns a result indicating the server encountered an unexpected condition that prevented it from fulfilling the request.
|
||||
/// Corresponds to HTTP status code 500 Internal Server Error.
|
||||
@ -143,6 +153,10 @@ public partial class Result<T> : Result {
|
||||
return new Result<T>(value, false, [..messages], HttpStatusCode.HttpVersionNotSupported);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extended Or Less Common Server Errors
|
||||
|
||||
/// <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.
|
||||
/// 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) {
|
||||
return new Result<T>(value, false, [..messages], HttpStatusCode.NetworkAuthenticationRequired);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
using System.Net;
|
||||
using System.Net;
|
||||
|
||||
|
||||
namespace MaksIT.Results;
|
||||
|
||||
public partial class Result {
|
||||
|
||||
#region Common Success Responses
|
||||
|
||||
/// <summary>
|
||||
/// Returns a result indicating the request was successful and the server returned the requested data.
|
||||
/// Corresponds to HTTP status code 200 OK.
|
||||
@ -61,6 +63,10 @@ public partial class Result {
|
||||
return new Result(true, [..messages], HttpStatusCode.PartialContent);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extended Or Less Common Success Responses
|
||||
|
||||
/// <summary>
|
||||
/// Returns a result indicating the request was successful and the response contains multiple status codes, typically used for WebDAV.
|
||||
/// Corresponds to HTTP status code 207 Multi-Status.
|
||||
@ -84,9 +90,13 @@ public partial class Result {
|
||||
public static Result IMUsed(params string[] messages) {
|
||||
return new Result(true, [..messages], (HttpStatusCode)226); // 226 is the official status code for IM Used
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
public partial class Result<T> : Result {
|
||||
|
||||
#region Common Success Responses
|
||||
|
||||
/// <summary>
|
||||
/// Returns a result indicating the request was successful and the server returned the requested data.
|
||||
/// Corresponds to HTTP status code 200 OK.
|
||||
@ -143,6 +153,10 @@ public partial class Result<T> : Result {
|
||||
return new Result<T>(value, true, [..messages], HttpStatusCode.PartialContent);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extended Or Less Common Success Responses
|
||||
|
||||
/// <summary>
|
||||
/// Returns a result indicating the request was successful and the response contains multiple status codes, typically used for WebDAV.
|
||||
/// Corresponds to HTTP status code 207 Multi-Status.
|
||||
@ -166,4 +180,6 @@ public partial class Result<T> : Result {
|
||||
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
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -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"
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
|
||||
pause
|
||||
220
utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
Normal file
220
utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
Normal 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
|
||||
18
utils/Force-AmendTaggedCommit/scriptsettings.json
Normal file
18
utils/Force-AmendTaggedCommit/scriptsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1"
|
||||
pause
|
||||
232
utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1
Normal file
232
utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1
Normal 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
|
||||
44
utils/Generate-CoverageBadges/scriptsettings.json
Normal file
44
utils/Generate-CoverageBadges/scriptsettings.json
Normal 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
265
utils/GitTools.psm1
Normal 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
67
utils/Logging.psm1
Normal 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
|
||||
3
utils/Release-NuGetPackage/Release-NuGetPackage.bat
Normal file
3
utils/Release-NuGetPackage/Release-NuGetPackage.bat
Normal file
@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Release-NuGetPackage.ps1"
|
||||
pause
|
||||
770
utils/Release-NuGetPackage/Release-NuGetPackage.ps1
Normal file
770
utils/Release-NuGetPackage/Release-NuGetPackage.ps1
Normal 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
|
||||
67
utils/Release-NuGetPackage/scriptsettings.json
Normal file
67
utils/Release-NuGetPackage/scriptsettings.json
Normal 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
32
utils/ScriptConfig.psm1
Normal 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
199
utils/TestRunner.psm1
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user