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 _logger; public ChangelogGeneratorPlugin(ILogger 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 SupportedParameters => new Dictionary { ["repositoryPath"] = typeof(string), ["fromVersion"] = typeof(string), ["toVersion"] = typeof(string), ["format"] = typeof(string), ["groupByType"] = typeof(bool), ["includeAuthors"] = typeof(bool), ["outputPath"] = typeof(string) }; public async Task ExecuteAsync(IReadOnlyDictionary 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()).Count, Fixes = changelogData.ChangesByType.GetValueOrDefault("fix", new List()).Count, BreakingChanges = changelogData.ChangesByType.GetValueOrDefault("breaking", new List()).Count, OtherChanges = changelogData.ChangesByType.GetValueOrDefault("other", new List()).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 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(); } else { fromCommit = repo.Lookup(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(); } else { toCommit = repo.Lookup(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 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 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 = @"^(?\w+)(?:\((?[\w\-\.]+)\))?(?!)?: (?.+)"; 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 ExtractReferences(string message) { var references = new List(); // 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 { ["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()).Count, fixes = changelogData.ChangesByType.GetValueOrDefault("fix", new List()).Count, breakingChanges = changelogData.ChangesByType.GetValueOrDefault("breaking", new List()).Count, otherChanges = changelogData.Entries.Count - changelogData.ChangesByType.GetValueOrDefault("feature", new List()).Count - changelogData.ChangesByType.GetValueOrDefault("fix", new List()).Count - changelogData.ChangesByType.GetValueOrDefault("breaking", new List()).Count } } }; return JsonSerializer.Serialize(jsonData, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); } private string GenerateHtmlChangelog(ChangelogData changelogData) { var html = new StringBuilder(); html.AppendLine(""); html.AppendLine(""); html.AppendLine(""); html.AppendLine(" "); html.AppendLine(" "); html.AppendLine(" Changelog"); html.AppendLine(" "); html.AppendLine(""); html.AppendLine(""); html.AppendLine("
"); // Header html.AppendLine("

📋 Changelog

"); html.AppendLine($"
"); html.AppendLine($" Generated on {changelogData.GeneratedDate:yyyy-MM-dd HH:mm:ss} UTC
"); if (!string.IsNullOrEmpty(changelogData.FromVersion) || !string.IsNullOrEmpty(changelogData.ToVersion)) { html.AppendLine($" Version range: {changelogData.FromVersion ?? "start"} → {changelogData.ToVersion}
"); } html.AppendLine($" Total commits: {changelogData.TotalCommits}"); html.AppendLine($"
"); if (changelogData.ChangesByType.Any()) { var typeOrder = new[] { "breaking", "feature", "fix", "perf", "refactor", "docs", "style", "test", "chore", "other" }; var typeHeaders = new Dictionary { ["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($"

{typeHeaders.GetValueOrDefault(type, type.ToUpper())}

"); foreach (var entry in entries.OrderByDescending(e => e.Date)) { html.AppendLine($"
"); var description = entry.Description; if (!string.IsNullOrEmpty(entry.Scope)) { description = $"{entry.Scope}: {entry.Description}"; } html.AppendLine($" {description}"); if (entry.References.Any()) { html.AppendLine($" ({string.Join(", ", entry.References)})"); } html.AppendLine($"
{entry.CommitSha}"); if (!string.IsNullOrEmpty(entry.Author)) { html.AppendLine($" by {entry.Author}"); } html.AppendLine("
"); } } } } // Contributors section if (changelogData.Authors.Any()) { html.AppendLine("

👥 Contributors

"); html.AppendLine("
"); foreach (var author in changelogData.Authors.OrderBy(a => a)) { html.AppendLine($"
{author}
"); } html.AppendLine("
"); } html.AppendLine("
"); html.AppendLine(""); html.AppendLine(""); 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 Entries { get; set; } = new(); public Dictionary> ChangesByType { get; set; } = new(); public List 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 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; } } }