MarketAlly.AIPlugin.Extensions/MarketAlly.AIPlugin.DevOps/ChangelogGeneratorPlugin.cs

679 lines
22 KiB
C#
Executable File

using MarketAlly.AIPlugin;
using Microsoft.Extensions.Logging;
using LibGit2Sharp;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace MarketAlly.AIPlugin.DevOps.Plugins
{
[AIPlugin("ChangelogGenerator", "Automatically generates changelogs from git history and commit messages")]
public class ChangelogGeneratorPlugin : IAIPlugin
{
private readonly ILogger<ChangelogGeneratorPlugin> _logger;
public ChangelogGeneratorPlugin(ILogger<ChangelogGeneratorPlugin> logger = null)
{
_logger = logger;
}
[AIParameter("Full path to the git repository", required: true)]
public string RepositoryPath { get; set; }
[AIParameter("Starting version or tag for changelog generation", required: false)]
public string FromVersion { get; set; }
[AIParameter("Ending version or tag for changelog generation", required: false)]
public string ToVersion { get; set; } = "HEAD";
[AIParameter("Changelog format: markdown, json, html", required: false)]
public string Format { get; set; } = "markdown";
[AIParameter("Group changes by type (feature, bugfix, breaking)", required: false)]
public bool GroupByType { get; set; } = true;
[AIParameter("Include commit authors", required: false)]
public bool IncludeAuthors { get; set; } = true;
[AIParameter("Output file path for the changelog", required: false)]
public string OutputPath { get; set; }
public IReadOnlyDictionary<string, Type> SupportedParameters => new Dictionary<string, Type>
{
["repositoryPath"] = typeof(string),
["fromVersion"] = typeof(string),
["toVersion"] = typeof(string),
["format"] = typeof(string),
["groupByType"] = typeof(bool),
["includeAuthors"] = typeof(bool),
["outputPath"] = typeof(string)
};
public async Task<AIPluginResult> ExecuteAsync(IReadOnlyDictionary<string, object> parameters)
{
try
{
_logger?.LogInformation("ChangelogGenerator plugin executing");
// Extract parameters
string repositoryPath = parameters["repositoryPath"].ToString();
string fromVersion = parameters.TryGetValue("fromVersion", out var fromObj) ? fromObj?.ToString() : null;
string toVersion = parameters.TryGetValue("toVersion", out var toObj) ? toObj?.ToString() : "HEAD";
string format = parameters.TryGetValue("format", out var formatObj) ? formatObj.ToString() : "markdown";
bool groupByType = parameters.TryGetValue("groupByType", out var groupObj) && Convert.ToBoolean(groupObj);
bool includeAuthors = parameters.TryGetValue("includeAuthors", out var authorObj) && Convert.ToBoolean(authorObj);
string outputPath = parameters.TryGetValue("outputPath", out var pathObj) ? pathObj?.ToString() : null;
// Validate repository path
if (!Directory.Exists(repositoryPath))
{
return new AIPluginResult(
new DirectoryNotFoundException($"Repository path not found: {repositoryPath}"),
"Repository path not found"
);
}
if (!Directory.Exists(Path.Combine(repositoryPath, ".git")))
{
return new AIPluginResult(
new InvalidOperationException($"Not a git repository: {repositoryPath}"),
"Not a git repository"
);
}
// Generate changelog
using (var repo = new Repository(repositoryPath))
{
var changelogData = await GenerateChangelogDataAsync(repo, fromVersion, toVersion, includeAuthors);
if (groupByType)
{
GroupChangesByType(changelogData);
}
// Generate changelog content based on format
string changelogContent = format.ToLower() switch
{
"markdown" => GenerateMarkdownChangelog(changelogData),
"json" => GenerateJsonChangelog(changelogData),
"html" => GenerateHtmlChangelog(changelogData),
_ => GenerateMarkdownChangelog(changelogData)
};
// Save to file if output path specified
if (!string.IsNullOrEmpty(outputPath))
{
var outputDirectory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(outputDirectory) && !Directory.Exists(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
await File.WriteAllTextAsync(outputPath, changelogContent, Encoding.UTF8);
}
var result = new
{
Message = "Changelog generation completed",
RepositoryPath = repositoryPath,
VersionRange = $"{fromVersion ?? "start"} -> {toVersion}",
CommitsProcessed = changelogData.TotalCommits,
ChangelogContent = changelogContent,
OutputPath = outputPath,
Summary = new
{
Features = changelogData.ChangesByType.GetValueOrDefault("feature", new List<ChangelogEntry>()).Count,
Fixes = changelogData.ChangesByType.GetValueOrDefault("fix", new List<ChangelogEntry>()).Count,
BreakingChanges = changelogData.ChangesByType.GetValueOrDefault("breaking", new List<ChangelogEntry>()).Count,
OtherChanges = changelogData.ChangesByType.GetValueOrDefault("other", new List<ChangelogEntry>()).Count,
UniqueAuthors = changelogData.Authors.Count,
DateRange = $"{changelogData.StartDate:yyyy-MM-dd} to {changelogData.EndDate:yyyy-MM-dd}"
}
};
_logger?.LogInformation("Changelog generation completed. Processed {CommitsProcessed} commits from {Authors} authors",
changelogData.TotalCommits, changelogData.Authors.Count);
return new AIPluginResult(result);
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to generate changelog");
return new AIPluginResult(ex, "Failed to generate changelog");
}
}
private async Task<ChangelogData> GenerateChangelogDataAsync(Repository repo, string fromVersion, string toVersion, bool includeAuthors)
{
var changelogData = new ChangelogData
{
FromVersion = fromVersion,
ToVersion = toVersion,
GeneratedDate = DateTime.UtcNow
};
// Resolve commit range
Commit fromCommit = null;
Commit toCommit = null;
try
{
if (!string.IsNullOrEmpty(fromVersion))
{
// Try to resolve as tag first, then as commit SHA
var fromTag = repo.Tags.FirstOrDefault(t => t.FriendlyName == fromVersion);
if (fromTag != null)
{
fromCommit = fromTag.Target.Peel<Commit>();
}
else
{
fromCommit = repo.Lookup<Commit>(fromVersion);
}
}
if (toVersion == "HEAD")
{
toCommit = repo.Head.Tip;
}
else
{
var toTag = repo.Tags.FirstOrDefault(t => t.FriendlyName == toVersion);
if (toTag != null)
{
toCommit = toTag.Target.Peel<Commit>();
}
else
{
toCommit = repo.Lookup<Commit>(toVersion);
}
}
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to resolve version range, using default range");
}
// Get commit range
var commits = GetCommitsInRange(repo, fromCommit, toCommit);
changelogData.TotalCommits = commits.Count();
// Set date range
if (commits.Any())
{
changelogData.StartDate = commits.Last().Author.When.DateTime;
changelogData.EndDate = commits.First().Author.When.DateTime;
}
// Process commits
foreach (var commit in commits)
{
var entry = CreateChangelogEntry(commit, includeAuthors);
changelogData.Entries.Add(entry);
if (includeAuthors && !changelogData.Authors.Contains(entry.Author))
{
changelogData.Authors.Add(entry.Author);
}
}
return changelogData;
}
private IEnumerable<Commit> GetCommitsInRange(Repository repo, Commit fromCommit, Commit toCommit)
{
if (toCommit == null)
{
toCommit = repo.Head.Tip;
}
var commitLog = repo.Commits.QueryBy(new CommitFilter
{
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time,
IncludeReachableFrom = toCommit
});
IEnumerable<Commit> commits = commitLog;
if (fromCommit != null)
{
commits = commits.TakeWhile(c => c.Id != fromCommit.Id);
}
return commits.Where(c => !IsMergeCommit(c) || IsImportantMergeCommit(c));
}
private bool IsMergeCommit(Commit commit)
{
return commit.Parents.Count() > 1;
}
private bool IsImportantMergeCommit(Commit commit)
{
// Include merge commits that seem important (e.g., feature merges)
var message = commit.MessageShort.ToLower();
return message.Contains("merge pull request") ||
message.Contains("merge feature") ||
message.Contains("merge branch");
}
private ChangelogEntry CreateChangelogEntry(Commit commit, bool includeAuthors)
{
var message = commit.MessageShort;
var fullMessage = commit.Message;
var entry = new ChangelogEntry
{
CommitSha = commit.Sha[0..8], // First 8 characters
Date = commit.Author.When.DateTime,
Author = includeAuthors ? $"{commit.Author.Name} <{commit.Author.Email}>" : commit.Author.Name,
Message = message,
FullMessage = fullMessage
};
// Parse conventional commit format
var conventionalCommit = ParseConventionalCommit(message);
if (conventionalCommit != null)
{
entry.Type = conventionalCommit.Type;
entry.Scope = conventionalCommit.Scope;
entry.Description = conventionalCommit.Description;
entry.IsBreaking = conventionalCommit.IsBreaking;
}
else
{
// Fallback to heuristic parsing
entry.Type = DetermineChangeType(message);
entry.Description = message;
}
// Extract issue/PR references
entry.References = ExtractReferences(fullMessage);
return entry;
}
private ConventionalCommit ParseConventionalCommit(string message)
{
// Conventional commit format: type(scope): description
var pattern = @"^(?<type>\w+)(?:\((?<scope>[\w\-\.]+)\))?(?<breaking>!)?: (?<description>.+)";
var match = Regex.Match(message, pattern);
if (match.Success)
{
return new ConventionalCommit
{
Type = match.Groups["type"].Value,
Scope = match.Groups["scope"].Success ? match.Groups["scope"].Value : null,
Description = match.Groups["description"].Value,
IsBreaking = match.Groups["breaking"].Success
};
}
return null;
}
private string DetermineChangeType(string message)
{
var lowerMessage = message.ToLower();
if (lowerMessage.StartsWith("feat") || lowerMessage.Contains("feature") || lowerMessage.Contains("add"))
return "feature";
if (lowerMessage.StartsWith("fix") || lowerMessage.Contains("bug") || lowerMessage.Contains("repair"))
return "fix";
if (lowerMessage.StartsWith("docs") || lowerMessage.Contains("documentation"))
return "docs";
if (lowerMessage.StartsWith("style") || lowerMessage.Contains("formatting"))
return "style";
if (lowerMessage.StartsWith("refactor") || lowerMessage.Contains("refactor"))
return "refactor";
if (lowerMessage.StartsWith("perf") || lowerMessage.Contains("performance"))
return "perf";
if (lowerMessage.StartsWith("test") || lowerMessage.Contains("test"))
return "test";
if (lowerMessage.StartsWith("chore") || lowerMessage.Contains("maintenance"))
return "chore";
if (lowerMessage.Contains("breaking") || lowerMessage.Contains("major"))
return "breaking";
return "other";
}
private List<string> ExtractReferences(string message)
{
var references = new List<string>();
// Extract issue references (#123)
var issueMatches = Regex.Matches(message, @"#(\d+)");
foreach (Match match in issueMatches)
{
references.Add($"#{match.Groups[1].Value}");
}
// Extract PR references
var prMatches = Regex.Matches(message, @"(?:PR|pull request)\s*#?(\d+)", RegexOptions.IgnoreCase);
foreach (Match match in prMatches)
{
references.Add($"PR #{match.Groups[1].Value}");
}
return references.Distinct().ToList();
}
private void GroupChangesByType(ChangelogData changelogData)
{
changelogData.ChangesByType = changelogData.Entries
.GroupBy(e => e.Type)
.ToDictionary(g => g.Key, g => g.ToList());
}
private string GenerateMarkdownChangelog(ChangelogData changelogData)
{
var markdown = new StringBuilder();
// Header
markdown.AppendLine("# Changelog");
markdown.AppendLine();
markdown.AppendLine($"Generated on {changelogData.GeneratedDate:yyyy-MM-dd HH:mm:ss} UTC");
if (!string.IsNullOrEmpty(changelogData.FromVersion) || !string.IsNullOrEmpty(changelogData.ToVersion))
{
markdown.AppendLine($"Version range: {changelogData.FromVersion ?? "start"} → {changelogData.ToVersion}");
}
markdown.AppendLine();
if (changelogData.ChangesByType.Any())
{
// Group by type
var typeOrder = new[] { "breaking", "feature", "fix", "perf", "refactor", "docs", "style", "test", "chore", "other" };
var typeHeaders = new Dictionary<string, string>
{
["breaking"] = "💥 Breaking Changes",
["feature"] = "✨ Features",
["fix"] = "🐛 Bug Fixes",
["perf"] = "⚡ Performance Improvements",
["refactor"] = "♻️ Code Refactoring",
["docs"] = "📚 Documentation",
["style"] = "💄 Styles",
["test"] = "✅ Tests",
["chore"] = "🔧 Chores",
["other"] = "📦 Other Changes"
};
foreach (var type in typeOrder)
{
if (changelogData.ChangesByType.TryGetValue(type, out var entries) && entries.Any())
{
markdown.AppendLine($"## {typeHeaders.GetValueOrDefault(type, type.ToUpper())}");
markdown.AppendLine();
foreach (var entry in entries.OrderByDescending(e => e.Date))
{
var line = $"- {entry.Description}";
if (!string.IsNullOrEmpty(entry.Scope))
{
line = $"- **{entry.Scope}**: {entry.Description}";
}
if (entry.References.Any())
{
line += $" ({string.Join(", ", entry.References)})";
}
line += $" ([`{entry.CommitSha}`])";
if (!string.IsNullOrEmpty(entry.Author))
{
line += $" - {entry.Author}";
}
markdown.AppendLine(line);
}
markdown.AppendLine();
}
}
}
else
{
// Chronological order if not grouped by type
markdown.AppendLine("## Changes");
markdown.AppendLine();
foreach (var entry in changelogData.Entries.OrderByDescending(e => e.Date))
{
var line = $"- {entry.Message} ([`{entry.CommitSha}`])";
if (!string.IsNullOrEmpty(entry.Author))
{
line += $" - {entry.Author}";
}
markdown.AppendLine(line);
}
}
// Contributors section
if (changelogData.Authors.Any())
{
markdown.AppendLine("## Contributors");
markdown.AppendLine();
foreach (var author in changelogData.Authors.OrderBy(a => a))
{
markdown.AppendLine($"- {author}");
}
markdown.AppendLine();
}
return markdown.ToString();
}
private string GenerateJsonChangelog(ChangelogData changelogData)
{
var jsonData = new
{
changelog = new
{
generatedDate = changelogData.GeneratedDate,
fromVersion = changelogData.FromVersion,
toVersion = changelogData.ToVersion,
totalCommits = changelogData.TotalCommits,
dateRange = new
{
start = changelogData.StartDate,
end = changelogData.EndDate
},
changesByType = changelogData.ChangesByType.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.Select(e => new
{
commitSha = e.CommitSha,
date = e.Date,
author = e.Author,
type = e.Type,
scope = e.Scope,
description = e.Description,
message = e.Message,
isBreaking = e.IsBreaking,
references = e.References
}).ToArray()
),
authors = changelogData.Authors.OrderBy(a => a).ToArray(),
summary = new
{
features = changelogData.ChangesByType.GetValueOrDefault("feature", new List<ChangelogEntry>()).Count,
fixes = changelogData.ChangesByType.GetValueOrDefault("fix", new List<ChangelogEntry>()).Count,
breakingChanges = changelogData.ChangesByType.GetValueOrDefault("breaking", new List<ChangelogEntry>()).Count,
otherChanges = changelogData.Entries.Count -
changelogData.ChangesByType.GetValueOrDefault("feature", new List<ChangelogEntry>()).Count -
changelogData.ChangesByType.GetValueOrDefault("fix", new List<ChangelogEntry>()).Count -
changelogData.ChangesByType.GetValueOrDefault("breaking", new List<ChangelogEntry>()).Count
}
}
};
return JsonSerializer.Serialize(jsonData, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
private string GenerateHtmlChangelog(ChangelogData changelogData)
{
var html = new StringBuilder();
html.AppendLine("<!DOCTYPE html>");
html.AppendLine("<html lang=\"en\">");
html.AppendLine("<head>");
html.AppendLine(" <meta charset=\"UTF-8\">");
html.AppendLine(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
html.AppendLine(" <title>Changelog</title>");
html.AppendLine(" <style>");
html.AppendLine(" body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; margin: 0; padding: 20px; background: #f5f5f5; }");
html.AppendLine(" .container { max-width: 900px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }");
html.AppendLine(" h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }");
html.AppendLine(" h2 { color: #34495e; margin-top: 40px; }");
html.AppendLine(" .meta { color: #7f8c8d; font-size: 0.9em; margin-bottom: 30px; }");
html.AppendLine(" .change-item { margin: 8px 0; padding: 8px; border-left: 3px solid #ecf0f1; }");
html.AppendLine(" .breaking { border-left-color: #e74c3c; background: #fdf2f2; }");
html.AppendLine(" .feature { border-left-color: #2ecc71; background: #f0f9f4; }");
html.AppendLine(" .fix { border-left-color: #f39c12; background: #fef9e7; }");
html.AppendLine(" .commit-sha { font-family: 'Courier New', monospace; font-size: 0.8em; color: #7f8c8d; }");
html.AppendLine(" .author { font-size: 0.8em; color: #95a5a6; }");
html.AppendLine(" .scope { font-weight: bold; color: #9b59b6; }");
html.AppendLine(" .references { font-size: 0.8em; color: #3498db; }");
html.AppendLine(" .contributors { display: flex; flex-wrap: wrap; gap: 10px; }");
html.AppendLine(" .contributor { background: #ecf0f1; padding: 5px 10px; border-radius: 15px; font-size: 0.8em; }");
html.AppendLine(" </style>");
html.AppendLine("</head>");
html.AppendLine("<body>");
html.AppendLine(" <div class=\"container\">");
// Header
html.AppendLine(" <h1>📋 Changelog</h1>");
html.AppendLine($" <div class=\"meta\">");
html.AppendLine($" Generated on {changelogData.GeneratedDate:yyyy-MM-dd HH:mm:ss} UTC<br>");
if (!string.IsNullOrEmpty(changelogData.FromVersion) || !string.IsNullOrEmpty(changelogData.ToVersion))
{
html.AppendLine($" Version range: {changelogData.FromVersion ?? "start"} → {changelogData.ToVersion}<br>");
}
html.AppendLine($" Total commits: {changelogData.TotalCommits}");
html.AppendLine($" </div>");
if (changelogData.ChangesByType.Any())
{
var typeOrder = new[] { "breaking", "feature", "fix", "perf", "refactor", "docs", "style", "test", "chore", "other" };
var typeHeaders = new Dictionary<string, string>
{
["breaking"] = "💥 Breaking Changes",
["feature"] = "✨ Features",
["fix"] = "🐛 Bug Fixes",
["perf"] = "⚡ Performance Improvements",
["refactor"] = "♻️ Code Refactoring",
["docs"] = "📚 Documentation",
["style"] = "💄 Styles",
["test"] = "✅ Tests",
["chore"] = "🔧 Chores",
["other"] = "📦 Other Changes"
};
foreach (var type in typeOrder)
{
if (changelogData.ChangesByType.TryGetValue(type, out var entries) && entries.Any())
{
html.AppendLine($" <h2>{typeHeaders.GetValueOrDefault(type, type.ToUpper())}</h2>");
foreach (var entry in entries.OrderByDescending(e => e.Date))
{
html.AppendLine($" <div class=\"change-item {type}\">");
var description = entry.Description;
if (!string.IsNullOrEmpty(entry.Scope))
{
description = $"<span class=\"scope\">{entry.Scope}</span>: {entry.Description}";
}
html.AppendLine($" {description}");
if (entry.References.Any())
{
html.AppendLine($" <span class=\"references\">({string.Join(", ", entry.References)})</span>");
}
html.AppendLine($" <br><span class=\"commit-sha\">{entry.CommitSha}</span>");
if (!string.IsNullOrEmpty(entry.Author))
{
html.AppendLine($" <span class=\"author\">by {entry.Author}</span>");
}
html.AppendLine(" </div>");
}
}
}
}
// Contributors section
if (changelogData.Authors.Any())
{
html.AppendLine(" <h2>👥 Contributors</h2>");
html.AppendLine(" <div class=\"contributors\">");
foreach (var author in changelogData.Authors.OrderBy(a => a))
{
html.AppendLine($" <div class=\"contributor\">{author}</div>");
}
html.AppendLine(" </div>");
}
html.AppendLine(" </div>");
html.AppendLine("</body>");
html.AppendLine("</html>");
return html.ToString();
}
}
// Data models for Changelog Generation
public class ChangelogData
{
public string FromVersion { get; set; }
public string ToVersion { get; set; }
public DateTime GeneratedDate { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public int TotalCommits { get; set; }
public List<ChangelogEntry> Entries { get; set; } = new();
public Dictionary<string, List<ChangelogEntry>> ChangesByType { get; set; } = new();
public List<string> Authors { get; set; } = new();
}
public class ChangelogEntry
{
public string CommitSha { get; set; }
public DateTime Date { get; set; }
public string Author { get; set; }
public string Type { get; set; }
public string Scope { get; set; }
public string Description { get; set; }
public string Message { get; set; }
public string FullMessage { get; set; }
public bool IsBreaking { get; set; }
public List<string> References { get; set; } = new();
}
public class ConventionalCommit
{
public string Type { get; set; }
public string Scope { get; set; }
public string Description { get; set; }
public bool IsBreaking { get; set; }
}
}