1- using System . Collections . Concurrent ;
2- using System . ComponentModel ;
1+ using System . ComponentModel ;
32using System . Diagnostics ;
43using System . Globalization ;
54using System . Net ;
98using Devlooped . Web ;
109using DotNetConfig ;
1110using Humanizer ;
11+ using Microsoft . OData ;
1212using NuGet . Configuration ;
1313using NuGet . Packaging ;
1414using 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 }
0 commit comments