Skip to content

Commit

Permalink
feat(agent): Support creating unit tests (#28)
Browse files Browse the repository at this point in the history
  • Loading branch information
skarllot authored Jan 6, 2025
1 parent daa69d4 commit 0704def
Show file tree
Hide file tree
Showing 19 changed files with 896 additions and 19 deletions.
63 changes: 50 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# FlowPair

_FlowPair provides automated code review and feedback using CI&T Flow AI through a command-line interface._
_FlowPair is a command-line interface tool that leverages CI&T Flow AI to enhance software development processes._

[![Build status](https://github.com/skarllot/flow-pair/actions/workflows/dotnet.yml/badge.svg?branch=main)](https://github.com/skarllot/flow-pair/actions)
[![GitHub release](https://img.shields.io/github/v/release/skarllot/flow-pair)](https://github.com/skarllot/flow-pair/releases)
Expand All @@ -11,14 +11,20 @@ _FlowPair provides automated code review and feedback using CI&T Flow AI through

## About

FlowPair is a CLI tool that leverages CI&T's Flow AI to provide automated code reviews. It detects Git staged (or unstaged) changes and generates insightful feedback, enhancing your development process with AI-powered assistance.
FlowPair is a powerful command-line tool designed to enhance your software development workflow. By integrating CI&T's Flow AI, FlowPair provides intelligent assistance for various development tasks, including:

## Features
- Automated code reviews
- Unit test generation
- AI-powered feedback on code changes

- Automated code review for Git changes
- AI-powered feedback generation
- HTML report output for easy review
- Simple configuration and usage
With FlowPair, developers can:

- Improve code quality through AI-assisted reviews
- Save time on routine tasks like unit test creation
- Receive instant, actionable feedback on their work
- Streamline the development process with easy-to-use commands

Whether you're working on a small project or a large-scale application, FlowPair offers the tools you need to develop more efficiently and maintain high code standards.

## Installation

Expand Down Expand Up @@ -52,23 +58,54 @@ These credentials are necessary for authenticating with the CI&T Flow AI service

## Usage

To review your Git changes and receive feedback, simply run:
### Code Review

To review your Git changes and receive feedback, use the `review` command:

```bash
flowpair review
flowpair review [path] [options]
```

Arguments:
- `[path]`: Optional. Path to the repository. If not specified, the current directory is used.

Options:
- `-c` or `--commit`: Optional. Specify a commit hash to review changes from that specific commit.

Examples:
1. Review changes in the current directory:
```bash
flowpair review
```

2. Review changes in a specific repository:
```bash
flowpair review /path/to/your/repo
```

3. Review changes from a specific commit:
```bash
flowpair review -c abc123
```

This command will:
1. Detect Git staged (or unstaged) changes in your current repository
1. Detect Git changes in the specified repository (or current directory)
2. Send the changes to CI&T Flow AI for review
3. Generate an HTML file with the feedback
4. Automatically open the HTML report in your default web browser

This streamlined process allows you to quickly view and act on the AI-generated feedback for your code changes.
### Creating Unit Tests

To create a unit test for a specific code file, use the create-unittest command:

## Documentation
```bash
flowpair create-unittest -f <file-path> [-e <example-file-path>]
```

For more detailed information on how to use FlowPair and its features, please refer to our [GitHub wiki](https://github.com/skarllot/flow-pair/wiki).
Options:
- `-f` or `--file-path`: The file path of the code to test (Required)
- `-e` or `--example-file-path`: The example unit test file path (Optional)
This command will generate a unit test for the specified code file, optionally using an example unit test file as a reference.

## Contributing

Expand Down
2 changes: 2 additions & 0 deletions src/FlowPair/Agent/Infrastructure/AgentJsonContext.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using Ciandt.FlowTools.FlowPair.Agent.Operations.CreateUnitTest.v1;
using Ciandt.FlowTools.FlowPair.Agent.Operations.ReviewChanges.v1;

namespace Ciandt.FlowTools.FlowPair.Agent.Infrastructure;
Expand All @@ -11,4 +12,5 @@ namespace Ciandt.FlowTools.FlowPair.Agent.Infrastructure;
PropertyNameCaseInsensitive = true,
RespectNullableAnnotations = true)]
[JsonSerializable(typeof(ImmutableList<ReviewerFeedbackResponse>))]
[JsonSerializable(typeof(FilePathResponse))]
public partial class AgentJsonContext : JsonSerializerContext;
3 changes: 3 additions & 0 deletions src/FlowPair/Agent/Infrastructure/IAgentModule.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Ciandt.FlowTools.FlowPair.Agent.Operations.CreateUnitTest;
using Ciandt.FlowTools.FlowPair.Agent.Operations.Login;
using Ciandt.FlowTools.FlowPair.Agent.Operations.ReviewChanges;
using Ciandt.FlowTools.FlowPair.Agent.Services;
Expand All @@ -16,11 +17,13 @@ namespace Ciandt.FlowTools.FlowPair.Agent.Infrastructure;

// Chat definitions
[Singleton(typeof(IReviewChatDefinition), typeof(ReviewChatDefinition))]
[Singleton(typeof(ICreateUnitTestChatDefinition), typeof(CreateUnitTestChatDefinition))]

// Operations
[Singleton(typeof(ILoginUseCase), typeof(LoginUseCase))]
[Singleton(typeof(LoginCommand))]
[Singleton(typeof(ReviewChangesCommand))]
[Singleton(typeof(CreateUnitTestCommand))]
public interface IAgentModule
{
static AgentJsonContext GetJsonContext() => AgentJsonContext.Default;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using Ciandt.FlowTools.FlowPair.Agent.Infrastructure;
using Ciandt.FlowTools.FlowPair.Agent.Operations.CreateUnitTest.v1;
using Ciandt.FlowTools.FlowPair.Chats.Contracts.v1;
using Ciandt.FlowTools.FlowPair.Chats.Models;
using Ciandt.FlowTools.FlowPair.Chats.Services;

namespace Ciandt.FlowTools.FlowPair.Agent.Operations.CreateUnitTest;

public interface ICreateUnitTestChatDefinition : IChatDefinition<CreateUnitTestResponse>;

public sealed class CreateUnitTestChatDefinition(
AgentJsonContext jsonContext)
: ICreateUnitTestChatDefinition
{
private const string CodeResponseKey = "Markdown";
private const string JsonResponseKey = "JSON";

public ChatScript ChatScript { get; } = new(
"Create unit tests chat script",
[
/* Python */".py", ".pyw", ".pyx", ".pxd", ".pxi",
/* JavaScript */".js", ".jsx", ".mjs", ".cjs",
/* Java */".java",
/* C# */".cs", ".csx",
/* C++ */".cpp", ".cxx", ".cc", ".c++", ".hpp", ".hxx", ".h", ".hh", ".h++",
/* PHP */".php", ".phtml", ".phps",
/* Ruby */".rb", ".rbw", ".rake",
/* Swift */".swift",
/* R */".r",
/* SQL */".sql",
/* Kotlin */".kt", ".kts",
/* TypeScript */".ts", ".tsx",
/* Go (Golang) */".go",
/* Rust */".rs",
/* Scala */".scala", ".sc",
/* Dart */".dart",
/* Perl */".pl", ".pm", ".t", ".pod",
/* MATLAB */".m",
],
"""
You are an expert developer, your task is to create unit tests following the best practices.
You are given a set of project files, containing the filenames and their contents.
""",
[
Instruction.StepInstruction.Of(
"Create unit tests for the specified code"),
Instruction.StepInstruction.Of(
"Ensure the unit test cover every path"),
Instruction.StepInstruction.Of(
"Ensure the unit test does not create any mutants on mutation analysis"),
Instruction.CodeExtractInstruction.Of(
CodeResponseKey,
"Return only the unit tests code inside a code block (```)"),
Instruction.StepInstruction.Of(
"Where the new file for the unit tests should be located " +
"according to language and project standards?"),
Instruction.JsonConvertInstruction.Of(
JsonResponseKey,
"""
Reply the file path in a valid JSON format.
The schema of the JSON object must be:
""",
FilePathResponse.Schema),
]);

public Result<object, string> Parse(string key, string input) => key switch
{
CodeResponseKey => MarkdownCodeExtractor
.TryExtractSingle(input)
.Select(static object (x) => x),
JsonResponseKey => JsonContentDeserializer
.TryDeserialize(input, jsonContext.FilePathResponse)
.Select(static object (x) => x),
_ => $"Unknown output key '{key}'"
};

public Option<CreateUnitTestResponse> ConvertResult(ChatWorkspace chatWorkspace) =>
from filePath in OutputProcessor.GetFirst<FilePathResponse>(chatWorkspace, JsonResponseKey)
from codeSnippet in OutputProcessor.GetFirst<CodeSnippet>(chatWorkspace, CodeResponseKey)
select new CreateUnitTestResponse(filePath.FilePath, codeSnippet.Content);
}
121 changes: 121 additions & 0 deletions src/FlowPair/Agent/Operations/CreateUnitTest/CreateUnitTestCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using System.IO.Abstractions;
using System.Text;
using Ciandt.FlowTools.FlowPair.Agent.Operations.CreateUnitTest.v1;
using Ciandt.FlowTools.FlowPair.Agent.Operations.Login;
using Ciandt.FlowTools.FlowPair.Agent.Services;
using Ciandt.FlowTools.FlowPair.Chats.Models;
using Ciandt.FlowTools.FlowPair.Chats.Services;
using Ciandt.FlowTools.FlowPair.Common;
using Ciandt.FlowTools.FlowPair.LocalFileSystem.Services;
using ConsoleAppFramework;
using Spectre.Console;

namespace Ciandt.FlowTools.FlowPair.Agent.Operations.CreateUnitTest;

public sealed class CreateUnitTestCommand(
IAnsiConsole console,
IFileSystem fileSystem,
ICreateUnitTestChatDefinition chatDefinition,
IWorkingDirectoryWalker workingDirectoryWalker,
IProjectFilesMessageFactory projectFilesMessageFactory,
IDirectoryStructureMessageFactory directoryStructureMessageFactory,
ILoginUseCase loginUseCase,
IChatService chatService)
{
/// <summary>
/// Create unit test for the code on the file.
/// </summary>
/// <param name="filePath">-f, The file path of the code to test.</param>
/// <param name="exampleFilePath">-e, The example unit test file path.</param>
[Command("create-unittest")]
public int Execute(
string filePath,
string? exampleFilePath = null)
{
var fileInfo = fileSystem.FileInfo.New(PathAnalyzer.Normalize(filePath));
if (!fileInfo.Exists)
{
console.MarkupLine("[red]Error:[/] The specified file does not exist.");
return 1;
}

var exampleFileInfo = exampleFilePath is not null
? fileSystem.FileInfo.New(PathAnalyzer.Normalize(exampleFilePath))
: null;
if (exampleFileInfo is not null && !exampleFileInfo.Exists)
{
console.MarkupLine("[red]Error:[/] The specified example file does not exist.");
return 2;
}

return (from rootPath in workingDirectoryWalker.TryFindRepositoryRoot(fileInfo.Directory?.FullName)
.OkOrElse(HandleFindRepositoryRootError)
from session in loginUseCase.Execute(isBackground: true)
.UnwrapErrOr(0)
.Ensure(n => n == 0, 4)
let initialMessages = BuildInitialMessages(fileInfo, exampleFileInfo, rootPath).ToList()
from response in chatService
.Run(console.Progress(), LlmModelType.Claude35Sonnet, chatDefinition, initialMessages)
.MapErr(HandleChatServiceError)
let testFile = CreateUnitTestFile(rootPath, response)
select 0)
.UnwrapEither();
}

private int HandleFindRepositoryRootError()
{
console.MarkupLine("[red]Error:[/] Could not locate Git repository.");
return 3;
}

private int HandleChatServiceError(string errorMessage)
{
console.MarkupLineInterpolated($"[red]Error:[/] {errorMessage}");
return 5;
}

private IEnumerable<Message> BuildInitialMessages(
IFileInfo fileInfo,
IFileInfo? exampleFileInfo,
IDirectoryInfo rootPath)
{
yield return projectFilesMessageFactory.CreateWithProjectFilesContent(rootPath);
yield return directoryStructureMessageFactory.CreateWithRepositoryStructure(rootPath);

if (exampleFileInfo is not null)
{
yield return new Message(
SenderRole.User,
$"""
Unit tests example:
```
{exampleFileInfo.ReadAllText()}
```
""");
}

yield return new Message(
SenderRole.User,
$"""
The source file to be tested is located at '{rootPath.GetRelativePath(fileInfo.FullName)}' and its content is:
```
{fileInfo.ReadAllText()}
```
""");
}

private Unit CreateUnitTestFile(IDirectoryInfo rootPath, CreateUnitTestResponse response)
{
var normalizedFilePath = PathAnalyzer.Normalize(response.FilePath);

var testPath = fileSystem.Path.Combine(rootPath.FullName, normalizedFilePath);

var dirPath = fileSystem.Path.GetDirectoryName(testPath);
if (dirPath != null && !fileSystem.Directory.Exists(dirPath))
fileSystem.Directory.CreateDirectory(dirPath);

fileSystem.File.WriteAllText(testPath, response.Content, Encoding.UTF8);
console.MarkupLineInterpolated($"[green]File created:[/] {response.FilePath}");
return Unit();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Ciandt.FlowTools.FlowPair.Agent.Operations.CreateUnitTest.v1;

public sealed record CreateUnitTestResponse(
string FilePath,
string Content);
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Ciandt.FlowTools.FlowPair.Agent.Operations.CreateUnitTest.v1;

public sealed record FilePathResponse(
string FilePath)
{
public const string Schema =
"""
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"description": "Represents a response for creating a unit test file",
"properties": {
"filePath": {
"type": "string",
"description": "The file path of the created unit test (relative to the repository root directory)",
"minLength": 1
}
},
"required": ["filePath"],
"additionalProperties": false
}
""";
}
2 changes: 1 addition & 1 deletion src/FlowPair/Chats/Services/ChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public Result<TResult, string> Run<TResult>(
Progress progress,
LlmModelType llmModelType,
IChatDefinition<TResult> chatDefinition,
IEnumerable<Message> initialMessages)
IReadOnlyList<Message> initialMessages)
where TResult : notnull
{
return progress.Start(
Expand Down
2 changes: 1 addition & 1 deletion src/FlowPair/Chats/Services/OutputProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public static class OutputProcessor
{
public static Option<TResult> GetFirst<TResult>(ChatWorkspace chatWorkspace, string key)
where TResult : class => chatWorkspace.ChatThreads
.Select(t => CollectionExtensions.GetValueOrDefault(t.Outputs, key) as TResult)
.Select(t => t.Outputs.GetValueOrDefault(key) as TResult)
.WhereNotNull()
.FirstOrNone();

Expand Down
4 changes: 3 additions & 1 deletion src/FlowPair/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Ciandt.FlowTools.FlowPair.Agent.Operations.Login;
using Ciandt.FlowTools.FlowPair.Agent.Operations.CreateUnitTest;
using Ciandt.FlowTools.FlowPair.Agent.Operations.Login;
using Ciandt.FlowTools.FlowPair.Agent.Operations.ReviewChanges;
using Ciandt.FlowTools.FlowPair.DependencyInjection;
using Ciandt.FlowTools.FlowPair.Settings.Operations.Configure;
Expand All @@ -12,4 +13,5 @@
app.Add<ConfigureCommand>();
app.Add<LoginCommand>();
app.Add<ReviewChangesCommand>();
app.Add<CreateUnitTestCommand>();
app.Run(args);
Loading

0 comments on commit 0704def

Please sign in to comment.