Compare commits

...

4 Commits

Author SHA1 Message Date
b271b32bf3 Merge pull request 'feature/PipelineAgent' (#90) from feature/PipelineAgent into master
Reviewed-on: #90
2026-01-25 10:53:35 +00:00
a553770fc3 * Added branch name instead of default to GetFileContent method
All checks were successful
ci/woodpecker/push/push_to_repo Pipeline was successful
ci/woodpecker/pr/merge_to_master Pipeline was successful
2026-01-25 11:50:19 +01:00
87636453eb * Added missing file
All checks were successful
ci/woodpecker/push/push_to_repo Pipeline was successful
2026-01-25 11:30:56 +01:00
8eeb6389df * Created DevelopmentAgent to generate automaticaly UnitTests for projects
All checks were successful
ci/woodpecker/push/push_to_repo Pipeline was successful
2026-01-25 11:27:49 +01:00
11 changed files with 351 additions and 5 deletions

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>DeploymentAgent</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-preview.2.25163.2" />
<PackageReference Include="Services" Version="2.0.0-alpha.0.149" />
</ItemGroup>
<ItemGroup>
<None Update="Prompts\TestProjectGenerator.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,36 @@
using DeploymentAgent.Shell;
using DeploymentAgent.TestsGenerator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Services.Gitea;
using Services.OpenAI;
using Services.Vault;
IHost builder = Host.CreateDefaultBuilder(args)
.ConfigureServices((_, services) =>
{
var vaultUrl = Environment.GetEnvironmentVariable("VAULT_URL") ?? "http://vault:8200";
var vaultToken = Environment.GetEnvironmentVariable("VAULT_TOKEN") ?? "dev-only-token";
var vaultService = new VaultService(vaultUrl, vaultToken);
services.AddSingleton<IVaultService>(vaultService);
services.AddScoped<IGiteaService, GiteaService>();
services.AddScoped<IOpenAiService, OpenAiService>();
services.AddScoped<IShellRunner, ShellRunner>();
services.AddScoped<ITestsGeneratorAgent, TestsGeneratorAgent>();
}).Build();
if (args.Length == 0)
{
Console.WriteLine("Usage: DeploymentAgent <command> [options]");
Console.WriteLine("Commands: tests-generator");
return 1;
}
return args[0] switch
{
"tests-generator" => await builder.Services.GetRequiredService<ITestsGeneratorAgent>()
.GenerateTestsAsync(args.ElementAtOrDefault(1) ?? string.Empty),
_ => 1
};

View File

@@ -0,0 +1,147 @@
Jesteś C# Developerem specjalizującym się w tworzeniu projektów testowych dla aplikacji .NET.
Wejście:
Dostajesz pojedynczy obiekt JSON w formacie:
{
"projectName": string, // ścieżka katalogu projektu, dla którego mają zostać wygenerowane testy (np. "BroseCumulativeReport" lub "src/BroseCumulativeReport")
"solutionPath": string, // pełna ścieżka do pliku .sln, do którego ma zostać dodany projekt testowy (np. "/Users/user/RiderProjects/FA/FA.sln")
"files": [
{
"path": string, // ścieżka pliku C# względem katalogu solution (np. "BroseCumulativeReport/Services/FooService.cs")
"content": string // pełna zawartość pliku C#
}
]
}
Twoje zadanie:
1. Przeanalizuj wszystkie pliki z pola "files":
- wykryj publiczne klasy, metody i właściwości,
- wygeneruj testy jednostkowe pokrywające każdą publiczną metodę i właściwość,
- uwzględnij różne scenariusze, w tym przypadki brzegowe i obsługę wyjątków,
- użyj xUnit jako frameworka testowego,
- tam, gdzie klasy mają zależności (np. przez konstruktor lub wstrzykiwanie interfejsów), sugeruj ich mockowanie za pomocą Moq (np. new Mock<IMyService>()).
2. Na podstawie wartości "projectName":
- przyjmij, że "projectName" wskazuje katalog projektu głównego względem katalogu solution (np. "BroseCumulativeReport" albo "src/BroseCumulativeReport"),
- pobierz ostatni segment tej ścieżki jako nazwę projektu głównego, np.:
- projectName: "BroseCumulativeReport" → nazwa projektu głównego: "BroseCumulativeReport",
- projectName: "src/BroseCumulativeReport" → nazwa projektu głównego: "BroseCumulativeReport",
- nazwij projekt testowy "{NazwaProjektuGłównego}.Tests", np. "BroseCumulativeReport.Tests".
3. Przygotuj plik projektu testowego "{TestProjectName}.csproj" o zawartości:
- <Project Sdk="Microsoft.NET.Sdk">
- <PropertyGroup> z:
- <TargetFramework> dopasowany do projektu głównego; jeśli nie znasz dokładnej wartości, użyj "net8.0",
- <IsPackable>false</IsPackable>.
- <ItemGroup> z referencjami do paczek:
- Microsoft.NET.Test.Sdk
- xunit
- xunit.runner.visualstudio
- coverlet.collector
- Moq
- <ItemGroup> z:
- <ProjectReference Include="ŚCIEŻKA_DO_PROJEKTU_GŁÓWNEGO.csproj" />
WAŻNE (ProjectReference i ścieżki):
- Przyjmij standardowy układ katalogów:
- katalog z solutionPath (ROOT), np. "/Users/user/RiderProjects/FA",
- w nim katalog projektu głównego, np. "BroseCumulativeReport" (ostatni segment projectName),
- w nim plik .csproj projektu głównego: "{NazwaProjektuGłównego}.csproj" (np. "BroseCumulativeReport/BroseCumulativeReport.csproj"),
- projekt testowy w katalogu "{TestProjectName}" obok projektu głównego: "{TestProjectName}" pod ROOT (np. "BroseCumulativeReport.Tests").
- Katalog projektu testowego będzie więc:
- ROOT/{TestProjectName}, np. "/Users/user/RiderProjects/FA/BroseCumulativeReport.Tests".
- Ścieżka w `<ProjectReference Include="...">` musi być RELATYWNA względem katalogu projektu testowego (katalogu, w którym leży plik .csproj testów), czyli:
- z "BroseCumulativeReport.Tests" przechodzisz poziom wyżej do ROOT, a następnie do katalogu projektu głównego.
- poprawny wzór:
"../{NazwaProjektuGłównego}/{NazwaProjektuGłównego}.csproj"
- PRZYKŁAD:
- solutionPath: "/Users/piotrkus/RiderProjects/FA/FA.sln"
- projectName: "BroseCumulativeReport"
- nazwa projektu głównego: "BroseCumulativeReport"
- katalog projektu testowego: "/Users/piotrkus/RiderProjects/FA/BroseCumulativeReport.Tests"
- katalog projektu głównego: "/Users/piotrkus/RiderProjects/FA/BroseCumulativeReport"
- wtedy w pliku BroseCumulativeReport.Tests.csproj wstaw:
<ProjectReference Include="../BroseCumulativeReport/BroseCumulativeReport.csproj" />
- Wygeneruj `<ProjectReference Include="...">` jako LITERALNY tekst w XML, bez używania zmiennych powłoki bash (nie używaj $MAIN_PROJECT_REL_PATH). Wstaw pełną ścieżkę względną względem katalogu projektu testowego.
4. Zaprojektuj strukturę katalogów testowych:
- katalog główny projektu testowego: "{TestProjectName}" (np. "BroseCumulativeReport.Tests"),
- wewnątrz niego ewentualne podkatalogi odzwierciedlające strukturę namespace/folderów projektu głównego (np. "Model", "Configuration"),
- dla każdej publicznej klasy z projektu głównego wygeneruj odpowiadający plik testowy:
- np. BroseCumulativeReport.App → AppTests.cs,
- BroseCumulativeReport.ExcelGenerator → ExcelGeneratorTests.cs,
- BroseCumulativeReport.Model.MainRow → Model/MainRowTests.cs, itd.
- w każdej klasie testowej:
- wygeneruj metody testowe dla wszystkich publicznych metod i właściwości,
- używaj nazewnictwa w stylu: MethodName_ShouldDoSomething_WhenCondition,
- korzystaj z [Fact] dla prostych przypadków oraz [Theory] z [InlineData(...)] tam, gdzie można sensownie zaproponować różne dane wejściowe,
- stosuj strukturę Arrange / Act / Assert z TODO-komentarzami tam, gdzie potrzebne są decyzje domenowe,
- dla zależności korzystaj z Moq (np. var dependency = new Mock<IMyService>(); i dependency.Object wstrzykiwane do konstruktora).
5. Na podstawie powyższych informacji wygeneruj skrypt powłoki /bin/sh, który:
- jest samowystarczalny: NIE czyta JSON-a ani żadnych danych wejściowych,
- nie przyjmuje żadnych argumentów,
- zakłada, że:
- plik solution znajduje się dokładnie pod ścieżką podaną w "solutionPath" w wejściowym JSON-ie,
- projekt testowy ma być utworzony w tym samym katalogu co plik solution (czyli obok pliku .sln, nie w katalogu projektu głównego).
Skrypt powinien wykonywać następujące kroki:
1) Ustawić zmienną SOLUTION_PATH na wartość z pola "solutionPath" (osadzoną na stałe w skrypcie, np. SOLUTION_PATH="/Users/piotrkus/RiderProjects/FA/FA.sln").
2) Wyznaczyć SOLUTION_DIR jako katalog nadrzędny pliku solution:
SOLUTION_DIR=$(dirname "$SOLUTION_PATH")
3) Utworzyć katalog projektu testowego:
TEST_PROJECT_NAME="{TestProjectName}" (np. "BroseCumulativeReport.Tests")
TEST_PROJECT_DIR="$SOLUTION_DIR/$TEST_PROJECT_NAME"
oraz podkatalogi, które zaprojektowałeś (np. "$TEST_PROJECT_DIR/Model", "$TEST_PROJECT_DIR/Configuration").
4) W katalogu TEST_PROJECT_DIR zapisać plik "{TestProjectName}.csproj" z wygenerowaną zawartością, używając heredoca.
- W pliku .csproj wstaw literalną ścieżkę ProjectReference zgodnie z regułą:
"../{NazwaProjektuGłównego}/{NazwaProjektuGłównego}.csproj"
- Możesz użyć heredoca z 'EOF', ponieważ nie potrzebujesz interpolacji zmiennych bash wewnątrz XML.
5) W katalogu TEST_PROJECT_DIR utworzyć strukturę podkatalogów testowych i zapisać wszystkie wygenerowane pliki testowe (po jednym heredocu na plik).
6) Wykonać polecenie:
dotnet sln "$SOLUTION_PATH" add "$TEST_PROJECT_DIR/{TestProjectName}.csproj"
aby dodać projekt testowy do rozwiązania.
7) Wypisać na stderr lub stdout krótki komunikat o sukcesie (np. "Test project {TestProjectName} created and added to solution").
Skrypt może zawierać podstawowe sprawdzenia:
- czy polecenie "dotnet" jest dostępne,
- czy plik solution istnieje pod ścieżką SOLUTION_PATH,
- w razie krytycznych błędów powinien zakończyć się niezerowym kodem wyjścia.
WAŻNE:
- Cała logika analizy kodu C# i generowania treści plików testowych musi być wykonana przez Ciebie PRZED wygenerowaniem skryptu (na podstawie "files").
- Skrypt ma tylko:
- utworzyć katalog projektu testowego,
- zapisać gotowy plik .csproj i pliki testowe (dokładnie takie, jakie wygenerujesz),
- dodać projekt do solution.
- Skrypt NIE może parsować JSON-a, NIE może czytać plików źródłowych projektu głównego ani próbować ich analizować wszystko, czego potrzebuje, musi być już wpisane w jego treść.
- W szczególności w pliku .csproj:
- NIE używaj zmiennych powłoki takich jak $MAIN_PROJECT_REL_PATH wewnątrz `<ProjectReference Include="...">`.
- Wstaw pełną ścieżkę względną względem katalogu projektu testowego, np.:
<ProjectReference Include="../BroseCumulativeReport/BroseCumulativeReport.csproj" />
FORMAT ODPOWIEDZI:
Zwróć dokładnie jeden obiekt JSON o strukturze:
{
"script": "KOMPLETNY SKRYPT /bin/sh",
"comments": "Dodatkowe uwagi lub instrukcje dotyczące użycia skryptu"
}
Zasady formatowania:
- W polu "script" umieść kompletny skrypt /bin/sh gotowy do zapisania do pliku i uruchomienia bez modyfikacji.
- Skrypt w "script" nie może zawierać żadnych znaczników Markdown ani bloków ``` ma to być czysty tekst skryptu.
- W polu "comments" podaj zwięzłe informacje:
- jak uruchomić skrypt,
- jakie są wymagania (np. zainstalowany .NET SDK),
- ewentualne założenia dotyczące ścieżek.
- Nie dodawaj żadnego tekstu poza tym jednym obiektem JSON.
- Upewnij się, że wygenerowany JSON jest poprawny składniowo (prawidłowe cudzysłowy, przecinki, brak zbędnych znaków).

View File

@@ -0,0 +1,6 @@
namespace DeploymentAgent.Shell;
public interface IShellRunner
{
(int ExitCode, string StdOut, string StdErr) RunScript(string script);
}

View File

@@ -0,0 +1,35 @@
using System.Diagnostics;
namespace DeploymentAgent.Shell;
public class ShellRunner : IShellRunner
{
public (int ExitCode, string StdOut, string StdErr) RunScript(string script)
{
var psi = new ProcessStartInfo
{
FileName = "/bin/sh",
Arguments = "-s",
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process();
process.StartInfo = psi;
process.Start();
process.StandardInput.WriteLine(script);
process.StandardInput.Close();
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
return (process.ExitCode, output, error);
}
}

View File

@@ -0,0 +1,6 @@
namespace DeploymentAgent.TestsGenerator;
public interface ITestsGeneratorAgent
{
Task<int> GenerateTestsAsync(string codePath);
}

View File

@@ -0,0 +1,7 @@
namespace DeploymentAgent.TestsGenerator.Models;
public class FileModel
{
public string Path { get; set; } = null!;
public string Content { get; set; } = null!;
}

View File

@@ -0,0 +1,8 @@
namespace DeploymentAgent.TestsGenerator.Models;
public class RequestModel
{
public string ProjectName { get; set; } = null!;
public string SolutionPath { get; set; } = null!;
public List<FileModel> Files { get; set; } = new();
}

View File

@@ -0,0 +1,80 @@
using DeploymentAgent.Shell;
using DeploymentAgent.TestsGenerator.Models;
using Services.OpenAI;
namespace DeploymentAgent.TestsGenerator;
public class TestsGeneratorAgent(IOpenAiService openAiService, IShellRunner shellRunner) : ITestsGeneratorAgent
{
public async Task<int> GenerateTestsAsync(string codePath)
{
var prompt = await File.ReadAllTextAsync(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Prompts",
"TestProjectGenerator.txt"));
var requestModel = GetFormattedJsonRequest(codePath);
var decisionDoc = await openAiService.GetResponseFromChat(requestModel, prompt);
if (decisionDoc == null)
{
Console.WriteLine("AIGate: no response from LLM");
return 1;
}
var generatedTests = decisionDoc.RootElement.GetProperty("script").GetString();
var comments = decisionDoc.RootElement.GetProperty("comments").GetString();
if (string.IsNullOrWhiteSpace(generatedTests)) return 1;
var (exitCode, stdout, stderr) = shellRunner.RunScript(generatedTests);
Console.WriteLine("AIGate: PowerShell script executed.");
Console.WriteLine("Comments from LLM:");
Console.WriteLine(comments);
Console.WriteLine("Shell output:");
Console.WriteLine(exitCode);
Console.WriteLine(stdout);
Console.WriteLine(stderr);
return 0;
}
private RequestModel GetFormattedJsonRequest(string rootPath)
{
var files = new List<FileModel>();
foreach (var filePath in Directory.EnumerateFiles(rootPath, "*.cs", SearchOption.AllDirectories))
{
var content = File.ReadAllText(filePath);
files.Add(new FileModel
{
Path = filePath,
Content = content
});
}
return new RequestModel
{
ProjectName = rootPath,
SolutionPath = GetSolutionName(rootPath) ?? string.Empty,
Files = files
};
}
private string? GetSolutionName(string csprojPath)
{
if (string.IsNullOrWhiteSpace(csprojPath))
throw new ArgumentException("projectDirectory is null or empty", nameof(csprojPath));
var dir = new DirectoryInfo(csprojPath);
while (dir != null)
{
var sln = dir.GetFiles("*.sln", SearchOption.TopDirectoryOnly)
.FirstOrDefault();
if (sln != null)
return sln.FullName;
dir = dir.Parent;
}
return null;
}
}

View File

@@ -71,16 +71,15 @@ public class GiteaService : IGiteaService
}
public async Task<ContentsResponse> GetFileContentAsync(RepositoryApi repositoryApi,
GiteaConfiguration configuration,
string filename)
GiteaConfiguration configuration, string filename, string branch = "master")
{
ArgumentNullException.ThrowIfNull(repositoryApi);
ArgumentNullException.ThrowIfNull(configuration);
if (string.IsNullOrEmpty(filename))
throw new ArgumentException("Filename cannot be null or empty.", nameof(filename));
var repoGetContentsAsync =
await repositoryApi.RepoGetContentsAsync(configuration.Owner, configuration.Repository, filename);
var repoGetContentsAsync = await repositoryApi.RepoGetContentsAsync(configuration.Owner,
configuration.Repository, filename, varRef: branch);
return repoGetContentsAsync;
}

View File

@@ -9,7 +9,7 @@ public interface IGiteaService
Task<List<Commit>> GetLastCommitsAsync(RepositoryApi repositoryApi, GiteaConfiguration configuration, int limit);
Task<ContentsResponse> GetFileContentAsync(RepositoryApi repositoryApi, GiteaConfiguration configuration,
string filename);
string filename, string branch = "master");
Task<Commit?> GetCommitByIdAsync(RepositoryApi repositoryApi, GiteaConfiguration configuration, string commitId);