From d5be3c0337cb6a2afa14d9c03501499ff58e626f Mon Sep 17 00:00:00 2001 From: Nigusu Yenework Date: Thu, 2 Apr 2026 19:21:05 -0700 Subject: [PATCH 1/6] Add PR health tool --- NuGetClientPRHealth/DashboardService.cs | 80 ++++++++ NuGetClientPRHealth/GitHubClient.cs | 186 ++++++++++++++++++ NuGetClientPRHealth/HtmlGenerator.cs | 87 ++++++++ NuGetClientPRHealth/Models.cs | 26 +++ .../NuGetClientPRHealth.csproj | 10 + NuGetClientPRHealth/Program.cs | 51 +++++ NuGetClientPRHealth/README.md | 29 +++ 7 files changed, 469 insertions(+) create mode 100644 NuGetClientPRHealth/DashboardService.cs create mode 100644 NuGetClientPRHealth/GitHubClient.cs create mode 100644 NuGetClientPRHealth/HtmlGenerator.cs create mode 100644 NuGetClientPRHealth/Models.cs create mode 100644 NuGetClientPRHealth/NuGetClientPRHealth.csproj create mode 100644 NuGetClientPRHealth/Program.cs create mode 100644 NuGetClientPRHealth/README.md diff --git a/NuGetClientPRHealth/DashboardService.cs b/NuGetClientPRHealth/DashboardService.cs new file mode 100644 index 0000000..b5d67c0 --- /dev/null +++ b/NuGetClientPRHealth/DashboardService.cs @@ -0,0 +1,80 @@ +namespace NuGetDashboard; + +public class DashboardService(GitHubClient client, int windowDays = 14) +{ + private static readonly HashSet TeamMembers = new(StringComparer.OrdinalIgnoreCase) + { + "nkolev92", "zivkan", "jeffkl", "donnie-msft", "kartheekp-ms", + "martinrrm", "jebriede", "Nigusu-Allehu", "aortiz-msft" + }; + + public async Task BuildDashboardAsync() + { + var now = DateTime.UtcNow; + var windowAgo = now.AddDays(-windowDays); + + Console.Write($" Fetching PRs merged in the past {windowDays} days... "); + var rawPRs = (await client.SearchMergedPRsAsync(windowAgo, now)) + .Where(p => TeamMembers.Contains(p.Author)) + .ToList(); + Console.WriteLine($"{rawPRs.Count} found."); + + var callsNeeded = rawPRs.Count * 2; // timeline + reviews per PR + var (coreRemaining, coreLimit) = await client.GetCoreRateLimitAsync(); + Console.WriteLine($" Core API budget: {coreRemaining}/{coreLimit} remaining, {callsNeeded} needed."); + if (coreRemaining < callsNeeded) + throw new InvalidOperationException( + $"GitHub core API rate limit too low: {coreRemaining} remaining, {callsNeeded} needed.\n" + + $" → Create a free classic token at github.com/settings/tokens/new?type=classic (no scopes needed)"); + + Console.Write($" Enriching {rawPRs.Count} PRs... "); + var prs = await EnrichAsync(rawPRs); + Console.WriteLine("done."); + + return new DashboardData( + DateRange: $"{windowAgo:MMM d} \u2013 {now:MMM d, yyyy}", + AsOf: now.ToString("MMMM d, yyyy") + " UTC", + WindowDays: windowDays, + Metrics: ComputeMetrics(prs), + SlowPRs: prs.Where(p => p.HoursToMerge > 72).OrderByDescending(p => p.HoursToMerge).ToList(), + AllPRs: prs.OrderBy(p => p.MergedAt).ToList()); + } + + private async Task> EnrichAsync(List prs) + { + var results = new List(); + foreach (var raw in prs) + { + var readyAt = await client.GetReadyTimeAsync(raw.Number); + var effectiveStart = readyAt ?? raw.CreatedAt; + var approvedAt = await client.GetFirstApprovalAtAsync(raw.Number); + + results.Add(new PRRecord( + raw.Number, raw.Title, raw.Url, raw.Author, + raw.CreatedAt, effectiveStart, raw.MergedAt, + HoursToMerge: Math.Max(0, (raw.MergedAt - effectiveStart).TotalHours), + FirstApprovalHours: approvedAt.HasValue ? (approvedAt.Value - effectiveStart).TotalHours : null, + FirstApprovedAt: approvedAt)); + + await Task.Delay(200); // avoid GitHub secondary rate limits + } + return results; + } + + private static DashboardMetrics ComputeMetrics(List prs) + { + if (prs.Count == 0) return new DashboardMetrics(0, 0, 0, 0); + + var sorted = prs.Select(p => p.HoursToMerge).OrderBy(x => x).ToList(); + var n = sorted.Count; + var median = n % 2 == 0 ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 : sorted[n / 2]; + var reviewed = prs.Where(p => p.FirstApprovalHours.HasValue).ToList(); + + return new DashboardMetrics( + TotalPRs: prs.Count, + MedianHoursToComplete: Math.Round(median, 1), + PercentApprovedUnder24h: reviewed.Count > 0 + ? Math.Round((double)reviewed.Count(p => p.FirstApprovalHours! < 24) / reviewed.Count * 100, 1) : 0, + PercentMergedUnder24h: Math.Round((double)prs.Count(p => p.HoursToMerge < 24) / prs.Count * 100, 1)); + } +} diff --git a/NuGetClientPRHealth/GitHubClient.cs b/NuGetClientPRHealth/GitHubClient.cs new file mode 100644 index 0000000..1ba67d1 --- /dev/null +++ b/NuGetClientPRHealth/GitHubClient.cs @@ -0,0 +1,186 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace NuGetDashboard; + +public sealed class GitHubClient : IDisposable +{ + private readonly HttpClient _http; + private const string Repo = "NuGet/NuGet.Client"; + private int _rateLimitRemaining = int.MaxValue; + + public int RateLimitRemaining => _rateLimitRemaining; + + /// + /// Calls /rate_limit (free — not counted against quota) and returns + /// the core API remaining budget, which is what timeline/review calls consume. + /// + public async Task<(int remaining, int limit)> GetCoreRateLimitAsync() + { + using var resp = await _http.GetAsync("rate_limit"); + if (!resp.IsSuccessStatusCode) return (int.MaxValue, int.MaxValue); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + var core = doc.RootElement.GetProperty("resources").GetProperty("core"); + return (core.GetProperty("remaining").GetInt32(), core.GetProperty("limit").GetInt32()); + } + + public GitHubClient(string? token = null) + { + _http = new HttpClient { BaseAddress = new Uri("https://api.github.com/") }; + _http.DefaultRequestHeaders.UserAgent.ParseAdd("NuGetDashboardCli/1.0"); + // Include mockingbird preview to ensure timeline events are returned + _http.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github.mockingbird-preview+json"); + _http.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); + _http.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); + if (token is not null) + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + public async Task> SearchMergedPRsAsync(DateTime since, DateTime until) + { + var results = new List(); + var q = Uri.EscapeDataString($"repo:{Repo} is:pr is:merged merged:{since:yyyy-MM-dd}..{until:yyyy-MM-dd}"); + + for (var page = 1; ; page++) + { + using var resp = await _http.GetAsync($"search/issues?q={q}&per_page=100&page={page}"); + TrackRateLimit(resp); + resp.EnsureSuccessStatusCode(); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + var items = doc.RootElement.GetProperty("items"); + foreach (var item in items.EnumerateArray()) + results.Add(ParseRawPR(item)); + if (items.GetArrayLength() < 100) break; + } + return results; + } + + /// + /// Returns the time the PR became ready for review: + /// first ready_for_review event → first review_requested event → null (caller falls back to created_at). + /// + public async Task GetReadyTimeAsync(int prNumber) + { + using var resp = await _http.GetAsync( + $"repos/{Repo}/issues/{prNumber}/timeline?per_page=100"); + TrackRateLimit(resp); + await ThrowIfErrorAsync(resp, $"timeline for PR #{prNumber}"); + if (!resp.IsSuccessStatusCode) return null; + + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + + DateTime? readyForReview = null; + DateTime? firstReviewRequest = null; + + foreach (var ev in doc.RootElement.EnumerateArray()) + { + if (!ev.TryGetProperty("event", out var evProp)) continue; + if (!ev.TryGetProperty("created_at", out var tsProp)) continue; + if (!tsProp.TryGetDateTime(out var ts)) continue; + + switch (evProp.GetString()) + { + case "ready_for_review": + if (readyForReview is null || ts < readyForReview) + readyForReview = ts; + break; + case "review_requested": + if (firstReviewRequest is null || ts < firstReviewRequest) + firstReviewRequest = ts; + break; + } + } + return readyForReview ?? firstReviewRequest; + } + + /// Returns the DateTime of the first APPROVED review, or null. + public async Task GetFirstApprovalAtAsync(int prNumber) + { + using var resp = await _http.GetAsync($"repos/{Repo}/pulls/{prNumber}/reviews"); + TrackRateLimit(resp); + await ThrowIfErrorAsync(resp, $"reviews for PR #{prNumber}"); + if (!resp.IsSuccessStatusCode) return null; + + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + DateTime? firstApproval = null; + + foreach (var r in doc.RootElement.EnumerateArray()) + { + if (r.GetProperty("state").GetString() != "APPROVED") continue; + if (!r.TryGetProperty("submitted_at", out var el)) continue; + if (!el.TryGetDateTime(out var t)) continue; + if (firstApproval is null || t < firstApproval) firstApproval = t; + } + return firstApproval; + } + + private void TrackRateLimit(HttpResponseMessage resp) + { + if (resp.Headers.TryGetValues("X-RateLimit-Remaining", out var vals) && + int.TryParse(vals.FirstOrDefault(), out var remaining)) + _rateLimitRemaining = remaining; + } + + private static async Task ThrowIfErrorAsync(HttpResponseMessage resp, string context) + { + if (resp.IsSuccessStatusCode) return; + + var body = await resp.Content.ReadAsStringAsync(); + + // Distinguish the three common failure modes from the response body + if (body.Contains("secondary rate limit", StringComparison.OrdinalIgnoreCase) || + resp.Headers.Contains("Retry-After")) + { + throw new InvalidOperationException( + $"GitHub secondary rate limit (abuse detection) hit fetching {context}.\n" + + $" → Wait a minute then retry."); + } + + if (resp.Headers.TryGetValues("X-RateLimit-Remaining", out var v) && v.FirstOrDefault() == "0") + { + throw new InvalidOperationException( + $"GitHub primary rate limit exhausted fetching {context}.\n" + + $" → Wait until the hour resets, or use a different token."); + } + + if (body.Contains("Resource not accessible by integration", StringComparison.OrdinalIgnoreCase) || + body.Contains("must have push access", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Permission denied fetching {context}.\n" + + $" Your token is a fine-grained PAT — it needs these permissions:\n" + + $" • Issues: Read\n" + + $" • Pull requests: Read\n" + + $" → Edit the token at github.com/settings/tokens and add those, then retry.\n" + + $" → Or use a classic token (github.com/settings/tokens/new?type=classic) with no scopes.\n" + + $" Raw error: {body}"); + } + + if (body.Contains("forbids access via a fine-grained personal access token", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The NuGet org blocks fine-grained PATs with lifetime > 7 days.\n" + + $" → Use a classic token instead (recommended — no scopes needed):\n" + + $" github.com/settings/tokens/new?type=classic\n" + + $" → Or shorten your fine-grained PAT's lifetime to ≤7 days at:\n" + + $" github.com/settings/personal-access-tokens"); + } + + throw new InvalidOperationException( + $"GitHub API {(int)resp.StatusCode} error fetching {context}.\n Body: {body}"); + } + + private static RawPR ParseRawPR(JsonElement item) + { + var pr = item.GetProperty("pull_request"); + return new RawPR( + Number: item.GetProperty("number").GetInt32(), + Title: item.GetProperty("title").GetString()!, + Url: item.GetProperty("html_url").GetString()!, + Author: item.GetProperty("user").GetProperty("login").GetString()!, + CreatedAt: item.GetProperty("created_at").GetDateTime(), + MergedAt: pr.GetProperty("merged_at").GetDateTime()); + } + + public void Dispose() => _http.Dispose(); +} diff --git a/NuGetClientPRHealth/HtmlGenerator.cs b/NuGetClientPRHealth/HtmlGenerator.cs new file mode 100644 index 0000000..7175082 --- /dev/null +++ b/NuGetClientPRHealth/HtmlGenerator.cs @@ -0,0 +1,87 @@ +namespace NuGetDashboard; + +public static class HtmlGenerator +{ + public static void Generate(DashboardData data, string outputPath) + { + var sb = new System.Text.StringBuilder(); + + sb.AppendLine(""" + + + + + """); + + sb.AppendLine($"

PR health over the past {data.WindowDays} days

"); + + // Metrics + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine("
MeasurementValue
Total number of PRs in range{data.Metrics.TotalPRs}
Median: Hours to complete{data.Metrics.MedianHoursToComplete:F1}
Percentage of PRs approved under 24 hrs{data.Metrics.PercentApprovedUnder24h:F1}
Percentage of PRs completed under 24 hrs{data.Metrics.PercentMergedUnder24h:F1}
"); + + // Slow PRs + sb.AppendLine($"

Long lived PRs (closed after 72 hrs): the past {data.WindowDays} days

"); + if (data.SlowPRs.Count == 0) + { + sb.AppendLine("

🎉 All PRs closed within 72 hours this period!

"); + } + else + { + sb.AppendLine(""); + sb.AppendLine(""); + foreach (var pr in data.SlowPRs) + sb.AppendLine($""); + sb.AppendLine("
PR linkHours to closeWhy so long?
{pr.Url}{pr.HoursToMerge:F2}
"); + + sb.AppendLine("
"); + sb.AppendLine("Why so long Key"); + foreach (var line in new[] + { + "💔 - Delayed getting reviews from PR Buddy™️ or Team", + "🤖 - Testing infrastructure, delayed intentionally", + "🌪️ - PR fell behind other priorities", + "⛱️ - Weekend/PTO delayed", + "🛠️ - Merging Blocked (ex. CI Failures, rule violations)", + "📜 - Lots of feedback", + "🙂 - No issues - just letting more people chime in", + }) + sb.AppendLine($"

{line}

"); + sb.AppendLine("
"); + } + + // Appendix + sb.AppendLine($"

Appendix — All PRs in Period ({data.AllPRs.Count})

"); + sb.AppendLine(""); + sb.AppendLine(""); + foreach (var pr in data.AllPRs) + { + var approved = pr.FirstApprovedAt.HasValue ? Ts(pr.FirstApprovedAt.Value) : "—"; + sb.AppendLine($""); + } + sb.AppendLine("
PRTitleCreated (UTC)Ready for ReviewFirst ApprovedMerged (UTC)Duration
#{pr.Number}{H(pr.Title)}{Ts(pr.CreatedAt)}{Ts(pr.EffectiveStart)}{approved}{Ts(pr.MergedAt)}{FormatHours(pr.HoursToMerge)}
"); + + sb.AppendLine(""); + + File.WriteAllText(outputPath, sb.ToString(), System.Text.Encoding.UTF8); + } + + private static string Ts(DateTime dt) => dt.ToUniversalTime().ToString("MMM d HH:mm"); + private static string H(string s) => System.Net.WebUtility.HtmlEncode(s); + private static string FormatHours(double h) => + h < 24 ? $"{h:F1}h" : $"{(int)(h / 24)}d {(int)(h % 24)}h"; +} diff --git a/NuGetClientPRHealth/Models.cs b/NuGetClientPRHealth/Models.cs new file mode 100644 index 0000000..e9f05d1 --- /dev/null +++ b/NuGetClientPRHealth/Models.cs @@ -0,0 +1,26 @@ +namespace NuGetDashboard; + +public record RawPR(int Number, string Title, string Url, string Author, DateTime CreatedAt, DateTime MergedAt); + +public record PRRecord( + int Number, string Title, string Url, string Author, + DateTime CreatedAt, + DateTime EffectiveStart, // ready_for_review → review_requested → created_at + DateTime MergedAt, + double HoursToMerge, // EffectiveStart → MergedAt + double? FirstApprovalHours, + DateTime? FirstApprovedAt); + +public record DashboardMetrics( + int TotalPRs, + double MedianHoursToComplete, + double PercentApprovedUnder24h, + double PercentMergedUnder24h); + +public record DashboardData( + string DateRange, + string AsOf, + int WindowDays, + DashboardMetrics Metrics, + List SlowPRs, + List AllPRs); diff --git a/NuGetClientPRHealth/NuGetClientPRHealth.csproj b/NuGetClientPRHealth/NuGetClientPRHealth.csproj new file mode 100644 index 0000000..facff9d --- /dev/null +++ b/NuGetClientPRHealth/NuGetClientPRHealth.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0 + enable + enable + nuget-dashboard + NuGetDashboard + + diff --git a/NuGetClientPRHealth/Program.cs b/NuGetClientPRHealth/Program.cs new file mode 100644 index 0000000..c6e3bee --- /dev/null +++ b/NuGetClientPRHealth/Program.cs @@ -0,0 +1,51 @@ +using NuGetDashboard; + +int ParseArg(string name, int defaultVal) +{ + var raw = args.FirstOrDefault(a => a.StartsWith($"--{name}="))?.Split('=', 2).Last(); + return raw is not null && int.TryParse(raw, out var v) && v > 0 ? v : defaultVal; +} + +var token = args.FirstOrDefault(a => a.StartsWith("--token="))?.Split('=', 2).Last() + ?? Environment.GetEnvironmentVariable("GITHUB_TOKEN"); +var windowDays = ParseArg("days", 14); +var outputPath = args.FirstOrDefault(a => a.StartsWith("--output="))?.Split('=', 2).Last() + ?? $"nuget-pr-health-{DateTime.Now:yyyy-MM-dd}.html"; + +Console.WriteLine(); +Console.WriteLine(" NuGet.Client PR Health Dashboard"); +Console.WriteLine(" ══════════════════════════════════"); +Console.WriteLine($" Window : past {windowDays} days"); +Console.WriteLine($" Output : {outputPath}"); +Console.WriteLine(); + +if (token is null) +{ + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(" Warning: No GitHub token found (rate limit: 60 req/hr)."); + Console.WriteLine(" Set GITHUB_TOKEN or pass --token="); + Console.ResetColor(); + Console.WriteLine(); +} + +try +{ + using var client = new GitHubClient(token); + var data = await new DashboardService(client, windowDays).BuildDashboardAsync(); + + Console.Write("\n Generating report... "); + HtmlGenerator.Generate(data, outputPath); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("Done!"); + Console.ResetColor(); + Console.WriteLine($" Saved -> {Path.GetFullPath(outputPath)}"); + Console.WriteLine(); +} +catch (Exception ex) +{ + var inner = ex is AggregateException ae ? ae.InnerExceptions.First() : ex; + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\n Error: {inner.Message}"); + Console.ResetColor(); + Environment.Exit(1); +} diff --git a/NuGetClientPRHealth/README.md b/NuGetClientPRHealth/README.md new file mode 100644 index 0000000..4541e44 --- /dev/null +++ b/NuGetClientPRHealth/README.md @@ -0,0 +1,29 @@ +# NuGetClientPRHealth + +Generates an HTML dashboard showing PR review health for the NuGet.Client team over a configurable time window. + +## Usage + +``` +dotnet run [--token=] [--days=] [--output=] +``` + +## Inputs + +| Argument | Default | Description | +|---|---|---| +| `--token=` | `GITHUB_TOKEN` env var | GitHub personal access token (no scopes needed). Without it, rate limit is 60 req/hr. | +| `--days=` | `14` | Number of past days to include. | +| `--output=` | `nuget-pr-health-.html` | Output file path. | + +## Output + +A self-contained `.html` file with: +- Summary metrics: total PRs, median hours to merge, % approved/merged under 24 h +- Table of slow PRs (>72 h to merge) +- Full table of all merged PRs in the window + +## Notes + +- Only PRs authored by known team members are included (hardcoded list in `DashboardService.cs`). +- Requires ~2 GitHub API calls per PR (timeline + reviews). The tool checks your remaining rate limit before proceeding. From 72ab236e67f784a36443be2d29bf43e0d8d9f4d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:02:59 +0000 Subject: [PATCH 2/6] Use merged_at with closed_at fallback in ParseRawPR Agent-Logs-Url: https://github.com/NuGet/Entropy/sessions/5c1a69d4-c050-4eb2-a6c8-a64d858343a3 Co-authored-by: Nigusu-Allehu <59111203+Nigusu-Allehu@users.noreply.github.com> --- NuGetClientPRHealth/GitHubClient.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/NuGetClientPRHealth/GitHubClient.cs b/NuGetClientPRHealth/GitHubClient.cs index 1ba67d1..2335180 100644 --- a/NuGetClientPRHealth/GitHubClient.cs +++ b/NuGetClientPRHealth/GitHubClient.cs @@ -173,13 +173,25 @@ private static async Task ThrowIfErrorAsync(HttpResponseMessage resp, string con private static RawPR ParseRawPR(JsonElement item) { var pr = item.GetProperty("pull_request"); + DateTime mergedAt; + if (pr.TryGetProperty("merged_at", out var mergedAtProp) && + mergedAtProp.ValueKind != JsonValueKind.Null && + mergedAtProp.TryGetDateTime(out var mergedAtValue)) + { + mergedAt = mergedAtValue; + } + else + { + mergedAt = item.GetProperty("closed_at").GetDateTime(); + } + return new RawPR( Number: item.GetProperty("number").GetInt32(), Title: item.GetProperty("title").GetString()!, Url: item.GetProperty("html_url").GetString()!, Author: item.GetProperty("user").GetProperty("login").GetString()!, CreatedAt: item.GetProperty("created_at").GetDateTime(), - MergedAt: pr.GetProperty("merged_at").GetDateTime()); + MergedAt: mergedAt); } public void Dispose() => _http.Dispose(); From 0b2e3af234da658f2a982c37690f1283b6dc8b90 Mon Sep 17 00:00:00 2001 From: Nigusu Solomon Yenework <59111203+Nigusu-Allehu@users.noreply.github.com> Date: Fri, 3 Apr 2026 08:03:59 -0700 Subject: [PATCH 3/6] Update NuGetClientPRHealth/Program.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- NuGetClientPRHealth/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NuGetClientPRHealth/Program.cs b/NuGetClientPRHealth/Program.cs index c6e3bee..02832e4 100644 --- a/NuGetClientPRHealth/Program.cs +++ b/NuGetClientPRHealth/Program.cs @@ -10,7 +10,7 @@ int ParseArg(string name, int defaultVal) ?? Environment.GetEnvironmentVariable("GITHUB_TOKEN"); var windowDays = ParseArg("days", 14); var outputPath = args.FirstOrDefault(a => a.StartsWith("--output="))?.Split('=', 2).Last() - ?? $"nuget-pr-health-{DateTime.Now:yyyy-MM-dd}.html"; + ?? $"nuget-pr-health-{DateTime.UtcNow:yyyy-MM-dd}.html"; Console.WriteLine(); Console.WriteLine(" NuGet.Client PR Health Dashboard"); From 637099c17cb5d0007cea5f321db820251ad0544c Mon Sep 17 00:00:00 2001 From: Nigusu Solomon Yenework <59111203+Nigusu-Allehu@users.noreply.github.com> Date: Fri, 3 Apr 2026 08:04:10 -0700 Subject: [PATCH 4/6] Update NuGetClientPRHealth/HtmlGenerator.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- NuGetClientPRHealth/HtmlGenerator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NuGetClientPRHealth/HtmlGenerator.cs b/NuGetClientPRHealth/HtmlGenerator.cs index 7175082..b16d9ee 100644 --- a/NuGetClientPRHealth/HtmlGenerator.cs +++ b/NuGetClientPRHealth/HtmlGenerator.cs @@ -30,8 +30,8 @@ public static void Generate(DashboardData data, string outputPath) sb.AppendLine("MeasurementValue"); sb.AppendLine($"Total number of PRs in range{data.Metrics.TotalPRs}"); sb.AppendLine($"Median: Hours to complete{data.Metrics.MedianHoursToComplete:F1}"); - sb.AppendLine($"Percentage of PRs approved under 24 hrs{data.Metrics.PercentApprovedUnder24h:F1}"); - sb.AppendLine($"Percentage of PRs completed under 24 hrs{data.Metrics.PercentMergedUnder24h:F1}"); + sb.AppendLine($"Percentage of PRs approved under 24 hrs{data.Metrics.PercentApprovedUnder24h:F1}%"); + sb.AppendLine($"Percentage of PRs completed under 24 hrs{data.Metrics.PercentMergedUnder24h:F1}%"); sb.AppendLine(""); // Slow PRs From 1159eefd202d41f2a5aa5e3a6deb1c3f3111b17d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:05:48 +0000 Subject: [PATCH 5/6] Use "completed" wording for slow PRs section in HTML report Agent-Logs-Url: https://github.com/NuGet/Entropy/sessions/446c20c7-b113-490c-bcd8-816c68582226 Co-authored-by: Nigusu-Allehu <59111203+Nigusu-Allehu@users.noreply.github.com> --- NuGetClientPRHealth/HtmlGenerator.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/NuGetClientPRHealth/HtmlGenerator.cs b/NuGetClientPRHealth/HtmlGenerator.cs index b16d9ee..dec69cf 100644 --- a/NuGetClientPRHealth/HtmlGenerator.cs +++ b/NuGetClientPRHealth/HtmlGenerator.cs @@ -35,15 +35,15 @@ public static void Generate(DashboardData data, string outputPath) sb.AppendLine(""); // Slow PRs - sb.AppendLine($"

Long lived PRs (closed after 72 hrs): the past {data.WindowDays} days

"); + sb.AppendLine($"

Long lived PRs (completed after 72 hrs): the past {data.WindowDays} days

"); if (data.SlowPRs.Count == 0) { - sb.AppendLine("

🎉 All PRs closed within 72 hours this period!

"); + sb.AppendLine("

🎉 All PRs completed within 72 hours this period!

"); } else { sb.AppendLine(""); - sb.AppendLine(""); + sb.AppendLine(""); foreach (var pr in data.SlowPRs) sb.AppendLine($""); sb.AppendLine("
PR linkHours to closeWhy so long?
PR linkHours to completeWhy so long?
{pr.Url}{pr.HoursToMerge:F2}
"); From c84de393e8808bfc9bd673cb127c25121ec6de42 Mon Sep 17 00:00:00 2001 From: Nigusu Yenework Date: Mon, 13 Apr 2026 16:10:40 -0700 Subject: [PATCH 6/6] First reviewd --- NuGetClientPRHealth/DashboardService.cs | 9 ++++++--- NuGetClientPRHealth/GitHubClient.cs | 17 +++++++++++------ NuGetClientPRHealth/HtmlGenerator.cs | 24 ++++++++++++++++++++++-- NuGetClientPRHealth/Models.cs | 3 +++ 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/NuGetClientPRHealth/DashboardService.cs b/NuGetClientPRHealth/DashboardService.cs index b5d67c0..90bd904 100644 --- a/NuGetClientPRHealth/DashboardService.cs +++ b/NuGetClientPRHealth/DashboardService.cs @@ -36,8 +36,9 @@ public async Task BuildDashboardAsync() AsOf: now.ToString("MMMM d, yyyy") + " UTC", WindowDays: windowDays, Metrics: ComputeMetrics(prs), - SlowPRs: prs.Where(p => p.HoursToMerge > 72).OrderByDescending(p => p.HoursToMerge).ToList(), - AllPRs: prs.OrderBy(p => p.MergedAt).ToList()); + SlowPRs: prs.Where(p => p.HoursToMerge > 72).OrderByDescending(p => p.HoursToMerge).ToList(), + SlowToReviewPRs: prs.Where(p => p.FirstReviewHours > 24).OrderByDescending(p => p.FirstReviewHours).ToList(), + AllPRs: prs.OrderBy(p => p.MergedAt).ToList()); } private async Task> EnrichAsync(List prs) @@ -47,12 +48,14 @@ private async Task> EnrichAsync(List prs) { var readyAt = await client.GetReadyTimeAsync(raw.Number); var effectiveStart = readyAt ?? raw.CreatedAt; - var approvedAt = await client.GetFirstApprovalAtAsync(raw.Number); + var (reviewedAt, approvedAt) = await client.GetFirstReviewAndApprovalAsync(raw.Number, effectiveStart); results.Add(new PRRecord( raw.Number, raw.Title, raw.Url, raw.Author, raw.CreatedAt, effectiveStart, raw.MergedAt, HoursToMerge: Math.Max(0, (raw.MergedAt - effectiveStart).TotalHours), + FirstReviewHours: reviewedAt.HasValue ? (reviewedAt.Value - effectiveStart).TotalHours : null, + FirstReviewedAt: reviewedAt, FirstApprovalHours: approvedAt.HasValue ? (approvedAt.Value - effectiveStart).TotalHours : null, FirstApprovedAt: approvedAt)); diff --git a/NuGetClientPRHealth/GitHubClient.cs b/NuGetClientPRHealth/GitHubClient.cs index 1ba67d1..e881f41 100644 --- a/NuGetClientPRHealth/GitHubClient.cs +++ b/NuGetClientPRHealth/GitHubClient.cs @@ -93,25 +93,30 @@ public async Task> SearchMergedPRsAsync(DateTime since, DateTime unt return readyForReview ?? firstReviewRequest; } - /// Returns the DateTime of the first APPROVED review, or null. - public async Task GetFirstApprovalAtAsync(int prNumber) + /// Returns the DateTimes of the first review of any kind and the first APPROVED review. + /// Reviews submitted before are skipped for the first-review result. + public async Task<(DateTime? firstReview, DateTime? firstApproval)> GetFirstReviewAndApprovalAsync(int prNumber, DateTime effectiveStart) { using var resp = await _http.GetAsync($"repos/{Repo}/pulls/{prNumber}/reviews"); TrackRateLimit(resp); await ThrowIfErrorAsync(resp, $"reviews for PR #{prNumber}"); - if (!resp.IsSuccessStatusCode) return null; + if (!resp.IsSuccessStatusCode) return (null, null); using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + DateTime? firstReview = null; DateTime? firstApproval = null; foreach (var r in doc.RootElement.EnumerateArray()) { - if (r.GetProperty("state").GetString() != "APPROVED") continue; + var state = r.GetProperty("state").GetString(); + if (state == "PENDING") continue; if (!r.TryGetProperty("submitted_at", out var el)) continue; if (!el.TryGetDateTime(out var t)) continue; - if (firstApproval is null || t < firstApproval) firstApproval = t; + + if (t >= effectiveStart && (firstReview is null || t < firstReview)) firstReview = t; + if (state == "APPROVED" && (firstApproval is null || t < firstApproval)) firstApproval = t; } - return firstApproval; + return (firstReview, firstApproval); } private void TrackRateLimit(HttpResponseMessage resp) diff --git a/NuGetClientPRHealth/HtmlGenerator.cs b/NuGetClientPRHealth/HtmlGenerator.cs index 7175082..14edeaf 100644 --- a/NuGetClientPRHealth/HtmlGenerator.cs +++ b/NuGetClientPRHealth/HtmlGenerator.cs @@ -47,7 +47,26 @@ public static void Generate(DashboardData data, string outputPath) foreach (var pr in data.SlowPRs) sb.AppendLine($"{pr.Url}{pr.HoursToMerge:F2}"); sb.AppendLine(""); + } + + // Slow to review PRs + sb.AppendLine($"

PRs that waited more than 24 hrs for first review: the past {data.WindowDays} days

"); + if (data.SlowToReviewPRs.Count == 0) + { + sb.AppendLine("

🎉 All PRs received a first review within 24 hours this period!

"); + } + else + { + sb.AppendLine(""); + sb.AppendLine(""); + foreach (var pr in data.SlowToReviewPRs) + sb.AppendLine($""); + sb.AppendLine("
PR linkHours to first reviewWhy so long?
{pr.Url}{pr.FirstReviewHours!.Value:F2}
"); + } + // Shared key (shown once after both tables, only if there's anything to annotate) + if (data.SlowPRs.Count > 0 || data.SlowToReviewPRs.Count > 0) + { sb.AppendLine("
"); sb.AppendLine("Why so long Key"); foreach (var line in new[] @@ -67,11 +86,12 @@ public static void Generate(DashboardData data, string outputPath) // Appendix sb.AppendLine($"

Appendix — All PRs in Period ({data.AllPRs.Count})

"); sb.AppendLine(""); - sb.AppendLine(""); + sb.AppendLine(""); foreach (var pr in data.AllPRs) { + var reviewed = pr.FirstReviewedAt.HasValue ? Ts(pr.FirstReviewedAt.Value) : "—"; var approved = pr.FirstApprovedAt.HasValue ? Ts(pr.FirstApprovedAt.Value) : "—"; - sb.AppendLine($""); + sb.AppendLine($""); } sb.AppendLine("
PRTitleCreated (UTC)Ready for ReviewFirst ApprovedMerged (UTC)Duration
PRTitleCreated (UTC)Ready for ReviewFirst ReviewedFirst ApprovedMerged (UTC)Duration
#{pr.Number}{H(pr.Title)}{Ts(pr.CreatedAt)}{Ts(pr.EffectiveStart)}{approved}{Ts(pr.MergedAt)}{FormatHours(pr.HoursToMerge)}
#{pr.Number}{H(pr.Title)}{Ts(pr.CreatedAt)}{Ts(pr.EffectiveStart)}{reviewed}{approved}{Ts(pr.MergedAt)}{FormatHours(pr.HoursToMerge)}
"); diff --git a/NuGetClientPRHealth/Models.cs b/NuGetClientPRHealth/Models.cs index e9f05d1..91e69e5 100644 --- a/NuGetClientPRHealth/Models.cs +++ b/NuGetClientPRHealth/Models.cs @@ -8,6 +8,8 @@ public record PRRecord( DateTime EffectiveStart, // ready_for_review → review_requested → created_at DateTime MergedAt, double HoursToMerge, // EffectiveStart → MergedAt + double? FirstReviewHours, // EffectiveStart → first review of any kind + DateTime? FirstReviewedAt, double? FirstApprovalHours, DateTime? FirstApprovedAt); @@ -23,4 +25,5 @@ public record DashboardData( int WindowDays, DashboardMetrics Metrics, List SlowPRs, + List SlowToReviewPRs, List AllPRs);