diff --git a/README.md b/README.md index 1fed891..81861f5 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,19 @@ Claude Haiku 4.5 - [dotnet/runtime](https://github.com/dotnet/runtime) - [Claude Docs - Models overview](https://platform.claude.com/docs/en/about-claude/models/overview) + +## Third-party Notices + +### Material Design Icons + +- Icon: rss +- URL: https://pictogrammers.com/library/mdi/icon/rss/ +- Copyright (c) 2014 Austin Andrews (https://templarian.com/) +- Licensed under the [Apache License, Version 2.0](https://github.com/Templarian/MaterialDesign/blob/master/LICENSE) + +### GitHub Octicons + +- Icon: mark-github +- URL: https://github.com/primer/octicons/blob/main/icons/mark-github-24.svg +- Copyright (c) 2012 GitHub, Inc. +- Licensed under the [MIT License](https://github.com/primer/octicons/blob/main/LICENSE) diff --git a/src/PRDigest.NET/HtmlGenereator.cs b/src/PRDigest.NET/HtmlGenerator.cs similarity index 70% rename from src/PRDigest.NET/HtmlGenereator.cs rename to src/PRDigest.NET/HtmlGenerator.cs index 9420af3..8ad1682 100644 --- a/src/PRDigest.NET/HtmlGenereator.cs +++ b/src/PRDigest.NET/HtmlGenerator.cs @@ -1,19 +1,14 @@ using Markdig; -using Markdig.Extensions.AutoIdentifiers; -using Markdig.Syntax; -using Markdig.Syntax.Inlines; +using System.Buffers; using System.Globalization; using System.Runtime.CompilerServices; +using System.Text.Encodings.Web; namespace PRDigest.NET; -internal static class HtmlGenereator +internal static class HtmlGenerator { private static readonly StringComparer NumericOrderingComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); - private static MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() - .UseAutoIdentifiers(AutoIdentifierOptions.GitHub) - .UseAdvancedExtensions() - .Build(); public static string GenerateIndex(string archivesDir, string outputsDir) { @@ -26,17 +21,40 @@ public static string GenerateIndex(string archivesDir, string outputsDir) foreach (var monthDirss in Directory.GetDirectories(yearDirs).OrderDescending(comparer)) { var month = Path.GetFileName(monthDirss); - detailsBuilder.AppendLiteral($"
{Environment.NewLine}"); - detailsBuilder.AppendLiteral($" {year}年{month}月{Environment.NewLine}"); - detailsBuilder.AppendLiteral($" "); + detailsBuilder.AppendLiteral(Environment.NewLine); + detailsBuilder.AppendLiteral("
"); + detailsBuilder.AppendLiteral(Environment.NewLine); } } @@ -57,8 +75,8 @@ public static string GenerateIndex(string archivesDir, string outputsDir) if (File.Exists(latestMarkdownPath)) { var markdownContent = File.ReadAllText(latestMarkdownPath); - var document = Markdown.Parse(markdownContent, Pipeline); - var analyzerResult = PullReqeustAnalayzer.Analayze(document); + var document = Markdown.Parse(markdownContent, MarkdownOptions.Pipeline); + var analyzerResult = PullRequestAnalyzer.Analyze(document); statsHtml = $"""
@@ -91,8 +109,8 @@ public static string GenerateIndex(string archivesDir, string outputsDir) public static string GenerateHtmlFromMarkdown(string startTargetDate, string markdownContent) { - var document = Markdown.Parse(markdownContent, Pipeline); - var contentHtml = Markdown.ToHtml(document, Pipeline); + var document = Markdown.Parse(markdownContent, MarkdownOptions.Pipeline); + var contentHtml = Markdown.ToHtml(document, MarkdownOptions.Pipeline); // Split contentHtml into TOC part and PR details part // The TOC ends after , then a
separates it from PR details @@ -123,7 +141,7 @@ public static string GenerateHtmlFromMarkdown(string startTargetDate, string mar prDetailsHtml = ""; } - var analyzerResult = PullReqeustAnalayzer.Analayze(document); + var analyzerResult = PullRequestAnalyzer.Analyze(document); var categoryViewHtml = GenerateCategorizedTocHtml(analyzerResult); var labelViewHtml = GenerateLabelViewHtml(analyzerResult); @@ -151,114 +169,107 @@ public static string GenerateHtmlFromMarkdown(string startTargetDate, string mar return GenerateTemplateHtml($"Pull Request on {startTargetDate}", "dotnet/runtimeにマージされたPull RequestをAIで日本語要約", content, includeViewScript: true); } - private static string GenerateLabelViewHtml(PullReqeustAnalayzer.AnalayzerResult analyzerResult) + private static string GenerateLabelViewHtml(PullRequestAnalyzer.AnalysisResults analyzerResult) { - if (analyzerResult.LabelInfo is null || analyzerResult.LabelCount == 0) + if (analyzerResult.LabelCount == 0) return "

ラベル情報がありません。

"; var builder = new DefaultInterpolatedStringHandler(0, 0); - builder.AppendLiteral($"

ラベル別PR一覧

{Environment.NewLine}"); + builder.AppendLiteral("

ラベル別PR一覧

"); + builder.AppendLiteral(Environment.NewLine); - foreach (var (labelName, headingBlocks) in analyzerResult.LabelInfo.OrderByDescending(kv => kv.Value.Count)) + foreach (var (labelName, metadataList) in analyzerResult.LabelMap.OrderByDescending(kv => kv.Value.Length)) { - var colorStyle = ""; - if (analyzerResult.LabelColorMap is not null && analyzerResult.LabelColorMap.TryGetValue(labelName, out var color)) + builder.AppendLiteral("
"); + builder.AppendLiteral(Environment.NewLine); + builder.AppendLiteral($" "); + builder.AppendLiteral(labelName); + builder.AppendLiteral(" ("); + builder.AppendFormatted(metadataList.Length); + builder.AppendLiteral(" PRs)"); + builder.AppendFormatted(Environment.NewLine); - builder.AppendLiteral($"
{Environment.NewLine}"); - builder.AppendLiteral($" {labelName} ({headingBlocks.Count} PRs){Environment.NewLine}"); - builder.AppendLiteral($"
    {Environment.NewLine}"); + builder.AppendLiteral("
      "); + builder.AppendLiteral(Environment.NewLine); - foreach (var heading in headingBlocks) + foreach (var metadata in metadataList) { - AppendHeadingListItem(ref builder, heading); + AppendHeadingListItem(ref builder, metadata); } - builder.AppendLiteral($"
    {Environment.NewLine}"); - builder.AppendLiteral($"
{Environment.NewLine}"); + builder.AppendLiteral(" "); + builder.AppendLiteral(Environment.NewLine); + builder.AppendLiteral("
"); + builder.AppendLiteral(Environment.NewLine); } return builder.ToStringAndClear(); } - private static string GenerateCategorizedTocHtml(PullReqeustAnalayzer.AnalayzerResult analyzerResult) + private static string GenerateCategorizedTocHtml(PullRequestAnalyzer.AnalysisResults analyzerResult) { var builder = new DefaultInterpolatedStringHandler(0, 0); - builder.AppendLiteral($"

カテゴリ別PR一覧

{Environment.NewLine}"); - + builder.AppendLiteral($"

カテゴリ別PR一覧

"); + builder.AppendLiteral(Environment.NewLine); // Community PRs (expanded) - var communityCount = analyzerResult.CommunityPullRequestHeadingSpan.Length; - builder.AppendLiteral($"
{Environment.NewLine}"); - builder.AppendLiteral($" Community PRs ({communityCount} PRs){Environment.NewLine}"); - builder.AppendLiteral($"
    {Environment.NewLine}"); - foreach (var heading in analyzerResult.CommunityPullRequestHeadingSpan) + var communityCount = analyzerResult.CommunityPullRequestMetadataSpan.Length; + builder.AppendLiteral($"
    "); + builder.AppendLiteral(Environment.NewLine); + builder.AppendLiteral($" Community PRs ("); + builder.AppendFormatted(communityCount); + builder.AppendLiteral(" PRs)"); + builder.AppendFormatted(Environment.NewLine); + + builder.AppendLiteral($"
      "); + builder.AppendLiteral(Environment.NewLine); + + foreach (var heading in analyzerResult.CommunityPullRequestMetadataSpan) { AppendHeadingListItem(ref builder, heading); } - builder.AppendLiteral($"
    {Environment.NewLine}"); - builder.AppendLiteral($"
    {Environment.NewLine}"); + builder.AppendLiteral("
"); + builder.AppendLiteral(Environment.NewLine); + builder.AppendLiteral("
"); + builder.AppendLiteral(Environment.NewLine); // Bot PRs (collapsed) - var botCount = analyzerResult.BotPullRequestHeadings?.Count ?? 0; - builder.AppendLiteral($"
{Environment.NewLine}"); - builder.AppendLiteral($" Bot PRs ({botCount} PRs){Environment.NewLine}"); - builder.AppendLiteral($"
    {Environment.NewLine}"); - foreach (var heading in analyzerResult.BotPullRequestHeadings ?? []) + var botCount = analyzerResult.BotPullRequestMetadataSpan.Length; + builder.AppendLiteral("
    "); + builder.AppendLiteral(Environment.NewLine); + builder.AppendLiteral($" Bot PRs ("); + builder.AppendFormatted(botCount); + builder.AppendLiteral(" PRs)"); + builder.AppendLiteral(Environment.NewLine); + builder.AppendLiteral("
      "); + builder.AppendLiteral(Environment.NewLine); + foreach (var heading in analyzerResult.BotPullRequestMetadataSpan) { AppendHeadingListItem(ref builder, heading); } - builder.AppendLiteral($"
    {Environment.NewLine}"); - builder.AppendLiteral($"
    {Environment.NewLine}"); + builder.AppendLiteral("
"); + builder.AppendLiteral(Environment.NewLine); + builder.AppendLiteral("
"); + builder.AppendLiteral(Environment.NewLine); return builder.ToStringAndClear(); } - private static void AppendHeadingListItem(ref DefaultInterpolatedStringHandler builder, HeadingBlock heading) + private static void AppendHeadingListItem(ref DefaultInterpolatedStringHandler builder, PullRequestAnalyzer.Metadata metadata) { - var pullRequestNumber = ""; - var titleText = ""; - - var inline = heading.Inline?.FirstChild; - while (inline is not null) - { - if (inline is LinkInline linkInline) - { - var linkChild = linkInline.FirstChild; - while (linkChild is not null) - { - if (linkChild is LiteralInline lit) - { - pullRequestNumber = lit.Content.ToString(); - } - linkChild = linkChild.NextSibling; - } - } - else if (inline is LiteralInline literal) - { - titleText += literal.Content.ToString(); - - if (literal.NextSibling is LinkDelimiterInline linkDelimiterInline) - { - titleText += linkDelimiterInline.ToLiteral(); - foreach (var linkChild in linkDelimiterInline.OfType()) - { - titleText += linkChild.Content.ToString(); - } - } - } - else if (inline is CodeInline codeInline) - { - titleText += codeInline.Content; - } - inline = inline.NextSibling; - } - - var anchorId = pullRequestNumber.TrimStart('#'); - var displayText = $"{pullRequestNumber} {titleText.Trim()}"; - - builder.AppendLiteral($"
  • {System.Net.WebUtility.HtmlEncode(displayText)}
  • {Environment.NewLine}"); + var text = HtmlEncoder.Default.Encode(metadata.TitleText); + builder.AppendLiteral($"
  • "); + builder.AppendLiteral(text); + builder.AppendLiteral("
  • "); + builder.AppendLiteral(Environment.NewLine); } private static string GenerateTemplateHtml(string title, string subTitle, string content, bool includeViewScript = false) @@ -302,6 +313,7 @@ private static string GenerateTemplateHtml(string title, string subTitle, string + @@ -315,9 +327,16 @@ private static string GenerateTemplateHtml(string title, string subTitle, string
    diff --git a/src/PRDigest.NET/MarkdownOptions.cs b/src/PRDigest.NET/MarkdownOptions.cs new file mode 100644 index 0000000..0fc28fb --- /dev/null +++ b/src/PRDigest.NET/MarkdownOptions.cs @@ -0,0 +1,12 @@ +using Markdig; +using Markdig.Extensions.AutoIdentifiers; + +namespace PRDigest.NET; + +internal static class MarkdownOptions +{ + public static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() + .UseAutoIdentifiers(AutoIdentifierOptions.GitHub) + .UseAdvancedExtensions() + .Build(); +} diff --git a/src/PRDigest.NET/Program.cs b/src/PRDigest.NET/Program.cs index dfc5e46..eea061d 100644 --- a/src/PRDigest.NET/Program.cs +++ b/src/PRDigest.NET/Program.cs @@ -3,6 +3,8 @@ using Anthropic.Models.Messages; using Octokit; using PRDigest.NET; +using System.Globalization; +using System.Runtime.InteropServices; using System.Text; if (args.Length == 0) return; @@ -20,6 +22,9 @@ // convert all markdown files to HTML await CreateHtml(archivesDir, outputsDir); +// (Re)create RSS feed from archived markdown files +await CreateRss(archivesDir, outputsDir); + // end var endTime = TimeProvider.System.GetTimestamp(); Console.WriteLine($"Total elapsed time: {TimeProvider.System.GetElapsedTime(startTime, endTime).TotalSeconds} seconds."); @@ -68,7 +73,7 @@ async ValueTask SummarizeCurrentPullRequestAndCreate(string archivesDir, string if (string.IsNullOrEmpty(markdown)) return; // Save markdown and HTML files. - var html = HtmlGenereator.GenerateHtmlFromMarkdown($"{year}年{month}月{day}日", markdown); + var html = HtmlGenerator.GenerateHtmlFromMarkdown($"{year}年{month}月{day}日", markdown); var markdownTask = File.WriteAllTextAsync(Path.Combine(archivesDir, year, month, $"{day}.md"), markdown); var htmlTask = File.WriteAllTextAsync(Path.Combine(outputsDir, year, month, $"{day}.html"), html); await Task.WhenAll(markdownTask, htmlTask); @@ -260,6 +265,37 @@ async ValueTask SummarizePullRequestAsync(PullRequestInfo[] pullRequestI return $"{tableOfContentsBuilder}{separator}{markdownlBuilder}"; } +async ValueTask CreateRss(string archivesDir, string outputsDir) +{ + const int MaxDays = 3; + var comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); + + var items = new List<(string target, string markdownContent)>(MaxDays); + foreach (var yearDir in Directory.EnumerateDirectories(archivesDir).OrderDescending(comparer)) + { + var year = Path.GetFileName(yearDir); + + foreach (var monthDir in Directory.EnumerateDirectories(yearDir).OrderDescending(comparer)) + { + var month = Path.GetFileName(monthDir); + + foreach (var mdFilePath in Directory.EnumerateFiles(monthDir, "*.md").OrderDescending(comparer)) + { + if (items.Count >= MaxDays) goto END; + var day = Path.GetFileNameWithoutExtension(mdFilePath); + var markdown = await File.ReadAllTextAsync(mdFilePath); + items.Add(($"{year}/{month}/{day}", markdown)); + } + } + } + + if (items.Count == 0) return; + +END: + var rssContent = RssFeedGenerator.Generate(CollectionsMarshal.AsSpan(items)); + await File.WriteAllTextAsync(Path.Combine(outputsDir, $"feed.xml"), rssContent); +} + async ValueTask CreateHtml(string archivesDir, string outputsDir) { // set up archives directory @@ -274,7 +310,7 @@ async ValueTask CreateHtml(string archivesDir, string outputsDir) Directory.CreateDirectory(outputsDir); } - foreach (var yearDirs in Directory.GetDirectories(archivesDir)) + foreach (var yearDirs in Directory.EnumerateDirectories(archivesDir)) { var year = Path.GetFileName(yearDirs); if (!Directory.Exists(Path.Combine(outputsDir, year))) @@ -282,7 +318,7 @@ async ValueTask CreateHtml(string archivesDir, string outputsDir) Directory.CreateDirectory(Path.Combine(outputsDir, year)); } - foreach (var monthDirss in Directory.GetDirectories(yearDirs)) + foreach (var monthDirss in Directory.EnumerateDirectories(yearDirs)) { var month = Path.GetFileName(monthDirss); if (!Directory.Exists(Path.Combine(outputsDir, year, month))) @@ -290,20 +326,20 @@ async ValueTask CreateHtml(string archivesDir, string outputsDir) Directory.CreateDirectory(Path.Combine(outputsDir, year, month)); } - await Parallel.ForEachAsync(Directory.GetFiles(monthDirss, "*.md"), async (dayFiles, _) => + await Parallel.ForEachAsync(Directory.EnumerateFiles(monthDirss, "*.md"), async (dayFiles, _) => { var day = Path.GetFileNameWithoutExtension(dayFiles); var markdown = await File.ReadAllTextAsync(dayFiles); // ./yyyy/mm/dd.html - var html = HtmlGenereator.GenerateHtmlFromMarkdown($"{year}年{month}月{day}日", markdown); + var html = HtmlGenerator.GenerateHtmlFromMarkdown($"{year}年{month}月{day}日", markdown); await File.WriteAllTextAsync(Path.Combine(outputsDir, year, month, $"{day}.html"), html); }); } } // set up index.html - await File.WriteAllTextAsync(Path.Combine(outputsDir, "index.html"), HtmlGenereator.GenerateIndex(archivesDir, outputsDir)); + await File.WriteAllTextAsync(Path.Combine(outputsDir, "index.html"), HtmlGenerator.GenerateIndex(archivesDir, outputsDir)); } internal sealed class PullRequestInfo diff --git a/src/PRDigest.NET/PullReqeustAnalayzer.cs b/src/PRDigest.NET/PullReqeustAnalayzer.cs deleted file mode 100644 index 403e0bb..0000000 --- a/src/PRDigest.NET/PullReqeustAnalayzer.cs +++ /dev/null @@ -1,161 +0,0 @@ -using Markdig.Syntax; -using Markdig.Syntax.Inlines; -using System.Runtime.InteropServices; - -namespace PRDigest.NET; - -internal static class PullReqeustAnalayzer -{ - public static AnalayzerResult Analayze(MarkdownDocument document) - { - var tableOfContents = false; - var pullRequestTotalCount = 0; - var pullRequestCountForBot = 0; - HeadingBlock? nextPrNumber = null; - HashSet? prNumberTable = null; - Dictionary>? labelTable = new(); - Dictionary? labelColorMap = new(); - List botPullRequestHeadings = new(); - List communityPrHeadings = new(); - - foreach (var block in document) - { - if (tableOfContents && block is HeadingBlock headingBlock) - { - var link = headingBlock.Inline?.Descendants().FirstOrDefault()?.FirstChild; - if (prNumberTable!.TryGetValue(((link as LiteralInline)?.Content.ToString() ?? "Notfound"), out var prNumber)) - { - nextPrNumber = headingBlock; - } - } - else if (block is ListBlock listBlock) - { - if (tableOfContents && nextPrNumber is not null) - { - // pullRequestInfo is 4 items. - // 0: User - // 1: Created at - // 2: Merged at - // 3: Labels - var pullRequestInfo = listBlock.Descendants().ToArray(); - - var userBlock = pullRequestInfo[0]; - var user = userBlock?.Descendants().Skip(1).FirstOrDefault(); - - if (user is not null) - { - var userName = user.Content.ToString().Trim(); - - // check ..[bot].. or @Copilot to count bot PRs - if (userName.EndsWith("[bot]", StringComparison.OrdinalIgnoreCase) || - userName.IndexOf("@Copilot", StringComparison.OrdinalIgnoreCase) > -1) - { - pullRequestCountForBot++; - botPullRequestHeadings.Add(nextPrNumber); - } - else - { - communityPrHeadings.Add(nextPrNumber); - } - } - else - { - communityPrHeadings.Add(nextPrNumber); - } - - var labelBlock = pullRequestInfo[3]; - var labels = labelBlock?.Descendants().Where(l => { - var labelText = l.Content.ToString(); - return !string.IsNullOrWhiteSpace(labelText) && !labelText.Contains("ラベル"); - }); - - foreach (var label in labels ?? []) - { - ref var prList = ref CollectionsMarshal.GetValueRefOrAddDefault(labelTable, label.ToString(), out var _); - prList ??= new(); - prList.Add(nextPrNumber); - } - - // Extract label colors from HtmlInline spans - if (labelBlock is not null) - { - int backgroundColorLength = 17; // "background-color:".Length - foreach (var htmlInline in labelBlock.Descendants()) - { - var tag = htmlInline.Tag; - if (tag is null) continue; - - var tagSpan = tag.AsSpan(); - if (tagSpan.IndexOf("background-color") > -1) - { - var bgStart = tagSpan.IndexOf("background-color:", StringComparison.Ordinal); - if (bgStart < 0) continue; - - bgStart += backgroundColorLength; - var bgEnd = tagSpan.Slice(bgStart).IndexOf(';'); - if (bgEnd <= 0) continue; - - var color = tagSpan[bgStart..(bgStart + bgEnd)].Trim(); - // Find the label text: the next sibling LiteralInline - var nextSibling = htmlInline.NextSibling; - while (nextSibling is not null) - { - if (nextSibling is LiteralInline literal) - { - var labelName = literal.Content.ToString().Trim(); - if (!string.IsNullOrWhiteSpace(labelName) && !labelName.Contains("ラベル")) - { - labelColorMap.TryAdd(labelName, color.ToString()); - } - break; - } - nextSibling = nextSibling.NextSibling; - } - } - } - } - - nextPrNumber = null; - } - else if (!tableOfContents) - { - foreach (var listItemBlock in listBlock.Descendants()) - { - pullRequestTotalCount++; - var prNumber = listItemBlock.Descendants().FirstOrDefault(); - if (prNumber is not null) - { - prNumberTable ??= new HashSet(); - prNumberTable.Add(prNumber?.Url?.Trim() ?? ""); - } - } - tableOfContents = true; - } - } - } - - - return new AnalayzerResult - { - PullRequestTotalCount = pullRequestTotalCount, - PullRequestCountForBot = pullRequestCountForBot, - LabelInfo = labelTable, - LabelColorMap = labelColorMap, - BotPullRequestHeadings = botPullRequestHeadings, - CommunityPullRequestHeadings = communityPrHeadings - }; - } - - public ref struct AnalayzerResult - { - public int PullRequestTotalCount; - public int PullRequestCountForBot; - public Dictionary>? LabelInfo; - public Dictionary? LabelColorMap; - public List? BotPullRequestHeadings; - public List CommunityPullRequestHeadings; - - public readonly int LabelCount => LabelInfo?.Count ?? 0; - public ReadOnlySpan CommunityPullRequestHeadingSpan => CollectionsMarshal.AsSpan(CommunityPullRequestHeadings); - } -} \ No newline at end of file diff --git a/src/PRDigest.NET/PullRequestAnalyzer.cs b/src/PRDigest.NET/PullRequestAnalyzer.cs new file mode 100644 index 0000000..62e567a --- /dev/null +++ b/src/PRDigest.NET/PullRequestAnalyzer.cs @@ -0,0 +1,302 @@ +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using System.Collections.Frozen; +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace PRDigest.NET; + +internal static class PullRequestAnalyzer +{ + private enum PullRequestPosition + { + None, + Title, + Metadata, + Overview, + FileChanged, + Performance, + RelatedIssue, + Other, + Unknown + } + + public static AnalysisResults Analyze(MarkdownDocument document) + { + var currentPosition = PullRequestPosition.None; + var tableOfContents = false; + var pullRequestTotalCount = 0; + var pullRequestCountForBot = 0; + HeadingBlock? nextPullRequestNumber = null; + HashSet? pullRequestNumberTable = null; + Dictionary> labelTable = []; + Dictionary labelColorMap = []; + List botPullRequestHeadings = []; + List communityPrHeadings = []; + Metadata currentMetadata = default; + Dictionary pullRequestInfoTable = []; + + foreach (var block in document) + { + if (block is HeadingBlock headingBlock) + { + if (tableOfContents && currentPosition != PullRequestPosition.Metadata) + { + var link = headingBlock.Inline?.Descendants().FirstOrDefault()?.FirstChild; + if (pullRequestNumberTable!.TryGetValue(((link as LiteralInline)?.Content.ToString() ?? "Notfound"), out var prNumber)) + { + nextPullRequestNumber = headingBlock; + } + } + else if (currentPosition == PullRequestPosition.Metadata) + { + var content = headingBlock.Inline?.Descendants().FirstOrDefault()?.Content.ToString() ?? ""; + if (content == "概要") + { + currentPosition = PullRequestPosition.Overview; + } + else + { + currentPosition = PullRequestPosition.Unknown; + } + } + } + else if (block is ListBlock listBlock) + { + if (tableOfContents && nextPullRequestNumber is not null) + { + // metadataList is 4 items. + // 0: User + // 1: Created at + // 2: Merged at + // 3: Labels + currentPosition = PullRequestPosition.Metadata; + var metadataList = listBlock.Descendants().ToArray(); + if (metadataList.Length < 4) throw new FormatException($"Expected metadata list length to be at least 4, but got {metadataList.Length}."); + + var labelBlock = metadataList[3]; + // Extract label colors from HtmlInline spans + if (labelBlock is not null) + { + int backgroundColorLength = 17; // "background-color:".Length + foreach (var htmlInline in labelBlock.Descendants()) + { + var tag = htmlInline.Tag; + if (tag is null) continue; + + var tagSpan = tag.AsSpan(); + if (tagSpan.IndexOf("background-color") > -1) + { + var bgStart = tagSpan.IndexOf("background-color:", StringComparison.Ordinal); + if (bgStart < 0) continue; + + bgStart += backgroundColorLength; + var bgEnd = tagSpan.Slice(bgStart).IndexOf(';'); + if (bgEnd <= 0) continue; + + var color = tagSpan[bgStart..(bgStart + bgEnd)].Trim(); + // Find the label text: the next sibling LiteralInline + var nextSibling = htmlInline.NextSibling; + while (nextSibling is not null) + { + if (nextSibling is LiteralInline literal) + { + var labelName = literal.Content.ToString().Trim(); + if (!string.IsNullOrWhiteSpace(labelName) && !labelName.Contains("ラベル")) + { + labelColorMap.TryAdd(labelName, color.ToString()); + } + break; + } + nextSibling = nextSibling.NextSibling; + } + } + } + } + + var labels = labelBlock?.Descendants().Where(l => + { + var labelText = l.Content.ToString(); + return !string.IsNullOrWhiteSpace(labelText) && !labelText.Contains("ラベル"); + }); + + currentMetadata = GetMetadata(nextPullRequestNumber, labels); + + foreach (var label in labels ?? []) + { + ref var prList = ref CollectionsMarshal.GetValueRefOrAddDefault(labelTable, label.ToString(), out var _); + prList ??= new List(1); + prList.Add(currentMetadata); + } + + var user = metadataList[0]?.Descendants().Skip(1).FirstOrDefault(); + if (user is not null) + { + var userName = user.Content.ToString().Trim(); + + // check ..[bot].. or @Copilot to count bot PRs + if (userName.EndsWith("[bot]", StringComparison.OrdinalIgnoreCase) || + userName.IndexOf("@Copilot", StringComparison.OrdinalIgnoreCase) > -1) + { + pullRequestCountForBot++; + botPullRequestHeadings.Add(currentMetadata); + } + else + { + communityPrHeadings.Add(currentMetadata); + } + } + else + { + communityPrHeadings.Add(currentMetadata); + } + + nextPullRequestNumber = null; + } + else if (!tableOfContents) + { + foreach (var listItemBlock in listBlock.Descendants()) + { + pullRequestTotalCount++; + var prNumber = listItemBlock.Descendants().FirstOrDefault(); + if (prNumber is not null) + { + pullRequestNumberTable ??= new HashSet(); + pullRequestNumberTable.Add(prNumber?.Url?.Trim() ?? ""); + } + } + tableOfContents = true; + } + } + else if (block is ParagraphBlock paragraphBlock) + { + if (currentPosition == PullRequestPosition.Overview) + { + var overviewText = GetOverview(paragraphBlock.Inline); + pullRequestInfoTable.TryAdd(currentMetadata.PullRequestNumber, new Summary(overviewText)); + } + currentPosition = PullRequestPosition.None; + } + } + + return new AnalysisResults( + pullRequestTotalCount, + pullRequestCountForBot, + labelTable.ToFrozenDictionary(kvp => kvp.Key, kvp => kvp.Value.ToImmutableArray()), + labelColorMap.ToFrozenDictionary(), + botPullRequestHeadings, + communityPrHeadings, + pullRequestInfoTable.ToFrozenDictionary()); + } + + private static Metadata GetMetadata(HeadingBlock heading, IEnumerable? labels) + { + var pullRequestNumber = ""; + var titleText = ""; + + var inline = heading.Inline?.FirstChild; + while (inline is not null) + { + if (inline is LinkInline linkInline) + { + var linkChild = linkInline.FirstChild; + while (linkChild is not null) + { + if (linkChild is LiteralInline lit) + { + pullRequestNumber = lit.Content.ToString(); + } + linkChild = linkChild.NextSibling; + } + } + else if (inline is LiteralInline literal) + { + titleText += literal.Content.ToString(); + + if (literal.NextSibling is LinkDelimiterInline linkDelimiterInline) + { + titleText += linkDelimiterInline.ToLiteral(); + foreach (var linkChild in linkDelimiterInline.OfType()) + { + titleText += linkChild.Content.ToString(); + } + } + } + else if (inline is CodeInline codeInline) + { + titleText += codeInline.Content; + } + inline = inline.NextSibling; + } + + var anchorId = pullRequestNumber.TrimStart('#'); + var displayText = $"{pullRequestNumber} {titleText.Trim()}"; + + return new Metadata(anchorId, displayText, labels?.Select(l => l.ToString()).ToImmutableArray() ?? ImmutableArray.Empty); + } + + private static string GetOverview(ContainerInline? inline) + { + if (inline is null) return ""; + + var builder = new DefaultInterpolatedStringHandler(0, 0); + var child = inline.FirstChild; + while (child is not null) + { + switch (child) + { + case LiteralInline literal: + builder.AppendLiteral(literal.Content.ToString()); + break; + case CodeInline codeInline: + builder.AppendLiteral(codeInline.Content); + break; + case LineBreakInline: + builder.AppendLiteral("\n"); + break; + case LinkInline linkInline: + builder.AppendLiteral(GetOverview(linkInline)); + break; + case EmphasisInline emphasisInline: + builder.AppendLiteral(GetOverview(emphasisInline)); + break; + } + child = child.NextSibling; + } + return builder.ToStringAndClear(); + } + + public sealed class AnalysisResults( + int pullRequestTotalCount, + int pullRequestCountForBot, + FrozenDictionary> labelMap, + FrozenDictionary labelColorMap, + List botPullRequestMetadata, + List communityPullRequestMetadata, + FrozenDictionary summaryMap) + { + public int PullRequestTotalCount => pullRequestTotalCount; + public int PullRequestCountForBot => pullRequestCountForBot; + public FrozenDictionary> LabelMap => labelMap; + public FrozenDictionary LabelColorGroups => labelColorMap; + public int LabelCount => LabelMap.Count; + public ReadOnlySpan CommunityPullRequestMetadataSpan => CollectionsMarshal.AsSpan(communityPullRequestMetadata); + public ReadOnlySpan BotPullRequestMetadataSpan => CollectionsMarshal.AsSpan(botPullRequestMetadata); + public FrozenDictionary SummaryMap => summaryMap; + } + + public readonly struct Summary(string overview) + { + public string Overview => overview; + } + + public readonly struct Metadata(string pullRequestNumber, string titleText, ImmutableArray labels) + { + public string PullRequestNumber => pullRequestNumber; + + public string TitleText => titleText; + + public ImmutableArray Labels => labels; + } +} \ No newline at end of file diff --git a/src/PRDigest.NET/RssFeedGenerator.cs b/src/PRDigest.NET/RssFeedGenerator.cs new file mode 100644 index 0000000..2fe9414 --- /dev/null +++ b/src/PRDigest.NET/RssFeedGenerator.cs @@ -0,0 +1,112 @@ +using Markdig; +using System.Collections.Frozen; +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace PRDigest.NET; + +internal static class RssFeedGenerator +{ + public static string Generate(ReadOnlySpan<(string target, string markdownContent)> items) + { + var itemBuilder = new DefaultInterpolatedStringHandler(0, 0); + foreach(var (target, markdownContent) in items) + { + var document = Markdown.Parse(markdownContent, MarkdownOptions.Pipeline); + var analyzerResult = PullRequestAnalyzer.Analyze(document); + AppendItems(ref itemBuilder, target, analyzerResult); + } + var itemsText = itemBuilder.ToStringAndClear(); + + var rss = $""" + + + + PR Digest.NET + https://prozolic.github.io/PRDigest.NET/ + dotnet/runtimeにマージされたPull RequestをAIで日本語要約 + {TimeProvider.System.GetUtcNow():R} + + ja + + https://prozolic.github.io/PRDigest.NET/icon-512.png + PR Digest.NET + https://prozolic.github.io/PRDigest.NET/ + + Copyright © 2025 prozolic + {itemsText} + + + """; + + return rss; + } + + private static void AppendItems(ref DefaultInterpolatedStringHandler itemBuilder, string target, PullRequestAnalyzer.AnalysisResults analysisResult) + { + itemBuilder.AppendLiteral(Environment.NewLine); + foreach (var metadata in analysisResult.CommunityPullRequestMetadataSpan) + { + AppendItem(ref itemBuilder, target, analysisResult.SummaryMap, metadata); + } + foreach (var metadata in analysisResult.BotPullRequestMetadataSpan) + { + AppendItem(ref itemBuilder, target, analysisResult.SummaryMap, metadata); + } + + static void AppendItem(ref DefaultInterpolatedStringHandler builder, string target, FrozenDictionary summaryGroups, PullRequestAnalyzer.Metadata metadata) + { + // Rss feed item format: + // + // + // + // + // + // + // + + builder.AppendLiteral(" "); + builder.AppendLiteral(Environment.NewLine); + + var header = metadata; + builder.AppendLiteral(" <![CDATA[ "); + builder.AppendLiteral(header.TitleText); + builder.AppendLiteral(" ]]>"); + builder.AppendLiteral(Environment.NewLine); + + builder.AppendLiteral(" "); + builder.AppendLiteral($"https://prozolic.github.io/PRDigest.NET/"); + builder.AppendLiteral(target); + builder.AppendLiteral(".html"); + builder.AppendLiteral("#"); + builder.AppendLiteral(header.PullRequestNumber); + builder.AppendLiteral(""); + builder.AppendLiteral(Environment.NewLine); + + builder.AppendLiteral(" "); + builder.AppendLiteral("https://github.com/dotnet/runtime/pull/"); + builder.AppendLiteral(header.PullRequestNumber); + builder.AppendLiteral(""); + builder.AppendLiteral(Environment.NewLine); + + if (DateTimeOffset.TryParseExact(target, "yyyy/MM/dd".AsSpan(), CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal, out var date)) + { + builder.AppendLiteral(" "); + builder.AppendFormatted(date, format: "R"); + builder.AppendLiteral(""); + builder.AppendLiteral(Environment.NewLine); + } + + if (summaryGroups.TryGetValue(metadata.PullRequestNumber, out var info)) + { + builder.AppendLiteral(" "); + builder.AppendLiteral(Environment.NewLine); + } + + builder.AppendLiteral(" "); + builder.AppendLiteral(Environment.NewLine); + } + } +} \ No newline at end of file