Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
83 changes: 83 additions & 0 deletions NuGetClientPRHealth/DashboardService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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

Copilot AI Apr 3, 2026

Copy link

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(),
SlowToReviewPRs: prs.Where(p => p.FirstReviewHours > 24).OrderByDescending(p => p.FirstReviewHours).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 (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<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));
}
}
65 changes: 65 additions & 0 deletions NuGetClientPRHealth/GitCredentials.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Diagnostics;
using System.Text;

namespace NuGetDashboard;

// Implements https://git-scm.com/docs/git-credential#_typical_use_of_git_credential
internal static class GitCredentials
{
/// <summary>
/// Runs "git credential fill" for the given URI, allowing the credential manager
/// to show its interactive UI (browser/dialog) if no valid credentials are cached.
/// Returns null if git is unavailable.
/// </summary>
public static GitToken? Get(Uri uri)
{
var payload = RunCredentialCommand("fill", "url=" + uri.AbsoluteUri + "\n\n");
if (payload is null) return null;

string? password = null;
foreach (var line in payload.Split('\n'))
{
var idx = line.IndexOf('=');
if (idx == -1) continue;
if (line.AsSpan(0, idx).Equals("password", StringComparison.Ordinal))
{
password = line[(idx + 1)..].Trim();
break;
}
}

return password is not null ? new GitToken(password, payload) : null;
}

/// <summary>
/// Runs "git credential reject" to erase a stale/invalid token from the credential
/// store, so the next call to <see cref="Get"/> will prompt for fresh credentials.
/// </summary>
public static void Reject(GitToken token) =>
RunCredentialCommand("reject", token.Payload);

private static string? RunCredentialCommand(string subcommand, string input)
{
var psi = new ProcessStartInfo
{
FileName = "git",
Arguments = $"credential {subcommand}",
CreateNoWindow = false, // allow the credential manager UI to appear
RedirectStandardInput = true,
RedirectStandardOutput = subcommand == "fill",
};

var process = Process.Start(psi);
if (process is null) return null;

process.StandardInput.Write(input);
process.StandardInput.Close();

var output = subcommand == "fill" ? process.StandardOutput.ReadToEnd() : string.Empty;
process.WaitForExit();

return process.ExitCode == 0 ? output : null;
}
}

internal record GitToken(string Password, string Payload);
212 changes: 212 additions & 0 deletions NuGetClientPRHealth/GitHubClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
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

Copilot AI Apr 3, 2026

Copy link

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);
await ThrowIfErrorAsync(resp, "merged PRs search");
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 DateTimes of the first review of any kind and the first APPROVED review.
/// Reviews submitted before <paramref name="effectiveStart"/> are skipped for the first-review result.</summary>
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();

if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
throw new InvalidOperationException(
$"GitHub authentication failed (401) fetching {context}.\n" +
$" Your saved git credential may be expired.\n" +
$" → Run 'git push' on any GitHub repo to refresh it via the credential manager.\n" +
$" → Or pass --token=<pat> explicitly (set GITHUB_TOKEN env var for CI).");
}

// 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();
}
Loading