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)