Files
2025-12-28 05:38:14 -05:00

200 lines
7.1 KiB
C#

using System.Text;
using MarketAlly.AIPlugin;
using MarketAlly.AIPlugin.Conversation;
using MarketAlly.GitCommitEditor.Models;
using MarketAlly.GitCommitEditor.Options;
using MarketAlly.GitCommitEditor.Plugins;
using MarketAlly.GitCommitEditor.Services;
namespace MarketAlly.GitCommitEditor.Rewriters;
/// <summary>
/// Commit message rewriter using MarketAlly.AIPlugin's AIConversation API.
/// Supports multiple AI providers (Claude, OpenAI, Gemini, Mistral, Qwen).
/// </summary>
public sealed class AICommitRewriter : ICommitMessageRewriter, IDisposable
{
private const string OperationType = "CommitSuggestion";
private readonly AiOptions _options;
private readonly AIConversation _conversation;
private readonly ICostTrackingService? _costTracker;
public AICommitRewriter(AiOptions options, ICostTrackingService? costTracker = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_costTracker = costTracker;
var provider = ParseProvider(options.Provider);
_conversation = AIConversationBuilder.Create()
.UseProvider(provider, options.ApiKey)
.UseModel(options.Model)
.WithSystemPrompt(BuildSystemPrompt())
.WithTemperature(0.3) // Lower temperature for more consistent output
.WithMaxTokens(options.MaxTokens)
.WithToolExecutionLimit(3)
.RegisterPlugin<ReturnCommitMessagePlugin>()
.Build();
}
private static AIProvider ParseProvider(string? providerName)
{
return providerName?.ToLowerInvariant() switch
{
"claude" or "anthropic" => AIProvider.Claude,
"openai" or "gpt" => AIProvider.OpenAI,
"gemini" or "google" => AIProvider.Gemini,
"mistral" => AIProvider.Mistral,
"qwen" or "alibaba" => AIProvider.Qwen,
_ => AIProvider.Claude // Default to Claude
};
}
private static string BuildSystemPrompt()
{
return """
You are a git commit message expert. Your task is to improve commit messages following conventional commit format.
RULES:
1. Description: max 60 chars, imperative mood ("add" not "added"), no period, lowercase start
2. Scope is optional - use it for the area of codebase (e.g., "api", "ui", "auth")
3. Be specific about WHAT changed based on the files and diff
4. Add a body ONLY if the change is complex and needs explanation
5. Body should explain WHY, not repeat WHAT
When you receive commit information, call ReturnCommitMessage with the improved message.
""";
}
public async Task<SuggestionResult> SuggestImprovedMessageAsync(CommitAnalysis analysis, CancellationToken ct = default)
{
var prompt = BuildPrompt(analysis);
var response = await _conversation.SendForStructuredOutputAsync<CommitMessageResult>(
prompt,
"ReturnCommitMessage",
ct);
// Extract cost information from response
var inputTokens = response.InputTokens;
var outputTokens = response.OutputTokens;
var cost = response.EstimatedCost ?? 0m;
// Record cost if tracking service is available
_costTracker?.RecordOperation(OperationType, inputTokens, outputTokens, cost);
if (response.StructuredOutput is CommitMessageResult result)
{
// Combine subject and body
var suggestion = string.IsNullOrWhiteSpace(result.Body)
? result.Subject.Trim()
: $"{result.Subject.Trim()}\n\n{result.Body.Trim()}";
// Check if the suggestion is actually different from original
if (string.Equals(suggestion, analysis.OriginalMessage, StringComparison.Ordinal))
{
return SuggestionResult.FailedWithOriginal(
analysis.OriginalMessage,
$"AI returned same message as original. Raw: {response.FinalMessage}",
inputTokens, outputTokens, cost);
}
return SuggestionResult.Succeeded(suggestion, inputTokens, outputTokens, cost);
}
// AI failed to return structured output
return SuggestionResult.FailedWithOriginal(
analysis.OriginalMessage,
$"No structured output. FinalMessage: {response.FinalMessage ?? "(null)"}",
inputTokens, outputTokens, cost);
}
public async Task<IReadOnlyList<(CommitAnalysis Analysis, SuggestionResult Result)>> SuggestBatchAsync(
IEnumerable<CommitAnalysis> analyses,
IProgress<int>? progress = null,
CancellationToken ct = default)
{
var results = new List<(CommitAnalysis, SuggestionResult)>();
var analysisList = analyses.ToList();
var processed = 0;
foreach (var analysis in analysisList)
{
if (ct.IsCancellationRequested) break;
try
{
var result = await SuggestImprovedMessageAsync(analysis, ct);
results.Add((analysis, result));
}
catch (Exception ex)
{
results.Add((analysis, SuggestionResult.Failed(ex.Message)));
}
processed++;
progress?.Report(processed);
// Rate limiting delay between requests
if (processed < analysisList.Count && _options.RateLimitDelayMs > 0)
{
await Task.Delay(_options.RateLimitDelayMs, ct);
}
}
return results;
}
private string BuildPrompt(CommitAnalysis analysis)
{
var sb = new StringBuilder();
sb.AppendLine("Please improve this commit message:");
sb.AppendLine();
sb.AppendLine($"ORIGINAL MESSAGE: {analysis.OriginalMessage}");
sb.AppendLine();
sb.AppendLine("FILES CHANGED:");
var files = analysis.FilesChanged.Take(20).ToList();
foreach (var file in files)
{
sb.AppendLine($" - {file}");
}
if (analysis.FilesChanged.Count > 20)
{
sb.AppendLine($" ... and {analysis.FilesChanged.Count - 20} more files");
}
sb.AppendLine();
sb.AppendLine($"STATS: +{analysis.LinesAdded} -{analysis.LinesDeleted} lines");
if (!string.IsNullOrEmpty(analysis.DiffSummary) && _options.IncludeDiffContext)
{
sb.AppendLine();
sb.AppendLine("DIFF SUMMARY:");
var diffLines = analysis.DiffSummary.Split('\n').Take(_options.MaxDiffLines);
sb.AppendLine(string.Join('\n', diffLines));
}
if (analysis.Quality.Issues.Count > 0)
{
sb.AppendLine();
sb.AppendLine("ISSUES WITH CURRENT MESSAGE:");
foreach (var issue in analysis.Quality.Issues)
{
sb.AppendLine($" - [{issue.Severity}] {issue.Message}");
}
}
sb.AppendLine();
sb.AppendLine("Call ReturnCommitMessage with the improved message.");
return sb.ToString();
}
public void Dispose()
{
// AIConversation handles its own disposal
}
}