Skip to content

Commit d5be3c0

Browse files
committed
Add PR health tool
1 parent 1ce0de7 commit d5be3c0

7 files changed

Lines changed: 469 additions & 0 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
namespace NuGetDashboard;
2+
3+
public class DashboardService(GitHubClient client, int windowDays = 14)
4+
{
5+
private static readonly HashSet<string> TeamMembers = new(StringComparer.OrdinalIgnoreCase)
6+
{
7+
"nkolev92", "zivkan", "jeffkl", "donnie-msft", "kartheekp-ms",
8+
"martinrrm", "jebriede", "Nigusu-Allehu", "aortiz-msft"
9+
};
10+
11+
public async Task<DashboardData> BuildDashboardAsync()
12+
{
13+
var now = DateTime.UtcNow;
14+
var windowAgo = now.AddDays(-windowDays);
15+
16+
Console.Write($" Fetching PRs merged in the past {windowDays} days... ");
17+
var rawPRs = (await client.SearchMergedPRsAsync(windowAgo, now))
18+
.Where(p => TeamMembers.Contains(p.Author))
19+
.ToList();
20+
Console.WriteLine($"{rawPRs.Count} found.");
21+
22+
var callsNeeded = rawPRs.Count * 2; // timeline + reviews per PR
23+
var (coreRemaining, coreLimit) = await client.GetCoreRateLimitAsync();
24+
Console.WriteLine($" Core API budget: {coreRemaining}/{coreLimit} remaining, {callsNeeded} needed.");
25+
if (coreRemaining < callsNeeded)
26+
throw new InvalidOperationException(
27+
$"GitHub core API rate limit too low: {coreRemaining} remaining, {callsNeeded} needed.\n" +
28+
$" → Create a free classic token at github.com/settings/tokens/new?type=classic (no scopes needed)");
29+
30+
Console.Write($" Enriching {rawPRs.Count} PRs... ");
31+
var prs = await EnrichAsync(rawPRs);
32+
Console.WriteLine("done.");
33+
34+
return new DashboardData(
35+
DateRange: $"{windowAgo:MMM d} \u2013 {now:MMM d, yyyy}",
36+
AsOf: now.ToString("MMMM d, yyyy") + " UTC",
37+
WindowDays: windowDays,
38+
Metrics: ComputeMetrics(prs),
39+
SlowPRs: prs.Where(p => p.HoursToMerge > 72).OrderByDescending(p => p.HoursToMerge).ToList(),
40+
AllPRs: prs.OrderBy(p => p.MergedAt).ToList());
41+
}
42+
43+
private async Task<List<PRRecord>> EnrichAsync(List<RawPR> prs)
44+
{
45+
var results = new List<PRRecord>();
46+
foreach (var raw in prs)
47+
{
48+
var readyAt = await client.GetReadyTimeAsync(raw.Number);
49+
var effectiveStart = readyAt ?? raw.CreatedAt;
50+
var approvedAt = await client.GetFirstApprovalAtAsync(raw.Number);
51+
52+
results.Add(new PRRecord(
53+
raw.Number, raw.Title, raw.Url, raw.Author,
54+
raw.CreatedAt, effectiveStart, raw.MergedAt,
55+
HoursToMerge: Math.Max(0, (raw.MergedAt - effectiveStart).TotalHours),
56+
FirstApprovalHours: approvedAt.HasValue ? (approvedAt.Value - effectiveStart).TotalHours : null,
57+
FirstApprovedAt: approvedAt));
58+
59+
await Task.Delay(200); // avoid GitHub secondary rate limits
60+
}
61+
return results;
62+
}
63+
64+
private static DashboardMetrics ComputeMetrics(List<PRRecord> prs)
65+
{
66+
if (prs.Count == 0) return new DashboardMetrics(0, 0, 0, 0);
67+
68+
var sorted = prs.Select(p => p.HoursToMerge).OrderBy(x => x).ToList();
69+
var n = sorted.Count;
70+
var median = n % 2 == 0 ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 : sorted[n / 2];
71+
var reviewed = prs.Where(p => p.FirstApprovalHours.HasValue).ToList();
72+
73+
return new DashboardMetrics(
74+
TotalPRs: prs.Count,
75+
MedianHoursToComplete: Math.Round(median, 1),
76+
PercentApprovedUnder24h: reviewed.Count > 0
77+
? Math.Round((double)reviewed.Count(p => p.FirstApprovalHours! < 24) / reviewed.Count * 100, 1) : 0,
78+
PercentMergedUnder24h: Math.Round((double)prs.Count(p => p.HoursToMerge < 24) / prs.Count * 100, 1));
79+
}
80+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
using System.Net.Http.Headers;
2+
using System.Text.Json;
3+
4+
namespace NuGetDashboard;
5+
6+
public sealed class GitHubClient : IDisposable
7+
{
8+
private readonly HttpClient _http;
9+
private const string Repo = "NuGet/NuGet.Client";
10+
private int _rateLimitRemaining = int.MaxValue;
11+
12+
public int RateLimitRemaining => _rateLimitRemaining;
13+
14+
/// <summary>
15+
/// Calls /rate_limit (free — not counted against quota) and returns
16+
/// the core API remaining budget, which is what timeline/review calls consume.
17+
/// </summary>
18+
public async Task<(int remaining, int limit)> GetCoreRateLimitAsync()
19+
{
20+
using var resp = await _http.GetAsync("rate_limit");
21+
if (!resp.IsSuccessStatusCode) return (int.MaxValue, int.MaxValue);
22+
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
23+
var core = doc.RootElement.GetProperty("resources").GetProperty("core");
24+
return (core.GetProperty("remaining").GetInt32(), core.GetProperty("limit").GetInt32());
25+
}
26+
27+
public GitHubClient(string? token = null)
28+
{
29+
_http = new HttpClient { BaseAddress = new Uri("https://api.github.com/") };
30+
_http.DefaultRequestHeaders.UserAgent.ParseAdd("NuGetDashboardCli/1.0");
31+
// Include mockingbird preview to ensure timeline events are returned
32+
_http.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github.mockingbird-preview+json");
33+
_http.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
34+
_http.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
35+
if (token is not null)
36+
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
37+
}
38+
39+
public async Task<List<RawPR>> SearchMergedPRsAsync(DateTime since, DateTime until)
40+
{
41+
var results = new List<RawPR>();
42+
var q = Uri.EscapeDataString($"repo:{Repo} is:pr is:merged merged:{since:yyyy-MM-dd}..{until:yyyy-MM-dd}");
43+
44+
for (var page = 1; ; page++)
45+
{
46+
using var resp = await _http.GetAsync($"search/issues?q={q}&per_page=100&page={page}");
47+
TrackRateLimit(resp);
48+
resp.EnsureSuccessStatusCode();
49+
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
50+
var items = doc.RootElement.GetProperty("items");
51+
foreach (var item in items.EnumerateArray())
52+
results.Add(ParseRawPR(item));
53+
if (items.GetArrayLength() < 100) break;
54+
}
55+
return results;
56+
}
57+
58+
/// <summary>
59+
/// Returns the time the PR became ready for review:
60+
/// first ready_for_review event → first review_requested event → null (caller falls back to created_at).
61+
/// </summary>
62+
public async Task<DateTime?> GetReadyTimeAsync(int prNumber)
63+
{
64+
using var resp = await _http.GetAsync(
65+
$"repos/{Repo}/issues/{prNumber}/timeline?per_page=100");
66+
TrackRateLimit(resp);
67+
await ThrowIfErrorAsync(resp, $"timeline for PR #{prNumber}");
68+
if (!resp.IsSuccessStatusCode) return null;
69+
70+
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
71+
72+
DateTime? readyForReview = null;
73+
DateTime? firstReviewRequest = null;
74+
75+
foreach (var ev in doc.RootElement.EnumerateArray())
76+
{
77+
if (!ev.TryGetProperty("event", out var evProp)) continue;
78+
if (!ev.TryGetProperty("created_at", out var tsProp)) continue;
79+
if (!tsProp.TryGetDateTime(out var ts)) continue;
80+
81+
switch (evProp.GetString())
82+
{
83+
case "ready_for_review":
84+
if (readyForReview is null || ts < readyForReview)
85+
readyForReview = ts;
86+
break;
87+
case "review_requested":
88+
if (firstReviewRequest is null || ts < firstReviewRequest)
89+
firstReviewRequest = ts;
90+
break;
91+
}
92+
}
93+
return readyForReview ?? firstReviewRequest;
94+
}
95+
96+
/// <summary>Returns the DateTime of the first APPROVED review, or null.</summary>
97+
public async Task<DateTime?> GetFirstApprovalAtAsync(int prNumber)
98+
{
99+
using var resp = await _http.GetAsync($"repos/{Repo}/pulls/{prNumber}/reviews");
100+
TrackRateLimit(resp);
101+
await ThrowIfErrorAsync(resp, $"reviews for PR #{prNumber}");
102+
if (!resp.IsSuccessStatusCode) return null;
103+
104+
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
105+
DateTime? firstApproval = null;
106+
107+
foreach (var r in doc.RootElement.EnumerateArray())
108+
{
109+
if (r.GetProperty("state").GetString() != "APPROVED") continue;
110+
if (!r.TryGetProperty("submitted_at", out var el)) continue;
111+
if (!el.TryGetDateTime(out var t)) continue;
112+
if (firstApproval is null || t < firstApproval) firstApproval = t;
113+
}
114+
return firstApproval;
115+
}
116+
117+
private void TrackRateLimit(HttpResponseMessage resp)
118+
{
119+
if (resp.Headers.TryGetValues("X-RateLimit-Remaining", out var vals) &&
120+
int.TryParse(vals.FirstOrDefault(), out var remaining))
121+
_rateLimitRemaining = remaining;
122+
}
123+
124+
private static async Task ThrowIfErrorAsync(HttpResponseMessage resp, string context)
125+
{
126+
if (resp.IsSuccessStatusCode) return;
127+
128+
var body = await resp.Content.ReadAsStringAsync();
129+
130+
// Distinguish the three common failure modes from the response body
131+
if (body.Contains("secondary rate limit", StringComparison.OrdinalIgnoreCase) ||
132+
resp.Headers.Contains("Retry-After"))
133+
{
134+
throw new InvalidOperationException(
135+
$"GitHub secondary rate limit (abuse detection) hit fetching {context}.\n" +
136+
$" → Wait a minute then retry.");
137+
}
138+
139+
if (resp.Headers.TryGetValues("X-RateLimit-Remaining", out var v) && v.FirstOrDefault() == "0")
140+
{
141+
throw new InvalidOperationException(
142+
$"GitHub primary rate limit exhausted fetching {context}.\n" +
143+
$" → Wait until the hour resets, or use a different token.");
144+
}
145+
146+
if (body.Contains("Resource not accessible by integration", StringComparison.OrdinalIgnoreCase) ||
147+
body.Contains("must have push access", StringComparison.OrdinalIgnoreCase))
148+
{
149+
throw new InvalidOperationException(
150+
$"Permission denied fetching {context}.\n" +
151+
$" Your token is a fine-grained PAT — it needs these permissions:\n" +
152+
$" • Issues: Read\n" +
153+
$" • Pull requests: Read\n" +
154+
$" → Edit the token at github.com/settings/tokens and add those, then retry.\n" +
155+
$" → Or use a classic token (github.com/settings/tokens/new?type=classic) with no scopes.\n" +
156+
$" Raw error: {body}");
157+
}
158+
159+
if (body.Contains("forbids access via a fine-grained personal access token", StringComparison.OrdinalIgnoreCase))
160+
{
161+
throw new InvalidOperationException(
162+
$"The NuGet org blocks fine-grained PATs with lifetime > 7 days.\n" +
163+
$" → Use a classic token instead (recommended — no scopes needed):\n" +
164+
$" github.com/settings/tokens/new?type=classic\n" +
165+
$" → Or shorten your fine-grained PAT's lifetime to ≤7 days at:\n" +
166+
$" github.com/settings/personal-access-tokens");
167+
}
168+
169+
throw new InvalidOperationException(
170+
$"GitHub API {(int)resp.StatusCode} error fetching {context}.\n Body: {body}");
171+
}
172+
173+
private static RawPR ParseRawPR(JsonElement item)
174+
{
175+
var pr = item.GetProperty("pull_request");
176+
return new RawPR(
177+
Number: item.GetProperty("number").GetInt32(),
178+
Title: item.GetProperty("title").GetString()!,
179+
Url: item.GetProperty("html_url").GetString()!,
180+
Author: item.GetProperty("user").GetProperty("login").GetString()!,
181+
CreatedAt: item.GetProperty("created_at").GetDateTime(),
182+
MergedAt: pr.GetProperty("merged_at").GetDateTime());
183+
}
184+
185+
public void Dispose() => _http.Dispose();
186+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
namespace NuGetDashboard;
2+
3+
public static class HtmlGenerator
4+
{
5+
public static void Generate(DashboardData data, string outputPath)
6+
{
7+
var sb = new System.Text.StringBuilder();
8+
9+
sb.AppendLine("""
10+
<!DOCTYPE html>
11+
<html><head><meta charset="utf-8">
12+
<style>
13+
body { font-family: Calibri, Arial, sans-serif; font-size: 11pt; color: #1F1F1F; max-width: 900px; }
14+
h1 { font-size: 14pt; color: #1F3864; }
15+
h2 { font-size: 12pt; color: #2E74B5; margin-top: 24px; }
16+
table { border-collapse: collapse; width: 100%; margin-bottom: 12px; }
17+
th { background: #1F3864; color: #fff; padding: 6px 10px; text-align: left; }
18+
td { padding: 5px 10px; border-bottom: 1px solid #D9D9D9; }
19+
tr:nth-child(even) td { background: #F2F2F2; }
20+
a { color: #0563C1; }
21+
.key p { margin: 2px 0; }
22+
</style>
23+
</head><body>
24+
""");
25+
26+
sb.AppendLine($"<h1>PR health over the past {data.WindowDays} days</h1>");
27+
28+
// Metrics
29+
sb.AppendLine("<table>");
30+
sb.AppendLine("<tr><th>Measurement</th><th>Value</th></tr>");
31+
sb.AppendLine($"<tr><td>Total number of PRs in range</td><td>{data.Metrics.TotalPRs}</td></tr>");
32+
sb.AppendLine($"<tr><td>Median: Hours to complete</td><td>{data.Metrics.MedianHoursToComplete:F1}</td></tr>");
33+
sb.AppendLine($"<tr><td>Percentage of PRs approved under 24 hrs</td><td>{data.Metrics.PercentApprovedUnder24h:F1}</td></tr>");
34+
sb.AppendLine($"<tr><td>Percentage of PRs completed under 24 hrs</td><td>{data.Metrics.PercentMergedUnder24h:F1}</td></tr>");
35+
sb.AppendLine("</table>");
36+
37+
// Slow PRs
38+
sb.AppendLine($"<h2>Long lived PRs (closed after 72 hrs): the past {data.WindowDays} days</h2>");
39+
if (data.SlowPRs.Count == 0)
40+
{
41+
sb.AppendLine("<p>🎉 All PRs closed within 72 hours this period!</p>");
42+
}
43+
else
44+
{
45+
sb.AppendLine("<table>");
46+
sb.AppendLine("<tr><th>PR link</th><th>Hours to close</th><th>Why so long?</th></tr>");
47+
foreach (var pr in data.SlowPRs)
48+
sb.AppendLine($"<tr><td><a href=\"{pr.Url}\">{pr.Url}</a></td><td>{pr.HoursToMerge:F2}</td><td></td></tr>");
49+
sb.AppendLine("</table>");
50+
51+
sb.AppendLine("<div class='key'>");
52+
sb.AppendLine("<strong>Why so long Key</strong>");
53+
foreach (var line in new[]
54+
{
55+
"💔 - Delayed getting reviews from PR Buddy™️ or Team",
56+
"🤖 - Testing infrastructure, delayed intentionally",
57+
"🌪️ - PR fell behind other priorities",
58+
"⛱️ - Weekend/PTO delayed",
59+
"🛠️ - Merging Blocked (ex. CI Failures, rule violations)",
60+
"📜 - Lots of feedback",
61+
"🙂 - No issues - just letting more people chime in",
62+
})
63+
sb.AppendLine($"<p>{line}</p>");
64+
sb.AppendLine("</div>");
65+
}
66+
67+
// Appendix
68+
sb.AppendLine($"<h2>Appendix — All PRs in Period ({data.AllPRs.Count})</h2>");
69+
sb.AppendLine("<table>");
70+
sb.AppendLine("<tr><th>PR</th><th>Title</th><th>Created (UTC)</th><th>Ready for Review</th><th>First Approved</th><th>Merged (UTC)</th><th>Duration</th></tr>");
71+
foreach (var pr in data.AllPRs)
72+
{
73+
var approved = pr.FirstApprovedAt.HasValue ? Ts(pr.FirstApprovedAt.Value) : "—";
74+
sb.AppendLine($"<tr><td><a href=\"{pr.Url}\">#{pr.Number}</a></td><td>{H(pr.Title)}</td><td>{Ts(pr.CreatedAt)}</td><td>{Ts(pr.EffectiveStart)}</td><td>{approved}</td><td>{Ts(pr.MergedAt)}</td><td>{FormatHours(pr.HoursToMerge)}</td></tr>");
75+
}
76+
sb.AppendLine("</table>");
77+
78+
sb.AppendLine("</body></html>");
79+
80+
File.WriteAllText(outputPath, sb.ToString(), System.Text.Encoding.UTF8);
81+
}
82+
83+
private static string Ts(DateTime dt) => dt.ToUniversalTime().ToString("MMM d HH:mm");
84+
private static string H(string s) => System.Net.WebUtility.HtmlEncode(s);
85+
private static string FormatHours(double h) =>
86+
h < 24 ? $"{h:F1}h" : $"{(int)(h / 24)}d {(int)(h % 24)}h";
87+
}

NuGetClientPRHealth/Models.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
namespace NuGetDashboard;
2+
3+
public record RawPR(int Number, string Title, string Url, string Author, DateTime CreatedAt, DateTime MergedAt);
4+
5+
public record PRRecord(
6+
int Number, string Title, string Url, string Author,
7+
DateTime CreatedAt,
8+
DateTime EffectiveStart, // ready_for_review → review_requested → created_at
9+
DateTime MergedAt,
10+
double HoursToMerge, // EffectiveStart → MergedAt
11+
double? FirstApprovalHours,
12+
DateTime? FirstApprovedAt);
13+
14+
public record DashboardMetrics(
15+
int TotalPRs,
16+
double MedianHoursToComplete,
17+
double PercentApprovedUnder24h,
18+
double PercentMergedUnder24h);
19+
20+
public record DashboardData(
21+
string DateRange,
22+
string AsOf,
23+
int WindowDays,
24+
DashboardMetrics Metrics,
25+
List<PRRecord> SlowPRs,
26+
List<PRRecord> AllPRs);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>Exe</OutputType>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<AssemblyName>nuget-dashboard</AssemblyName>
8+
<RootNamespace>NuGetDashboard</RootNamespace>
9+
</PropertyGroup>
10+
</Project>

0 commit comments

Comments
 (0)