From 99e978b04aa7ad53cf1f6779e9a0105426fe980b Mon Sep 17 00:00:00 2001 From: prozolic <42107886+prozolic@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:57:17 +0900 Subject: [PATCH 1/7] rename file --- src/PRDigest.NET/{HtmlGenereator.cs => HtmlGenerator.cs} | 0 .../{PullReqeustAnalayzer.cs => PullReqeustAnalyzer.cs} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/PRDigest.NET/{HtmlGenereator.cs => HtmlGenerator.cs} (100%) rename src/PRDigest.NET/{PullReqeustAnalayzer.cs => PullReqeustAnalyzer.cs} (100%) diff --git a/src/PRDigest.NET/HtmlGenereator.cs b/src/PRDigest.NET/HtmlGenerator.cs similarity index 100% rename from src/PRDigest.NET/HtmlGenereator.cs rename to src/PRDigest.NET/HtmlGenerator.cs diff --git a/src/PRDigest.NET/PullReqeustAnalayzer.cs b/src/PRDigest.NET/PullReqeustAnalyzer.cs similarity index 100% rename from src/PRDigest.NET/PullReqeustAnalayzer.cs rename to src/PRDigest.NET/PullReqeustAnalyzer.cs From 37ed4df044f8548d83057065274c3dbef60006ac Mon Sep 17 00:00:00 2001 From: prozolic <42107886+prozolic@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:58:56 +0900 Subject: [PATCH 2/7] Add rss feed --- src/PRDigest.NET/HtmlGenerator.cs | 198 ++++++++-------- src/PRDigest.NET/MarkdownOptions.cs | 12 + src/PRDigest.NET/Program.cs | 31 ++- src/PRDigest.NET/PullReqeustAnalyzer.cs | 287 ++++++++++++++++++------ src/PRDigest.NET/RssFeedGenerator.cs | 111 +++++++++ 5 files changed, 470 insertions(+), 169 deletions(-) create mode 100644 src/PRDigest.NET/MarkdownOptions.cs create mode 100644 src/PRDigest.NET/RssFeedGenerator.cs diff --git a/src/PRDigest.NET/HtmlGenerator.cs b/src/PRDigest.NET/HtmlGenerator.cs index 9420af3..a9199cb 100644 --- a/src/PRDigest.NET/HtmlGenerator.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 + 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..df61ffd 100644 --- a/src/PRDigest.NET/Program.cs +++ b/src/PRDigest.NET/Program.cs @@ -3,6 +3,7 @@ using Anthropic.Models.Messages; using Octokit; using PRDigest.NET; +using System.Globalization; using System.Text; if (args.Length == 0) return; @@ -20,6 +21,9 @@ // convert all markdown files to HTML await CreateHtml(archivesDir, outputsDir); +// create RSS feed for the first time if it doesn't exist +await CreateRss(archivesDir, outputsDir); + // end var endTime = TimeProvider.System.GetTimestamp(); Console.WriteLine($"Total elapsed time: {TimeProvider.System.GetElapsedTime(startTime, endTime).TotalSeconds} seconds."); @@ -68,7 +72,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 +264,27 @@ async ValueTask SummarizePullRequestAsync(PullRequestInfo[] pullRequestI return $"{tableOfContentsBuilder}{separator}{markdownlBuilder}"; } +async ValueTask CreateRss(string archivesDir, string outputsDir) +{ + var comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); + var yearDir = Directory.EnumerateDirectories(archivesDir).OrderDescending(comparer).FirstOrDefault(); + if (yearDir == null) return; + + var monthDir = Directory.EnumerateDirectories(yearDir).OrderDescending(comparer).FirstOrDefault(); + if (monthDir == null) return; + + var mdFilePath = Directory.EnumerateFiles(monthDir).OrderDescending(comparer).FirstOrDefault(); + if (mdFilePath == null) return; + + var markdown = await File.ReadAllTextAsync(mdFilePath); + var year = Path.GetFileName(yearDir); + var month = Path.GetFileName(monthDir); + var day = Path.GetFileNameWithoutExtension(mdFilePath); + + var rssContent = RssFeedGenerator.Generate($"{year}/{month}/{day}", markdown); + await File.WriteAllTextAsync(Path.Combine(outputsDir, $"feed.xml"), rssContent); +} + async ValueTask CreateHtml(string archivesDir, string outputsDir) { // set up archives directory @@ -296,14 +321,14 @@ await Parallel.ForEachAsync(Directory.GetFiles(monthDirss, "*.md"), async (dayFi 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/PullReqeustAnalyzer.cs b/src/PRDigest.NET/PullReqeustAnalyzer.cs index 403e0bb..62e567a 100644 --- a/src/PRDigest.NET/PullReqeustAnalyzer.cs +++ b/src/PRDigest.NET/PullReqeustAnalyzer.cs @@ -1,81 +1,81 @@ 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 PullReqeustAnalayzer +internal static class PullRequestAnalyzer { - public static AnalayzerResult Analayze(MarkdownDocument document) + 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? nextPrNumber = null; - HashSet? prNumberTable = null; - Dictionary>? labelTable = new(); - Dictionary? labelColorMap = new(); - List botPullRequestHeadings = new(); - List communityPrHeadings = new(); + 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 (tableOfContents && block is HeadingBlock headingBlock) + if (block is HeadingBlock headingBlock) { - var link = headingBlock.Inline?.Descendants().FirstOrDefault()?.FirstChild; - if (prNumberTable!.TryGetValue(((link as LiteralInline)?.Content.ToString() ?? "Notfound"), out var prNumber)) + if (tableOfContents && currentPosition != PullRequestPosition.Metadata) { - nextPrNumber = headingBlock; + 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 && nextPrNumber is not null) + if (tableOfContents && nextPullRequestNumber is not null) { - // pullRequestInfo is 4 items. + // metadataList 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); - } + 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) { @@ -115,7 +115,44 @@ public static AnalayzerResult Analayze(MarkdownDocument document) } } - nextPrNumber = null; + 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) { @@ -125,37 +162,141 @@ public static AnalayzerResult Analayze(MarkdownDocument document) var prNumber = listItemBlock.Descendants().FirstOrDefault(); if (prNumber is not null) { - prNumberTable ??= new HashSet(); - prNumberTable.Add(prNumber?.Url?.Trim() ?? ""); + 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 ""; - return new AnalayzerResult + var builder = new DefaultInterpolatedStringHandler(0, 0); + var child = inline.FirstChild; + while (child is not null) { - PullRequestTotalCount = pullRequestTotalCount, - PullRequestCountForBot = pullRequestCountForBot, - LabelInfo = labelTable, - LabelColorMap = labelColorMap, - BotPullRequestHeadings = botPullRequestHeadings, - CommunityPullRequestHeadings = communityPrHeadings - }; + 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 ref struct AnalayzerResult + public sealed class AnalysisResults( + int pullRequestTotalCount, + int pullRequestCountForBot, + FrozenDictionary> labelMap, + FrozenDictionary labelColorMap, + List botPullRequestMetadata, + List communityPullRequestMetadata, + FrozenDictionary summaryMap) { - 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); + 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..6d3298b --- /dev/null +++ b/src/PRDigest.NET/RssFeedGenerator.cs @@ -0,0 +1,111 @@ +using Markdig; +using System.Collections.Frozen; +using System.Runtime.CompilerServices; +using System.Text.Encodings.Web; +using static PRDigest.NET.PullRequestAnalyzer; + +namespace PRDigest.NET; + +internal static class RssFeedGenerator +{ + public static string Generate(string target, string markdownContent) + { + var document = Markdown.Parse(markdownContent, MarkdownOptions.Pipeline); + var analyzerResult = PullRequestAnalyzer.Analyze(document); + + 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 + {GenerateItems(target, analyzerResult)} + + + """; + + return rss; + } + + private static string GenerateItems(string target, PullRequestAnalyzer.AnalysisResults analysisResult) + { + var itemBuilder = new DefaultInterpolatedStringHandler(0, 0); + 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); + } + + return itemBuilder.ToStringAndClear(); + + + 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(HtmlEncoder.Default.Encode(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", null, 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 From 6c896f20184af55bd1d85dcdf1aa0758b6e87862 Mon Sep 17 00:00:00 2001 From: prozolic <42107886+prozolic@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:17:36 +0900 Subject: [PATCH 3/7] fix --- src/PRDigest.NET/RssFeedGenerator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PRDigest.NET/RssFeedGenerator.cs b/src/PRDigest.NET/RssFeedGenerator.cs index 6d3298b..1f955cf 100644 --- a/src/PRDigest.NET/RssFeedGenerator.cs +++ b/src/PRDigest.NET/RssFeedGenerator.cs @@ -69,7 +69,7 @@ static void AppendItem(ref DefaultInterpolatedStringHandler builder, string targ var header = metadata; builder.AppendLiteral(" <![CDATA[ "); - builder.AppendLiteral(HtmlEncoder.Default.Encode(header.TitleText)); + builder.AppendLiteral(header.TitleText); builder.AppendLiteral(" ]]>"); builder.AppendLiteral(Environment.NewLine); @@ -88,7 +88,7 @@ static void AppendItem(ref DefaultInterpolatedStringHandler builder, string targ builder.AppendLiteral(""); builder.AppendLiteral(Environment.NewLine); - if (DateTimeOffset.TryParseExact(target, "yyyy/MM/dd", null, System.Globalization.DateTimeStyles.AssumeUniversal, out var date)) + if (DateTimeOffset.TryParseExact(target, "yyyy/MM/dd".AsSpan(), null, System.Globalization.DateTimeStyles.AssumeUniversal, out var date)) { builder.AppendLiteral(" "); builder.AppendFormatted(date, format: "R"); From 0b2298a3e5324a93350d0151b06ccf6616a4e432 Mon Sep 17 00:00:00 2001 From: prozolic <42107886+prozolic@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:30:52 +0900 Subject: [PATCH 4/7] Update --- src/PRDigest.NET/Program.cs | 39 ++++++++++++++++++---------- src/PRDigest.NET/RssFeedGenerator.cs | 24 ++++++++--------- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/src/PRDigest.NET/Program.cs b/src/PRDigest.NET/Program.cs index df61ffd..add8a10 100644 --- a/src/PRDigest.NET/Program.cs +++ b/src/PRDigest.NET/Program.cs @@ -4,6 +4,7 @@ using Octokit; using PRDigest.NET; using System.Globalization; +using System.Runtime.InteropServices; using System.Text; if (args.Length == 0) return; @@ -266,22 +267,32 @@ async ValueTask SummarizePullRequestAsync(PullRequestInfo[] pullRequestI async ValueTask CreateRss(string archivesDir, string outputsDir) { + const int MaxDays = 3; var comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); - var yearDir = Directory.EnumerateDirectories(archivesDir).OrderDescending(comparer).FirstOrDefault(); - if (yearDir == null) return; - var monthDir = Directory.EnumerateDirectories(yearDir).OrderDescending(comparer).FirstOrDefault(); - if (monthDir == null) return; + 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); - var mdFilePath = Directory.EnumerateFiles(monthDir).OrderDescending(comparer).FirstOrDefault(); - if (mdFilePath == null) return; + 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)); + } + } + } - var markdown = await File.ReadAllTextAsync(mdFilePath); - var year = Path.GetFileName(yearDir); - var month = Path.GetFileName(monthDir); - var day = Path.GetFileNameWithoutExtension(mdFilePath); + if (items.Count == 0) return; - var rssContent = RssFeedGenerator.Generate($"{year}/{month}/{day}", markdown); +END: + var rssContent = RssFeedGenerator.Generate(CollectionsMarshal.AsSpan(items)); await File.WriteAllTextAsync(Path.Combine(outputsDir, $"feed.xml"), rssContent); } @@ -299,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))) @@ -307,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))) @@ -315,7 +326,7 @@ 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); diff --git a/src/PRDigest.NET/RssFeedGenerator.cs b/src/PRDigest.NET/RssFeedGenerator.cs index 1f955cf..c203312 100644 --- a/src/PRDigest.NET/RssFeedGenerator.cs +++ b/src/PRDigest.NET/RssFeedGenerator.cs @@ -1,17 +1,21 @@ using Markdig; using System.Collections.Frozen; using System.Runtime.CompilerServices; -using System.Text.Encodings.Web; -using static PRDigest.NET.PullRequestAnalyzer; namespace PRDigest.NET; internal static class RssFeedGenerator { - public static string Generate(string target, string markdownContent) + public static string Generate(ReadOnlySpan<(string target, string markdownContent)> items) { - var document = Markdown.Parse(markdownContent, MarkdownOptions.Pipeline); - var analyzerResult = PullRequestAnalyzer.Analyze(document); + 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 = $""" @@ -29,7 +33,7 @@ public static string Generate(string target, string markdownContent) https://prozolic.github.io/PRDigest.NET/ Copyright © 2025 prozolic - {GenerateItems(target, analyzerResult)} + {itemsText} """; @@ -37,9 +41,8 @@ public static string Generate(string target, string markdownContent) return rss; } - private static string GenerateItems(string target, PullRequestAnalyzer.AnalysisResults analysisResult) + private static void AppendItems(ref DefaultInterpolatedStringHandler itemBuilder, string target, PullRequestAnalyzer.AnalysisResults analysisResult) { - var itemBuilder = new DefaultInterpolatedStringHandler(0, 0); itemBuilder.AppendLiteral(Environment.NewLine); foreach (var metadata in analysisResult.CommunityPullRequestMetadataSpan) { @@ -50,10 +53,7 @@ private static string GenerateItems(string target, PullRequestAnalyzer.AnalysisR AppendItem(ref itemBuilder, target, analysisResult.SummaryMap, metadata); } - return itemBuilder.ToStringAndClear(); - - - static void AppendItem(ref DefaultInterpolatedStringHandler builder, string target, FrozenDictionary summaryGroups, PullRequestAnalyzer.Metadata metadata) + static void AppendItem(ref DefaultInterpolatedStringHandler builder, string target, FrozenDictionary summaryGroups, PullRequestAnalyzer.Metadata metadata) { // Rss feed item format: // From cd181a7fc54b39fad1ad9882e23165f97e8b6dd0 Mon Sep 17 00:00:00 2001 From: prozolic <42107886+prozolic@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:43:36 +0900 Subject: [PATCH 5/7] Add license notice in READMD.md --- README.md | 16 ++++++++++++++++ src/PRDigest.NET/HtmlGenerator.cs | 13 ++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) 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/HtmlGenerator.cs b/src/PRDigest.NET/HtmlGenerator.cs index a9199cb..8ad1682 100644 --- a/src/PRDigest.NET/HtmlGenerator.cs +++ b/src/PRDigest.NET/HtmlGenerator.cs @@ -327,9 +327,16 @@ private static string GenerateTemplateHtml(string title, string subTitle, string
    From 16834e8380e9e5adf0b4e36447565cb687c59f42 Mon Sep 17 00:00:00 2001 From: prozolic <42107886+prozolic@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:56:55 +0900 Subject: [PATCH 6/7] Update src/PRDigest.NET/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/PRDigest.NET/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PRDigest.NET/Program.cs b/src/PRDigest.NET/Program.cs index add8a10..eea061d 100644 --- a/src/PRDigest.NET/Program.cs +++ b/src/PRDigest.NET/Program.cs @@ -22,7 +22,7 @@ // convert all markdown files to HTML await CreateHtml(archivesDir, outputsDir); -// create RSS feed for the first time if it doesn't exist +// (Re)create RSS feed from archived markdown files await CreateRss(archivesDir, outputsDir); // end From b971d0ad3e8fd4125996ad7231a9ac26ea72fc49 Mon Sep 17 00:00:00 2001 From: prozolic <42107886+prozolic@users.noreply.github.com> Date: Mon, 23 Feb 2026 01:06:55 +0900 Subject: [PATCH 7/7] Fix --- .../{PullReqeustAnalyzer.cs => PullRequestAnalyzer.cs} | 0 src/PRDigest.NET/RssFeedGenerator.cs | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) rename src/PRDigest.NET/{PullReqeustAnalyzer.cs => PullRequestAnalyzer.cs} (100%) diff --git a/src/PRDigest.NET/PullReqeustAnalyzer.cs b/src/PRDigest.NET/PullRequestAnalyzer.cs similarity index 100% rename from src/PRDigest.NET/PullReqeustAnalyzer.cs rename to src/PRDigest.NET/PullRequestAnalyzer.cs diff --git a/src/PRDigest.NET/RssFeedGenerator.cs b/src/PRDigest.NET/RssFeedGenerator.cs index c203312..2fe9414 100644 --- a/src/PRDigest.NET/RssFeedGenerator.cs +++ b/src/PRDigest.NET/RssFeedGenerator.cs @@ -1,5 +1,6 @@ using Markdig; using System.Collections.Frozen; +using System.Globalization; using System.Runtime.CompilerServices; namespace PRDigest.NET; @@ -88,7 +89,7 @@ static void AppendItem(ref DefaultInterpolatedStringHandler builder, string targ builder.AppendLiteral(""); builder.AppendLiteral(Environment.NewLine); - if (DateTimeOffset.TryParseExact(target, "yyyy/MM/dd".AsSpan(), null, System.Globalization.DateTimeStyles.AssumeUniversal, out var date)) + if (DateTimeOffset.TryParseExact(target, "yyyy/MM/dd".AsSpan(), CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeUniversal, out var date)) { builder.AppendLiteral(" "); builder.AppendFormatted(date, format: "R");