Skip to content

Commit e409d24

Browse files
committed
Allow fetching specific nuget owner stats
This is useful for populating owner-specific badges without having to scrap the whole nuget.org
1 parent 1d3583c commit e409d24

File tree

3 files changed

+93
-44
lines changed

3 files changed

+93
-44
lines changed

src/Commands/NuGetStatsCommand.cs

Lines changed: 89 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
using System.Collections.Concurrent;
2-
using System.ComponentModel;
1+
using System.ComponentModel;
32
using System.Diagnostics;
43
using System.Globalization;
54
using System.Net;
@@ -9,6 +8,7 @@
98
using Devlooped.Web;
109
using DotNetConfig;
1110
using Humanizer;
11+
using Microsoft.OData;
1212
using NuGet.Configuration;
1313
using NuGet.Packaging;
1414
using NuGet.Packaging.Core;
@@ -62,6 +62,32 @@ public class NuGetStatsSettings : ToSSettings
6262
[Description("Pages to skip")]
6363
[CommandOption("--skip", IsHidden = true)]
6464
public int Skip { get; set; }
65+
66+
[Description("Specific package owner to fetch full stats for")]
67+
[CommandOption("--owner")]
68+
public string? Owner { get; set; }
69+
70+
[Description("Only include OSS packages hosted on GitHub")]
71+
[DefaultValue(true)]
72+
[CommandOption("--gh-only")]
73+
public bool GitHubOnly { get; set; } = true;
74+
75+
[Description("Only include OSS packages")]
76+
[DefaultValue(true)]
77+
[CommandOption("--oss-only")]
78+
public bool OssOnly { get; set; } = true;
79+
80+
public override ValidationResult Validate()
81+
{
82+
if (OssOnly == false && Owner == null)
83+
return ValidationResult.Error("Non-OSS packages can only be fetched for a specific owner.");
84+
85+
// If not requesting OSS, change default for GH only.
86+
if (OssOnly == false)
87+
GitHubOnly = false;
88+
89+
return base.Validate();
90+
}
6591
}
6692

6793
public override async Task<int> ExecuteAsync(CommandContext context, NuGetStatsSettings settings)
@@ -82,12 +108,12 @@ public override async Task<int> ExecuteAsync(CommandContext context, NuGetStatsS
82108
.Or<NullReferenceException>()
83109
.WaitAndRetryForeverAsync(retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
84110

85-
// gh api repos/dotnet/aspnetcore/contributors --paginate | jq '[.[] | .login]'
111+
var fileName = (settings.Owner ?? "nuget") + ".json";
86112

87113
// The resulting model we'll populate.
88114
OpenSource model;
89-
if (File.Exists("nuget.json") && settings.Force != true)
90-
model = JsonSerializer.Deserialize<OpenSource>(File.ReadAllText("nuget.json"), JsonOptions.Default) ?? new OpenSource([], [], []);
115+
if (File.Exists(fileName) && settings.Force != true)
116+
model = JsonSerializer.Deserialize<OpenSource>(File.ReadAllText(fileName), JsonOptions.Default) ?? new OpenSource([], [], []);
91117
else
92118
model = new OpenSource([], [], []);
93119

@@ -104,11 +130,15 @@ public override async Task<int> ExecuteAsync(CommandContext context, NuGetStatsS
104130
if (settings.Skip > 0)
105131
index = settings.Skip + 1;
106132

133+
var baseUrl = "https://www.nuget.org/packages?sortBy=totalDownloads-desc&";
134+
if (settings.Owner != null)
135+
baseUrl += $"q=owner%3A{settings.Owner}&";
136+
107137
await progress.StartAsync(async context =>
108138
{
109139
while (true)
110140
{
111-
var listUrl = $"https://www.nuget.org/packages?page={index}&prerel=false&sortBy=totalDownloads-desc";
141+
var listUrl = $"{baseUrl}page={index}";
112142
AnsiConsole.MarkupLine($":globe_with_meridians: [aqua][link={listUrl}]packages#{index}[/][/]");
113143
var listTask = context.AddTask($":backhand_index_pointing_right: [grey]Processing page[/] [aqua][link={listUrl}]#{index}[/][/][grey]. Total[/] [lime]{model.Authors.Count}[/] [grey]oss authors so far across[/] {model.Repositories.Count} [grey]repos[/]", false);
114144
// Parse search page
@@ -131,8 +161,10 @@ await progress.StartAsync(async context =>
131161
}
132162

133163
// Skip corp owners
134-
var ids = allIds
135-
.Where(x => !x.Any(i => SkippedOwners.Contains(i.Owner)))
164+
var ids = (settings.Owner != null
165+
// Don't filter anything if we're fetching a specific owner
166+
? allIds
167+
: allIds.Where(x => settings.Owner == null && !x.Any(i => SkippedOwners.Contains(i.Owner))))
136168
.Select(x => new PackageIdentity(x.Key, NuGetVersion.Parse(x.First().Version)))
137169
.ToList();
138170

@@ -188,11 +220,12 @@ await Parallel.ForEachAsync(tasks, paralell, async (source, cancellation) =>
188220
return;
189221
}
190222

191-
if (!string.IsNullOrEmpty(repoMeta?.Url))
223+
if (!string.IsNullOrEmpty(repoMeta?.Url) &&
224+
Uri.TryCreate(repoMeta.Url, UriKind.Absolute, out var uri))
192225
{
193226
repoUrl = repoMeta.Url;
194-
if (!Uri.TryCreate(repoUrl, UriKind.Absolute, out var uri) ||
195-
uri.Host != "github.com")
227+
228+
if (settings.GitHubOnly && uri.Host != "github.com")
196229
{
197230
task.Description = $":cross_mark: [yellow]{link}[/]: non GitHub source, skipping";
198231
return;
@@ -203,37 +236,44 @@ await Parallel.ForEachAsync(tasks, paralell, async (source, cancellation) =>
203236
// change scheme to https
204237
uri = new UriBuilder(uri) { Scheme = "https", Port = 443 }.Uri;
205238

206-
try
239+
if (settings.GitHubOnly)
207240
{
208-
if (!(await http.SendAsync(new(HttpMethod.Head, uri), cancellation)).IsSuccessStatusCode)
241+
// Ensure we get an existing GH source repo as requested
242+
try
243+
{
244+
if (!(await http.SendAsync(new(HttpMethod.Head, uri), cancellation)).IsSuccessStatusCode)
245+
{
246+
task.Description = $":cross_mark: [yellow]{link}[/]: GitHub repo from nuspec not found at {uri}";
247+
return;
248+
}
249+
}
250+
catch (Exception)
209251
{
210252
task.Description = $":cross_mark: [yellow]{link}[/]: GitHub repo from nuspec not found at {uri}";
211253
return;
212254
}
213255
}
214-
catch (Exception)
215-
{
216-
task.Description = $":cross_mark: [yellow]{link}[/]: GitHub repo from nuspec not found at {uri}";
217-
return;
218-
}
219256

220257
ownerRepo = uri.PathAndQuery.TrimStart('/');
221258
if (ownerRepo.EndsWith(".git"))
222259
ownerRepo = ownerRepo[..^4];
223260

224261
var parts = ownerRepo.Split(['/'], StringSplitOptions.RemoveEmptyEntries);
225-
if (parts.Length < 2)
226-
{
227-
task.Description = $":cross_mark: [yellow]{link}[/]: source URL '{uri}' missing specific repo";
228-
return;
229-
}
230-
else if (parts.Length > 2)
262+
if (uri.Host == "github.com")
231263
{
232-
ownerRepo = string.Join('/', ownerRepo.Split(['/'], StringSplitOptions.RemoveEmptyEntries)[..2]);
264+
if (parts.Length < 2)
265+
{
266+
task.Description = $":cross_mark: [yellow]{link}[/]: source URL '{uri}' missing specific repo";
267+
return;
268+
}
269+
else if (parts.Length > 2)
270+
{
271+
ownerRepo = string.Join('/', ownerRepo.Split(['/'], StringSplitOptions.RemoveEmptyEntries)[..2]);
272+
}
233273
}
234274
// otherwise just keep the original.
235275
}
236-
else
276+
else if (settings.OssOnly)
237277
{
238278
// stop as there's no repo info even if we got nuspec ok
239279
task.Description = $":locked: [yellow]{link}[/]: no source repo information";
@@ -322,26 +362,35 @@ await Parallel.ForEachAsync(tasks, paralell, async (source, cancellation) =>
322362
var daysSince = Convert.ToInt32(Math.Max(1, Math.Round((DateTimeOffset.UtcNow - updated).TotalDays)));
323363
var dailyDownloads = Convert.ToInt32(downloads / daysSince);
324364
// We only consider the package "active" if it's got a minimum amount of downloads per day in the last x versions we consider.
325-
if (dailyDownloads < DailyDownloadsThreshold)
365+
// We don't filter inactive packages if we're fetching a specific owner.
366+
if (settings.Owner == null && dailyDownloads < DailyDownloadsThreshold)
326367
{
327368
inactive++;
328369
task.Description = $":thumbs_down: [yellow]{link}[/]: skipping with {dailyDownloads} downloads/day";
329370
return;
330371
}
331372

332-
// Check contributors only once per repo, since multiple packages can come out of the same repository
333-
if (!model.Repositories.ContainsKey(ownerRepo))
373+
if (ownerRepo != null)
334374
{
335-
var contribs = await graph.QueryAsync(GraphQueries.RepositoryContributors(ownerRepo));
336-
if (contribs != null)
337-
model.Repositories.TryAdd(ownerRepo, new(contribs));
338-
}
375+
// Check contributors only once per repo, since multiple packages can come out of the same repository
376+
if (!model.Repositories.ContainsKey(ownerRepo))
377+
{
378+
var contribs = await graph.QueryAsync(GraphQueries.RepositoryContributors(ownerRepo));
379+
if (contribs != null)
380+
model.Repositories.TryAdd(ownerRepo, new(contribs));
381+
else
382+
// Might not be a GH repo at all, or perhaps it's just empty?
383+
model.Repositories.TryAdd(ownerRepo, []);
384+
}
339385

340-
foreach (var author in model.Repositories[ownerRepo])
341-
model.Authors.GetOrAdd(author, []).Add(ownerRepo);
386+
foreach (var author in model.Repositories[ownerRepo])
387+
model.Authors.GetOrAdd(author, []).Add(ownerRepo);
388+
}
342389

343-
model.Packages.GetOrAdd(ownerRepo, []).TryAdd(id.Id, dailyDownloads);
344-
task.Description = $":check_mark_button: [deepskyblue1]{link}[/]: [white]{ownerRepo}[/] [grey]has[/] [lime]{model.Repositories[ownerRepo].Count}[/] [grey]contributors.[/]";
390+
// If we allow non-oss packages, we won't have an ownerRepo, so consider that an empty string.
391+
model.Packages.GetOrAdd(ownerRepo ?? "", []).TryAdd(id.Id, dailyDownloads);
392+
if (ownerRepo != null)
393+
task.Description = $":check_mark_button: [deepskyblue1]{link}[/]: [white]{ownerRepo}[/] [grey]has[/] [lime]{model.Repositories[ownerRepo].Count}[/] [grey]contributors.[/]";
345394
}
346395
finally
347396
{
@@ -354,7 +403,7 @@ await Parallel.ForEachAsync(tasks, paralell, async (source, cancellation) =>
354403
listTask.Description = $":hourglass_not_done: [grey]Finished page[/] [aqua]#{index}[/][grey]. Persisting model...[/]";
355404
lock (model)
356405
{
357-
File.WriteAllText("nuget.json", JsonSerializer.Serialize(model, JsonOptions.Default));
406+
File.WriteAllText(fileName, JsonSerializer.Serialize(model, JsonOptions.Default));
358407
}
359408

360409
listTask.Description = $":call_me_hand: [grey]Finished page[/] [aqua]#{index}[/][grey]. Total[/] [lime]{model.Authors.Count}[/] [grey]oss authors so far across[/] {model.Repositories.Count} [grey]repos.[/]";
@@ -363,8 +412,8 @@ await Parallel.ForEachAsync(tasks, paralell, async (source, cancellation) =>
363412
}
364413
});
365414

366-
var path = new FileInfo("nuget.json").FullName;
367-
AnsiConsole.MarkupLine($"Total [lime]{model.Authors.Count}[/] oss authors across {model.Repositories.Count} repos => [link={path}]nuget.json[/]");
415+
var path = new FileInfo(fileName).FullName;
416+
AnsiConsole.MarkupLine($"Total [lime]{model.Authors.Count}[/] oss authors across {model.Repositories.Count} repos => [link={path}]{fileName}[/]");
368417

369418
return 0;
370419
}

src/Web/Stats.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
using Microsoft.Azure.Functions.Worker.Http;
2-
using Microsoft.Azure.Functions.Worker;
3-
using System.Net;
1+
using System.Net;
42
using Humanizer;
3+
using Microsoft.Azure.Functions.Worker;
4+
using Microsoft.Azure.Functions.Worker.Http;
55

66
namespace Devlooped.Sponsors;
77

src/Web/Webhook.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ await issues.AddSponsorship(
3232

3333
await base.ProcessSponsorshipWebhookAsync(headers, payload, action);
3434
}
35-
35+
3636
protected override async Task ProcessIssueCommentWebhookAsync(WebhookHeaders headers, IssueCommentEvent payload, IssueCommentAction action)
3737
{
3838
if (await issues.UpdateBacked(github, payload.Repository?.Id, (int)payload.Issue.Number) == false)

0 commit comments

Comments
 (0)