Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions NuGetClientPRHealth/DashboardService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
namespace NuGetDashboard;

public class DashboardService(GitHubClient client, int windowDays = 14)
{
private static readonly HashSet<string> TeamMembers = new(StringComparer.OrdinalIgnoreCase)
{
"nkolev92", "zivkan", "jeffkl", "donnie-msft", "kartheekp-ms",
"martinrrm", "jebriede", "Nigusu-Allehu", "aortiz-msft"
};

public async Task<DashboardData> 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)
Comment on lines +22 to +25
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

callsNeeded = rawPRs.Count * 2 underestimates actual API usage: the search itself can require multiple requests (pagination) and timeline/review endpoints may also paginate or require retries. This can cause the tool to proceed even when the remaining budget is insufficient. Consider making the estimate more conservative (include search pages + worst-case pagination) or degrade gracefully when the budget runs out mid-run.

Copilot uses AI. Check for mistakes.
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<List<PRRecord>> EnrichAsync(List<RawPR> prs)
{
var results = new List<PRRecord>();
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<PRRecord> 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));
}
}
198 changes: 198 additions & 0 deletions NuGetClientPRHealth/GitHubClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
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;

/// <summary>
/// Calls /rate_limit (free — not counted against quota) and returns
/// the core API remaining budget, which is what timeline/review calls consume.
/// </summary>
public async Task<(int remaining, int limit)> GetCoreRateLimitAsync()
{
using var resp = await _http.GetAsync("rate_limit");
if (!resp.IsSuccessStatusCode) return (int.MaxValue, int.MaxValue);
Comment on lines +17 to +21
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If /rate_limit fails, this returns (int.MaxValue, int.MaxValue), which effectively disables the preflight budget check and can lead to confusing failures later. Consider surfacing the error (throw / return 0 with a warning) so users understand why rate-limit validation couldn’t be performed.

Suggested change
/// </summary>
public async Task<(int remaining, int limit)> GetCoreRateLimitAsync()
{
using var resp = await _http.GetAsync("rate_limit");
if (!resp.IsSuccessStatusCode) return (int.MaxValue, int.MaxValue);
/// Throws if rate-limit validation could not be performed.
/// </summary>
public async Task<(int remaining, int limit)> GetCoreRateLimitAsync()
{
using var resp = await _http.GetAsync("rate_limit");
TrackRateLimit(resp);
await ThrowIfErrorAsync(resp, "core rate limit");

Copilot uses AI. Check for mistakes.
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<List<RawPR>> SearchMergedPRsAsync(DateTime since, DateTime until)
{
var results = new List<RawPR>();
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;
}

/// <summary>
/// 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).
/// </summary>
public async Task<DateTime?> 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;
}

/// <summary>Returns the DateTime of the first APPROVED review, or null.</summary>
public async Task<DateTime?> 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");
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();
}
87 changes: 87 additions & 0 deletions NuGetClientPRHealth/HtmlGenerator.cs
Original file line number Diff line number Diff line change
@@ -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("""
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>
body { font-family: Calibri, Arial, sans-serif; font-size: 11pt; color: #1F1F1F; max-width: 900px; }
h1 { font-size: 14pt; color: #1F3864; }
h2 { font-size: 12pt; color: #2E74B5; margin-top: 24px; }
table { border-collapse: collapse; width: 100%; margin-bottom: 12px; }
th { background: #1F3864; color: #fff; padding: 6px 10px; text-align: left; }
td { padding: 5px 10px; border-bottom: 1px solid #D9D9D9; }
tr:nth-child(even) td { background: #F2F2F2; }
a { color: #0563C1; }
.key p { margin: 2px 0; }
</style>
</head><body>
""");

sb.AppendLine($"<h1>PR health over the past {data.WindowDays} days</h1>");

// Metrics
sb.AppendLine("<table>");
sb.AppendLine("<tr><th>Measurement</th><th>Value</th></tr>");
sb.AppendLine($"<tr><td>Total number of PRs in range</td><td>{data.Metrics.TotalPRs}</td></tr>");
sb.AppendLine($"<tr><td>Median: Hours to complete</td><td>{data.Metrics.MedianHoursToComplete:F1}</td></tr>");
sb.AppendLine($"<tr><td>Percentage of PRs approved under 24 hrs</td><td>{data.Metrics.PercentApprovedUnder24h:F1}%</td></tr>");
sb.AppendLine($"<tr><td>Percentage of PRs completed under 24 hrs</td><td>{data.Metrics.PercentMergedUnder24h:F1}%</td></tr>");
sb.AppendLine("</table>");

// Slow PRs
sb.AppendLine($"<h2>Long lived PRs (completed after 72 hrs): the past {data.WindowDays} days</h2>");
if (data.SlowPRs.Count == 0)
{
sb.AppendLine("<p>🎉 All PRs completed within 72 hours this period!</p>");
}
else
{
sb.AppendLine("<table>");
sb.AppendLine("<tr><th>PR link</th><th>Hours to complete</th><th>Why so long?</th></tr>");
foreach (var pr in data.SlowPRs)
sb.AppendLine($"<tr><td><a href=\"{pr.Url}\">{pr.Url}</a></td><td>{pr.HoursToMerge:F2}</td><td></td></tr>");
sb.AppendLine("</table>");

sb.AppendLine("<div class='key'>");
sb.AppendLine("<strong>Why so long Key</strong>");
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($"<p>{line}</p>");
sb.AppendLine("</div>");
}

// Appendix
sb.AppendLine($"<h2>Appendix — All PRs in Period ({data.AllPRs.Count})</h2>");
sb.AppendLine("<table>");
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>");
foreach (var pr in data.AllPRs)
{
var approved = pr.FirstApprovedAt.HasValue ? Ts(pr.FirstApprovedAt.Value) : "—";
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>");
}
Comment thread
Nigusu-Allehu marked this conversation as resolved.
sb.AppendLine("</table>");

sb.AppendLine("</body></html>");

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";
}
26 changes: 26 additions & 0 deletions NuGetClientPRHealth/Models.cs
Original file line number Diff line number Diff line change
@@ -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<PRRecord> SlowPRs,
List<PRRecord> AllPRs);
Loading