diff --git a/NuGetClientPRHealth/DashboardService.cs b/NuGetClientPRHealth/DashboardService.cs new file mode 100644 index 0000000..90bd904 --- /dev/null +++ b/NuGetClientPRHealth/DashboardService.cs @@ -0,0 +1,83 @@ +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(), + 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) + { + var results = new List(); + foreach (var raw in prs) + { + var readyAt = await client.GetReadyTimeAsync(raw.Number); + var effectiveStart = readyAt ?? raw.CreatedAt; + 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)); + + 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..5485771 --- /dev/null +++ b/NuGetClientPRHealth/GitHubClient.cs @@ -0,0 +1,203 @@ +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 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, null); + + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + DateTime? firstReview = null; + DateTime? firstApproval = null; + + foreach (var r in doc.RootElement.EnumerateArray()) + { + 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 (t >= effectiveStart && (firstReview is null || t < firstReview)) firstReview = t; + if (state == "APPROVED" && (firstApproval is null || t < firstApproval)) firstApproval = t; + } + return (firstReview, 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"); + 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: mergedAt); + } + + public void Dispose() => _http.Dispose(); +} diff --git a/NuGetClientPRHealth/HtmlGenerator.cs b/NuGetClientPRHealth/HtmlGenerator.cs new file mode 100644 index 0000000..dbe412e --- /dev/null +++ b/NuGetClientPRHealth/HtmlGenerator.cs @@ -0,0 +1,107 @@ +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 (completed after 72 hrs): the past {data.WindowDays} days

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

🎉 All PRs completed within 72 hours this period!

"); + } + else + { + sb.AppendLine(""); + sb.AppendLine(""); + foreach (var pr in data.SlowPRs) + sb.AppendLine($""); + sb.AppendLine("
PR linkHours to completeWhy so long?
{pr.Url}{pr.HoursToMerge:F2}
"); + } + + // 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[] + { + "💔 - 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 reviewed = pr.FirstReviewedAt.HasValue ? Ts(pr.FirstReviewedAt.Value) : "—"; + var approved = pr.FirstApprovedAt.HasValue ? Ts(pr.FirstApprovedAt.Value) : "—"; + sb.AppendLine($""); + } + sb.AppendLine("
PRTitleCreated (UTC)Ready for ReviewFirst ReviewedFirst ApprovedMerged (UTC)Duration
#{pr.Number}{H(pr.Title)}{Ts(pr.CreatedAt)}{Ts(pr.EffectiveStart)}{reviewed}{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..91e69e5 --- /dev/null +++ b/NuGetClientPRHealth/Models.cs @@ -0,0 +1,29 @@ +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? FirstReviewHours, // EffectiveStart → first review of any kind + DateTime? FirstReviewedAt, + 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 SlowToReviewPRs, + 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..02832e4 --- /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.UtcNow: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.