diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..874d0df30 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copy this file to .env and fill in the values. +# .env is gitignored and will never be committed or affect the published site. + +# Option 1: Skip all GitHub API calls entirely (fastest local builds, no contributor/commit data). +SKIP_CONTRIBUTORS=true + +# Option 2: Use a GitHub Personal Access Token (PAT) for full local builds with real data. +# Generate one at https://github.com/settings/tokens (read:public_repo scope is enough). +# Comment out SKIP_CONTRIBUTORS above and uncomment the line below. +# ACCESS_TOKEN=ghp_your_personal_access_token_here diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 01320cca6..2a364a0a1 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -31,7 +31,8 @@ "Deploy", "PullDnnPackages", "Restore", - "Serve" + "Serve", + "ValidateGitHubToken" ] }, "Verbosity": { @@ -117,6 +118,11 @@ "type": "string", "description": "Github Token" }, + "Port": { + "type": "integer", + "description": "Port to serve the docs on locally (default: 8085, auto-increments if already in use)", + "format": "int32" + }, "Solution": { "type": "string", "description": "Path to a solution file that is automatically loaded" diff --git a/README.md b/README.md index c6b33d12e..12f799c92 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,25 @@ Unless you are part of the DNNDocs core team, you will only have read access (yo ### .NET Framework Prerequisites You should ensure that you already have [.NET 5.0](https://dotnet.microsoft.com/download/dotnet) (used by the `build` project) and the "Developer Pack" for [.NET Framework 4.6.2](https://dotnet.microsoft.com/download/dotnet-framework/net462) (used by the custom `DocFx` plugins) installed on your machine before trying the build process. +### Environment Variables + +The build uses a `.env` file in the root of the repository for local configuration. This file is gitignored so it will never be committed. Copy `.env.example` to get started: + +``` +copy .env.example .env +``` + +Open `.env` and choose one of two modes: + +| Mode | When to use | +|------|-------------| +| `SKIP_CONTRIBUTORS=true` | **Recommended for most contributors.** Skips all GitHub API calls, making local builds much faster. Contributor and last-updated data will be absent from the local preview. | +| `ACCESS_TOKEN=ghp_...` | Use when you need to verify contributor/commit data locally. Generate a token at [github.com/settings/tokens](https://github.com/settings/tokens) with `read:public_repo` scope. | + +> **Note:** Without a token and without `SKIP_CONTRIBUTORS=true`, the build will still work but may be slow or hit the GitHub anonymous rate limit. + +### Running the build + You should now be able to run the development version of the docs locally with the following command: Windows Powershell: diff --git a/build/Build.cs b/build/Build.cs index 57d2f3f56..d14b0c066 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -1,5 +1,10 @@ using System; using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Threading.Tasks; using Nuke.Common; using Nuke.Common.CI.GitHubActions; using Nuke.Common.IO; @@ -53,6 +58,9 @@ class Build : NukeBuild [Parameter("Github Token")] readonly string GithubToken; + [Parameter("Port to serve the docs on locally (default: 8085, auto-increments if already in use)")] + readonly int Port = 8085; + // Nuke features injection. [Solution] readonly Solution Solution; [GitRepository] readonly GitRepository gitRepository; @@ -132,12 +140,86 @@ class Build : NukeBuild }); Target Serve => _ => _ + .DependsOn(ValidateGitHubToken) .DependsOn(Clean) .DependsOn(Restore) .DependsOn(BuildPlugins) .Executes(() => { - DnnDocFX?.Invoke("--serve --open-browser", RootDirectory); + DnnDocFX?.Invoke($"--serve --open-browser --port {FindFreePort(Port)}", RootDirectory); + }); + + static int FindFreePort(int startPort) + { + for (var port = startPort; port < startPort + 20; port++) + { + try + { + var listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + listener.Stop(); + return port; + } + catch (SocketException) { } + } + throw new Exception($"Could not find a free port in range {startPort}-{startPort + 19}."); + } + + Target ValidateGitHubToken => _ => _ + .Executes(async () => + { + // Load .env file manually (DotNetEnv is not a build project dependency) + var envFile = RootDirectory / ".env"; + if (envFile.FileExists()) + { + foreach (var line in System.IO.File.ReadAllLines(envFile)) + { + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) continue; + var parts = line.Split('=', 2); + if (parts.Length == 2) + Environment.SetEnvironmentVariable(parts[0].Trim(), parts[1].Trim()); + } + } + + var token = Environment.GetEnvironmentVariable("ACCESS_TOKEN"); + if (string.IsNullOrEmpty(token)) + token = GithubToken; + + if (string.IsNullOrEmpty(token)) + { + Serilog.Log.Warning("No GitHub token found. Contributor and commit data will be skipped."); + Serilog.Log.Warning("To enable it, set ACCESS_TOKEN in your .env file (see .env.example)."); + Environment.SetEnvironmentVariable("SKIP_CONTRIBUTORS", "true"); + return; + } + + using var http = new HttpClient(); + http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("DNNDocs-Build", "1.0")); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await http.GetAsync("https://api.github.com/user"); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + Assert.Fail("GitHub token is invalid or has expired. Generate a new one at https://github.com/settings/tokens"); + + if (!response.IsSuccessStatusCode) + Assert.Fail($"GitHub token validation returned an unexpected status: {(int)response.StatusCode} {response.ReasonPhrase}"); + + // Check that the token has the required scope + response.Headers.TryGetValues("X-OAuth-Scopes", out var scopeValues); + var scopes = (scopeValues?.FirstOrDefault() ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (scopes.Length > 0 && !scopes.Contains("public_repo") && !scopes.Contains("repo")) + Assert.Fail($"GitHub token is missing the 'public_repo' scope. Current scopes: {string.Join(", ", scopes)}. " + + "Regenerate it at https://github.com/settings/tokens"); + + var login = await response.Content.ReadAsStringAsync(); + var loginStart = login.IndexOf("\"login\":\"") + 9; + var loginEnd = login.IndexOf('"', loginStart); + var username = loginStart > 8 ? login[loginStart..loginEnd] : "unknown"; + + Serilog.Log.Information("GitHub token is valid. Authenticated as: {Username}", username); }); Target CreateDeployBranch => _ => _ @@ -151,6 +233,7 @@ class Build : NukeBuild Target Deploy => _ => _ .OnlyWhenDynamic(() => gitRepository.ToString() == $"https://github.com/{organizationName}/{repositoryName}") + .DependsOn(ValidateGitHubToken) .DependsOn(CreateDeployBranch) .DependsOn(Compile) .Executes(() => { diff --git a/plugins/DNNCommunity.DNNDocs.Plugins/Models/Contributor.cs b/plugins/DNNCommunity.DNNDocs.Plugins/Models/Contributor.cs index 555af2349..f6b46558f 100644 --- a/plugins/DNNCommunity.DNNDocs.Plugins/Models/Contributor.cs +++ b/plugins/DNNCommunity.DNNDocs.Plugins/Models/Contributor.cs @@ -15,5 +15,11 @@ public class Contributor [JsonProperty(PropertyName = "contributions")] public string Contributions { get; set; } + + /// Number of commits by this author in the sampled window. + public int Total { get; set; } + + /// Date of the most recent commit by this author. + public DateTimeOffset LatestCommitDate { get; set; } } } diff --git a/plugins/DNNCommunity.DNNDocs.Plugins/MoreLinksBuildStep.cs b/plugins/DNNCommunity.DNNDocs.Plugins/MoreLinksBuildStep.cs index af631ff61..a7b23a571 100644 --- a/plugins/DNNCommunity.DNNDocs.Plugins/MoreLinksBuildStep.cs +++ b/plugins/DNNCommunity.DNNDocs.Plugins/MoreLinksBuildStep.cs @@ -1,4 +1,5 @@ -using Docfx.Plugins; +using Docfx.Common; +using Docfx.Plugins; using System.Collections.Immutable; using System.Composition; using System.Text.RegularExpressions; @@ -14,7 +15,7 @@ public class MoreLinksBuildStep : IDocumentBuildStep public MoreLinksBuildStep() { - Console.WriteLine($"[PLUGIN] {nameof(MoreLinksBuildStep)} loaded"); + Logger.LogInfo($"{nameof(MoreLinksBuildStep)} loaded."); } public class Link @@ -58,10 +59,7 @@ public void Postbuild(ImmutableList models, IHostService host) } if (newLinks.Count > 0) { - foreach (var link in newLinks) - { - Console.WriteLine($"[PLUGIN] {nameof(MoreLinksBuildStep)}: {link.Url} related topic added for {model.File}"); - } + Logger.LogVerbose($"{nameof(MoreLinksBuildStep)}: {newLinks.Count} related topic(s) added for {model.File}"); AddLinksDivToContents(contents, "related-topics", newLinks); } } diff --git a/plugins/DNNCommunity.DNNDocs.Plugins/PageStatsBuildStep.cs b/plugins/DNNCommunity.DNNDocs.Plugins/PageStatsBuildStep.cs index 5f606ca89..44277ee33 100644 --- a/plugins/DNNCommunity.DNNDocs.Plugins/PageStatsBuildStep.cs +++ b/plugins/DNNCommunity.DNNDocs.Plugins/PageStatsBuildStep.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using System.Composition; using System.Diagnostics; +using Docfx.Common; using DNNCommunity.DNNDocs.Plugins.Models; using DNNCommunity.DNNDocs.Plugins.Providers; using Docfx.Plugins; @@ -16,7 +17,7 @@ public class PageStatsBuildStep : IDocumentBuildStep public PageStatsBuildStep() { - Console.WriteLine($"[PLUGIN] {nameof(PageStatsBuildStep)} loaded."); + Logger.LogInfo($"{nameof(PageStatsBuildStep)} loaded."); } public void Build(FileModel model, IHostService host) @@ -24,32 +25,36 @@ public void Build(FileModel model, IHostService host) } public void Postbuild(ImmutableList models, IHostService host) - { + => PostbuildAsync(models, host).GetAwaiter().GetResult(); + private async Task PostbuildAsync(ImmutableList models, IHostService host) + { if (models == null || models.Count == 0) { - Console.WriteLine("[PLUGIN] No models to process."); + Logger.LogWarning($"{nameof(PageStatsBuildStep)}: No models to process."); return; } - Console.WriteLine($"[PLUGIN] {nameof(PageStatsBuildStep)} Processing {models.Count} models"); + var articleModels = models + .Where(m => m.Type == DocumentType.Article) + .Where(m => !m.LocalPathFromRoot.StartsWith("content/reference/", StringComparison.OrdinalIgnoreCase)) + .ToList(); + Logger.LogInfo($"{nameof(PageStatsBuildStep)}: Processing {articleModels.Count} article models (reference pages excluded)."); - var gitHubApi = new GitHubApi(); // Optionally pass token + var gitHubApi = new GitHubApi(); + var totalSw = Stopwatch.StartNew(); - foreach (var model in models) + foreach (var item in articleModels.Select((value, index) => new { value, index })) { + if ((item.index + 1) % 100 == 0 || item.index + 1 == articleModels.Count) + { + Logger.LogInfo($"{nameof(PageStatsBuildStep)}: Processing model {item.index + 1}/{articleModels.Count} ({totalSw.Elapsed.TotalSeconds:F1}s elapsed)."); + } - if (model.Type != DocumentType.Article) continue; - - // Build the relative path for the GitHub API query - string transformedFilePath = model.LocalPathFromRoot.Replace("/", "%2F"); - - // Get commits for the specific page/file - var gitCommits = gitHubApi.GetCommitsAsync(model.LocalPathFromRoot).GetAwaiter().GetResult(); + var gitCommits = await gitHubApi.GetCommitsAsync(item.value.LocalPathFromRoot); if (gitCommits != null && gitCommits.Count > 0) { - // Group by author login and order by number of commits var topContributors = gitCommits .Where(c => c.Author != null && !string.IsNullOrEmpty(c.Author.Login)) .GroupBy(c => c.Author.Login) @@ -58,27 +63,23 @@ public void Postbuild(ImmutableList models, IHostService host) .Take(5) .ToList(); - var content = (Dictionary)model.Content; + var content = (Dictionary)item.value.Content; - var contributorsList = new List(); int index = 1; foreach (var commit in topContributors) { content[$"gitPageContributor{index}Login"] = commit.Author.Login; content[$"gitPageContributor{index}AvatarUrl"] = commit.Author.AvatarUrl; content[$"gitPageContributor{index}HtmlUrl"] = commit.Author.HtmlUrl; - - contributorsList.Add(commit.Author.Login); index++; } - // Set the page last updated date based on the latest commit var lastUpdated = gitCommits[0].Commit.Author.Date.DateTime.ToShortDateString(); content["gitPageDate"] = lastUpdated; - - Console.WriteLine($"[PLUGIN] {nameof(PageStatsBuildStep)} processed {model.File} | Contributors: {string.Join(", ", contributorsList)} | Last Updated: {lastUpdated}"); } } + + Logger.LogInfo($"{nameof(PageStatsBuildStep)}: Finished in {totalSw.Elapsed.TotalSeconds:F1}s."); } diff --git a/plugins/DNNCommunity.DNNDocs.Plugins/Providers/GitHubApi.cs b/plugins/DNNCommunity.DNNDocs.Plugins/Providers/GitHubApi.cs index a65b57e1d..230e684a1 100644 --- a/plugins/DNNCommunity.DNNDocs.Plugins/Providers/GitHubApi.cs +++ b/plugins/DNNCommunity.DNNDocs.Plugins/Providers/GitHubApi.cs @@ -1,4 +1,5 @@ -using Octokit; +using Docfx.Common; +using Octokit; namespace DNNCommunity.DNNDocs.Plugins.Providers { @@ -15,6 +16,8 @@ public GitHubApi() { DotNetEnv.Env.Load(); // Loads .env file if it exists, into environment variables. client = new GitHubClient(new ProductHeaderValue("DNNDocsPlugin")); + // Prevent any single API call from hanging indefinitely. + client.Connection.SetRequestTimeout(TimeSpan.FromSeconds(30)); var skip = Environment.GetEnvironmentVariable("SKIP_CONTRIBUTORS") == "true"; if (!skip) @@ -26,6 +29,10 @@ public GitHubApi() this.token = Environment.GetEnvironmentVariable("GithubToken"); } } + else + { + Logger.LogInfo("[RepoStats] SKIP_CONTRIBUTORS=true — all GitHub API calls will be skipped."); + } if (!string.IsNullOrEmpty(this.token)) { @@ -35,40 +42,64 @@ public GitHubApi() try { var rateLimits = client.RateLimit.GetRateLimits().GetAwaiter().GetResult(); - Console.WriteLine($"[RepoStats] GitHub API remaining: {rateLimits.Resources.Core.Remaining}/{rateLimits.Resources.Core.Limit}, resets at: {rateLimits.Resources.Core.Reset}"); + Logger.LogInfo($"[RepoStats] GitHub API remaining: {rateLimits.Resources.Core.Remaining}/{rateLimits.Resources.Core.Limit}, resets at: {rateLimits.Resources.Core.Reset}"); } catch (Exception ex) { - Console.WriteLine($"[RepoStats] WARNING: Could not retrieve GitHub rate limit info: {ex.Message}"); + Logger.LogWarning($"[RepoStats] Could not retrieve GitHub rate limit info: {ex.Message}"); } } - public async Task> GetContributorsAsync() + /// + /// Derives contributor stats from commit history, avoiding the GitHub Statistics API + /// which returns 202 indefinitely for cold repos and cannot be reliably cancelled. + /// + public async Task> GetContributorsAsync() { if (string.IsNullOrEmpty(this.token)) { - Console.WriteLine("[RepoStats] No GitHub token configured — skipping contributor stats."); - return new List(); + Logger.LogWarning("[RepoStats] No GitHub token configured — skipping contributor stats."); + return new List(); } if (!await this.ThrottleIfNeeded()) { - return new List(); + return new List(); } try { - return await client.Repository.Statistics.GetContributors("DNNCommunity", "DNNDocs"); + Logger.LogInfo("[RepoStats] Fetching commits to derive contributor stats (10 pages)..."); + var commits = await client.Repository.Commit.GetAll( + "DNNCommunity", "DNNDocs", + new CommitRequest(), + new ApiOptions { PageSize = 100, PageCount = 10 }); + + var contributors = commits + .Where(c => c.Author != null && !string.IsNullOrEmpty(c.Author.Login)) + .GroupBy(c => c.Author.Login) + .Select(g => new Models.Contributor + { + Login = g.Key, + AvatarUrl = g.First().Author.AvatarUrl, + HtmlUrl = g.First().Author.HtmlUrl, + Total = g.Count(), + LatestCommitDate = g.Max(c => c.Commit.Author.Date), + }) + .ToList(); + + Logger.LogInfo($"[RepoStats] Derived {contributors.Count} contributors from {commits.Count} commits."); + return contributors; } catch (RateLimitExceededException ex) { - Console.WriteLine($"[RepoStats] WARNING: GitHub API rate limit exceeded while fetching contributors. Skipping stats. Reset at: {ex.Reset}"); - return new List(); + Logger.LogWarning($"[RepoStats] GitHub API rate limit exceeded while fetching contributors. Skipping stats. Reset at: {ex.Reset}"); + return new List(); } catch (Exception ex) { - Console.WriteLine($"[RepoStats] WARNING: Failed to fetch contributors: {ex.Message}. Skipping stats."); - return new List(); + Logger.LogWarning($"[RepoStats] Failed to fetch contributors: {ex.Message}. Skipping stats."); + return new List(); } } @@ -76,7 +107,7 @@ public async Task> GetCommitsAsync(string path = "") { if (string.IsNullOrEmpty(this.token)) { - Console.WriteLine("[RepoStats] No GitHub token configured — skipping commit stats."); + Logger.LogWarning("[RepoStats] No GitHub token configured — skipping commit stats."); return new List(); } @@ -91,16 +122,26 @@ public async Task> GetCommitsAsync(string path = "") if (!string.IsNullOrEmpty(path)) request.Path = path; - return await client.Repository.Commit.GetAll("DNNCommunity", "DNNDocs", request); + // When fetching global commits (for recent-contributors), we only need enough + // to find 5 unique authors — cap at 3 pages (max 300 commits) to avoid + // paginating through the entire repo history. + var options = string.IsNullOrEmpty(path) + ? new ApiOptions { PageSize = 100, PageCount = 3 } + : new ApiOptions { PageSize = 100 }; + + Logger.LogInfo($"[RepoStats] Fetching commits{(string.IsNullOrEmpty(path) ? "" : $" for {path}")}..."); + var commits = await client.Repository.Commit.GetAll("DNNCommunity", "DNNDocs", request, options); + Logger.LogInfo($"[RepoStats] Fetched {commits.Count} commits{(string.IsNullOrEmpty(path) ? "" : $" for {path}")}"); + return commits; } catch (RateLimitExceededException ex) { - Console.WriteLine($"[RepoStats] WARNING: GitHub API rate limit exceeded while fetching commits. Skipping stats. Reset at: {ex.Reset}"); + Logger.LogWarning($"[RepoStats] GitHub API rate limit exceeded while fetching commits. Skipping stats. Reset at: {ex.Reset}"); return new List(); } catch (Exception ex) { - Console.WriteLine($"[RepoStats] WARNING: Failed to fetch commits: {ex.Message}. Skipping stats."); + Logger.LogWarning($"[RepoStats] Failed to fetch commits: {ex.Message}. Skipping stats."); return new List(); } } @@ -122,7 +163,7 @@ private async Task ThrottleIfNeeded() if (remaining == 0) { var waitSeconds = (int)(reset - DateTimeOffset.UtcNow).TotalSeconds + 5; - Console.WriteLine($"[RepoStats] WARNING: GitHub API rate limit fully exhausted (0/{limit}). Resets at {reset} UTC (~{waitSeconds}s). Skipping stats to avoid blocking the build."); + Logger.LogWarning($"[RepoStats] GitHub API rate limit fully exhausted (0/{limit}). Resets at {reset} UTC (~{waitSeconds}s). Skipping stats to avoid blocking the build."); return false; } @@ -131,7 +172,7 @@ private async Task ThrottleIfNeeded() int delayFactor = RateLimitThreshold - remaining; int delayMs = (int)Math.Min(Math.Pow(ExponentialBase, delayFactor), MaxDelayMs); - Console.WriteLine($"[RepoStats] Nearing GitHub API rate limit, applying backoff of {delayMs} ms. Remaining: {remaining}/{limit}, resets at {reset}."); + Logger.LogInfo($"[RepoStats] Nearing GitHub API rate limit, applying backoff of {delayMs} ms. Remaining: {remaining}/{limit}, resets at {reset}."); await Task.Delay(delayMs); } @@ -139,12 +180,12 @@ private async Task ThrottleIfNeeded() } catch (RateLimitExceededException ex) { - Console.WriteLine($"[RepoStats] WARNING: GitHub API rate limit exceeded during throttle check. Skipping stats. Reset at: {ex.Reset}"); + Logger.LogWarning($"[RepoStats] GitHub API rate limit exceeded during throttle check. Skipping stats. Reset at: {ex.Reset}"); return false; } catch (Exception ex) { - Console.WriteLine($"[RepoStats] WARNING: Could not check rate limit ({ex.Message}). Proceeding cautiously."); + Logger.LogWarning($"[RepoStats] Could not check rate limit ({ex.Message}). Proceeding cautiously."); return true; } } diff --git a/plugins/DNNCommunity.DNNDocs.Plugins/RepoStatsBuildStep.cs b/plugins/DNNCommunity.DNNDocs.Plugins/RepoStatsBuildStep.cs index 534ec450d..0369b6989 100644 --- a/plugins/DNNCommunity.DNNDocs.Plugins/RepoStatsBuildStep.cs +++ b/plugins/DNNCommunity.DNNDocs.Plugins/RepoStatsBuildStep.cs @@ -1,10 +1,10 @@ using System.Collections.Immutable; using System.Composition; using System.Diagnostics; +using Docfx.Common; using DNNCommunity.DNNDocs.Plugins.Models; using DNNCommunity.DNNDocs.Plugins.Providers; using Docfx.Plugins; -using Octokit; namespace DNNCommunity.DNNDocs.Plugins { @@ -17,7 +17,7 @@ public class RepoStatsBuildStep : IDocumentBuildStep public RepoStatsBuildStep() { - Console.WriteLine($"[plugin] {nameof(RepoStatsBuildStep)} loaded."); + Logger.LogInfo($"{nameof(RepoStatsBuildStep)} loaded."); } public void Build(FileModel model, IHostService host) @@ -32,93 +32,71 @@ private async Task PostbuildAsync(ImmutableList models, IHostService { if (models == null || models.Count == 0) { - Console.WriteLine("[PLUGIN] No models to process."); + Logger.LogWarning($"{nameof(RepoStatsBuildStep)}: No models to process."); return; } - Console.WriteLine($"[PLUGIN] {nameof(RepoStatsBuildStep)} Processing Repo Stats for {models.Count()} models"); + var articleModels = models.Where(m => m.Type == DocumentType.Article).ToList(); + Logger.LogInfo($"{nameof(RepoStatsBuildStep)}: Processing repo stats for {articleModels.Count} article models."); var gitHubApi = new GitHubApi(); - List contributors; - List commits; + List contributors; try { + var sw = Stopwatch.StartNew(); contributors = (await gitHubApi.GetContributorsAsync()).ToList(); - Console.WriteLine($"[RepoStats] Found {contributors.Count} contributors"); - - commits = (await gitHubApi.GetCommitsAsync()).ToList(); - // Order the commits by date - commits = commits.OrderByDescending(c => c.Commit.Author.Date).ToList(); - Console.WriteLine($"[RepoStats] Found {commits.Count} commits"); + Logger.LogInfo($"{nameof(RepoStatsBuildStep)}: Fetched {contributors.Count} contributors in {sw.Elapsed.TotalSeconds:F1}s."); } catch (Exception ex) { - Console.WriteLine($"[RepoStats] WARNING: Failed to retrieve GitHub stats ({ex.Message}). Skipping contributor data for this build."); + Logger.LogWarning($"{nameof(RepoStatsBuildStep)}: Failed to retrieve GitHub stats ({ex.Message}). Skipping contributor data for this build."); + return; + } + + if (!contributors.Any()) + { return; } - if (contributors.Any() && commits.Any()) + // Top contributors: most commits in the sampled window. + var topContributors = contributors.OrderByDescending(c => c.Total).Take(5).ToList(); + Logger.LogInfo($"{nameof(RepoStatsBuildStep)}: Top contributors: {string.Join(", ", topContributors.Select(c => c.Login))}"); + + // Recent contributors: most recent commit date. + var recentContributors = contributors.OrderByDescending(c => c.LatestCommitDate).Take(5).ToList(); + Logger.LogInfo($"{nameof(RepoStatsBuildStep)}: Recent contributors: {string.Join(", ", recentContributors.Select(c => c.Login))}"); + + var totalSw = Stopwatch.StartNew(); + foreach (var item in articleModels.Select((value, index) => new { value, index })) { - foreach (var model in models.Select((value, index) => new { value, index })) + if ((item.index + 1) % 100 == 0 || item.index + 1 == articleModels.Count) { - Console.WriteLine($"Processing model {model.index + 1} of {models.Count}"); - - if (model.value.Type == DocumentType.Article) - { - var content = (Dictionary)model.value.Content; - Console.WriteLine($"Processing Article : {model.value.OriginalFileAndType.FullPath}"); - - // Add top 5 contributors (or fewer if not enough) - var topContributors = contributors = contributors.OrderByDescending(c => c.Total).ToList(); - for (var i = 0; i < Math.Min(5, topContributors.Count); i++) - { - var contributor = contributors[i]; - content[$"gitContributor{i + 1}Contributions"] = contributor.Total; - content[$"gitContributor{i + 1}Login"] = contributor.Author.Login; - content[$"gitContributor{i + 1}AvatarUrl"] = contributor.Author.AvatarUrl; - content[$"gitContributor{i + 1}HtmlUrl"] = contributor.Author.HtmlUrl; - - Console.WriteLine($"Added contributor {i + 1}: {contributor.Author.Login}"); - } - - var recentContributors = new List(); - var seen = new HashSet(); - - foreach (var commit in commits) - { - var login = commit.Author?.Login; - if (login != null && seen.Add(login)) - { - var contributor = contributors.FirstOrDefault(c => c.Author.Login == login); - if (contributor != null) - { - recentContributors.Add(contributor); - } - - if (recentContributors.Count == 5) - { - break; - } - } - } - - Console.WriteLine($"Selected top {recentContributors.Count} recent contributors based on commits."); - - for (var i = 0; i < recentContributors.Count; i++) - { - var author = recentContributors[i].Author; - - content[$"gitRecentContributor{i + 1}Login"] = author.Login; - content[$"gitRecentContributor{i + 1}AvatarUrl"] = author.AvatarUrl; - content[$"gitRecentContributor{i + 1}HtmlUrl"] = author.HtmlUrl; - - Console.WriteLine($"Added recent contributor {i + 1}: {author.Login}"); - } - } + Logger.LogInfo($"{nameof(RepoStatsBuildStep)}: Stamping model {item.index + 1}/{articleModels.Count} ({totalSw.Elapsed.TotalSeconds:F1}s elapsed)."); + } + + var content = (Dictionary)item.value.Content; + + for (var i = 0; i < topContributors.Count; i++) + { + var c = topContributors[i]; + content[$"gitContributor{i + 1}Contributions"] = c.Total; + content[$"gitContributor{i + 1}Login"] = c.Login; + content[$"gitContributor{i + 1}AvatarUrl"] = c.AvatarUrl; + content[$"gitContributor{i + 1}HtmlUrl"] = c.HtmlUrl; + } + + for (var i = 0; i < recentContributors.Count; i++) + { + var c = recentContributors[i]; + content[$"gitRecentContributor{i + 1}Login"] = c.Login; + content[$"gitRecentContributor{i + 1}AvatarUrl"] = c.AvatarUrl; + content[$"gitRecentContributor{i + 1}HtmlUrl"] = c.HtmlUrl; } } + + Logger.LogInfo($"{nameof(RepoStatsBuildStep)}: Finished stamping {articleModels.Count} models in {totalSw.Elapsed.TotalSeconds:F1}s."); } public IEnumerable Prebuild(ImmutableList models, IHostService host)