679 lines
22 KiB
C#
Executable File
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; }
|
|
}
|
|
} |