Skip to content

Commit dbd7bbe

Browse files
authored
Merge pull request #16 from pkgdiff-dev/gsd/quick-261021-analysis-fixes
fix(analysis): categorise notes by their section and stop early only on looked-up versions
2 parents c8a54ba + daff55f commit dbd7bbe

7 files changed

Lines changed: 165 additions & 7 deletions

File tree

‎src/backend/PkgDiff.API/Extensions/HostingServiceCollectionExtensions.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ public static IServiceCollection AddPkgDiffBackgroundJobs(this IServiceCollectio
111111
services.AddScoped<PackageAnalysisJob>();
112112
services.AddScoped<PackageRefreshJob>();
113113
services.AddScoped<DatabaseCleanupJob>();
114+
services.AddScoped<ReleaseNoteReparseJob>();
114115

115116
services.AddSingleton<HangfireDashboardAccess>();
116117
services.AddScoped<IAdminRoleChecker, IdentityAdminRoleChecker>();

‎src/backend/PkgDiff.API/Extensions/StartupTasks.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ public static async Task ScheduleRecurringJobsAsync(this WebApplication app)
8484
"autopilot-blog-generator",
8585
job => job.RunAutoPilotJobAsync(),
8686
await ReadAutoPilotCronAsync(app));
87+
88+
// A no-op unless the release-note parser changed since the notes were stored
89+
BackgroundJob.Enqueue<ReleaseNoteReparseJob>(job => job.ExecuteAsync());
8790
}
8891

8992
private static async Task<string> ReadAutoPilotCronAsync(WebApplication app)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using System.Text.Json;
2+
using Microsoft.Extensions.Logging;
3+
using PkgDiff.Application.Interfaces;
4+
using PkgDiff.Domain.Entities;
5+
using PkgDiff.Domain.Interfaces;
6+
using PkgDiff.Shared.Constants;
7+
using PkgDiff.Shared.ReleaseNotes;
8+
9+
namespace PkgDiff.Application.Jobs;
10+
11+
// Stored categories are built when notes are scraped; a parser change re-parses them once, a package per job
12+
public class ReleaseNoteReparseJob
13+
{
14+
// Raise when the release-note parser changes what it returns
15+
public const string ParserVersion = "2";
16+
17+
private readonly IUnitOfWork _unitOfWork;
18+
private readonly IAnalyzerService _analyzer;
19+
private readonly IJobScheduler _jobs;
20+
private readonly ILogger<ReleaseNoteReparseJob> _logger;
21+
22+
public ReleaseNoteReparseJob(
23+
IUnitOfWork unitOfWork,
24+
IAnalyzerService analyzer,
25+
IJobScheduler jobs,
26+
ILogger<ReleaseNoteReparseJob> logger)
27+
{
28+
_unitOfWork = unitOfWork;
29+
_analyzer = analyzer;
30+
_jobs = jobs;
31+
_logger = logger;
32+
}
33+
34+
public async Task ExecuteAsync()
35+
{
36+
var setting = (await _unitOfWork.SystemSettings.FindAsync(s => s.Key == SettingKeys.ReleaseNoteParserVersion)).FirstOrDefault();
37+
if (setting?.Value == ParserVersion)
38+
return;
39+
40+
var packageIds = await _unitOfWork.ToListAsync(_unitOfWork.CachedPackages.Query().Select(p => p.Id));
41+
foreach (var packageId in packageIds)
42+
_jobs.Enqueue<ReleaseNoteReparseJob>(job => job.ReparsePackageAsync(packageId));
43+
44+
if (setting is null)
45+
{
46+
var definition = SettingKeys.Find(SettingKeys.ReleaseNoteParserVersion)!;
47+
await _unitOfWork.SystemSettings.AddAsync(new SystemSettings
48+
{
49+
Key = definition.Key,
50+
Value = ParserVersion,
51+
Description = definition.Description,
52+
DataType = definition.DataType,
53+
Category = definition.Category,
54+
IsSecret = definition.IsSecret
55+
});
56+
}
57+
else
58+
{
59+
setting.Value = ParserVersion;
60+
}
61+
await _unitOfWork.SaveChangesAsync();
62+
63+
_logger.LogInformation("Re-parsing the stored release notes of {Count} packages for parser {Version}", packageIds.Count, ParserVersion);
64+
}
65+
66+
public async Task ReparsePackageAsync(int cachedPackageId)
67+
{
68+
var notes = await _unitOfWork.CachedReleaseNotes.FindAsync(n => n.CachedPackageId == cachedPackageId && !n.IsEmpty);
69+
foreach (var note in notes)
70+
{
71+
note.ParsedNotesJson = JsonSerializer.Serialize(
72+
_analyzer.ParseReleaseNotes(CompiledReleaseNotes.Split(note.ReleaseNotes).Body));
73+
}
74+
await _unitOfWork.SaveChangesAsync();
75+
}
76+
}

‎src/backend/PkgDiff.Application/Services/PackageAnalysisService.cs‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ private async Task<List<VersionInfo>> ProcessVersionsAsync(
325325
var versionInfoList = new List<VersionInfo>();
326326
int consecutiveEmpty = 0;
327327
int current = 0;
328+
int dateLookups = 0;
328329

329330
foreach (var version in versionsToProcess)
330331
{
@@ -357,7 +358,10 @@ private async Task<List<VersionInfo>> ProcessVersionsAsync(
357358
{
358359
releaseNotes = string.Empty;
359360
releaseNotesSource = registryClient.SourceLabel;
360-
publishedDate = await registryClient.GetPublishedDateAsync(packageId, versionString);
361+
// Some registries answer a date with the whole package document; the scrape stores the rest
362+
publishedDate = dateLookups++ < maxConsecutiveEmpty
363+
? await registryClient.GetPublishedDateAsync(packageId, versionString)
364+
: null;
361365
isFromCache = false;
362366

363367
_logger.LogDebug("[PackageAnalysis] No cache found for {PackageId} v{Version}, triggering background scrape.", packageId, versionString);
@@ -367,7 +371,9 @@ private async Task<List<VersionInfo>> ProcessVersionsAsync(
367371
var versionInfo = CreateVersionInfo(version, releaseNotes, releaseNotesSource, publishedDate, cachedNote, isFromCache);
368372
versionInfoList.Add(versionInfo);
369373

370-
if (versionInfo.IsEmpty)
374+
// Only a version whose notes were looked up and not found counts; one not scraped yet may still have some
375+
var lookedUp = !string.IsNullOrEmpty(cachedNote?.ReleaseNotesSource);
376+
if (versionInfo.IsEmpty && lookedUp)
371377
{
372378
consecutiveEmpty++;
373379
if (consecutiveEmpty >= maxConsecutiveEmpty)
@@ -376,7 +382,7 @@ private async Task<List<VersionInfo>> ProcessVersionsAsync(
376382
break;
377383
}
378384
}
379-
else
385+
else if (!versionInfo.IsEmpty)
380386
{
381387
consecutiveEmpty = 0;
382388
}

‎src/backend/PkgDiff.Infrastructure/Services/AnalyzerService.cs‎

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ public class AnalyzerService : IAnalyzerService
99
// Ordered most specific first
1010
private static readonly (string[] Prefixes, ChangeCategory Category)[] LinePrefixRules =
1111
[
12-
(["breaking change", "breaking"], ChangeCategory.BreakingChange),
12+
(["breaking change", "breaking", "bc break"], ChangeCategory.BreakingChange),
1313
(["new feature", "feature", "added", "feat"], ChangeCategory.NewFeature),
1414
(["security fix", "security"], ChangeCategory.Security),
1515
(["bug fix", "bugfix", "fixed", "fix", "bug"], ChangeCategory.BugFix),
@@ -18,9 +18,22 @@ private static readonly (string[] Prefixes, ChangeCategory Category)[] LinePrefi
1818
(["change", "changed", "update", "updated", "improvement", "enhanced", "enhancement"], ChangeCategory.Other),
1919
];
2020

21+
// A heading names the category of the lines below it, until the next heading
22+
private static readonly (string[] Keywords, ChangeCategory Category)[] SectionRules =
23+
[
24+
(["breaking", "bc break", "bc-break", "incompatible"], ChangeCategory.BreakingChange),
25+
(["contributor", "thank", "acknowledg", "full changelog", "upgrade", "install"], ChangeCategory.Other),
26+
(["security"], ChangeCategory.Security),
27+
(["deprecat", "removed", "removal"], ChangeCategory.Deprecation),
28+
(["bug", "fixed", "fixes", "fix"], ChangeCategory.BugFix),
29+
(["feature", "added", "new"], ChangeCategory.NewFeature),
30+
(["performance", "perf"], ChangeCategory.Performance),
31+
(["changed", "changes", "improvement", "enhancement", "other", "misc", "internal", "dependencies"], ChangeCategory.Other),
32+
];
33+
2134
private static readonly (string[] Keywords, ChangeCategory Category)[] KeywordRules =
2235
[
23-
(["breaking", "incompatible"], ChangeCategory.BreakingChange),
36+
(["breaking", "incompatible", "bc break"], ChangeCategory.BreakingChange),
2437
(["security", "vulnerability", "cve"], ChangeCategory.Security),
2538
(["deprecat", "obsolete"], ChangeCategory.Deprecation),
2639
(["performance", "optimize", "faster"], ChangeCategory.Performance),
@@ -33,13 +46,38 @@ public List<ReleaseNoteEntry> ParseReleaseNotes(string releaseNotes)
3346

3447
var entries = new List<ReleaseNoteEntry>();
3548
var lines = releaseNotes.Split('\n', StringSplitOptions.RemoveEmptyEntries);
49+
ChangeCategory? section = null;
3650

3751
foreach (var rawLine in lines)
3852
{
3953
var line = rawLine.Trim();
4054
if (string.IsNullOrWhiteSpace(line))
4155
continue;
4256

57+
var heading = HeadingText(line);
58+
if (heading is not null && SectionOf(heading) is { } named)
59+
{
60+
section = named;
61+
continue;
62+
}
63+
64+
// "### 3.0.0" ends the section; a sentence that merely ends in ":" does not
65+
if (heading is not null && !IsColonHeading(line))
66+
{
67+
section = null;
68+
}
69+
else if (section is { } current)
70+
{
71+
var (lineCategory, lineText) = DetectAndStripPrefix(StripListMarker(line));
72+
if (!string.IsNullOrWhiteSpace(lineText))
73+
entries.Add(new ReleaseNoteEntry
74+
{
75+
Category = lineCategory == ChangeCategory.Other ? current : lineCategory,
76+
Text = lineText.Trim()
77+
});
78+
continue;
79+
}
80+
4381
var cleaned = StripListMarker(line);
4482

4583
var (category, text) = DetectAndStripPrefix(cleaned);
@@ -163,6 +201,34 @@ private static (ChangeCategory Category, string Text) DetectAndStripPrefix(strin
163201
return (ChangeCategory.Other, line);
164202
}
165203

204+
private const int MaxColonHeadingLength = 60;
205+
206+
// "## Fixed", "**Breaking changes**" or a short "Noteworthy BC Breaks:" line on its own
207+
private static string? HeadingText(string line)
208+
{
209+
if (line.StartsWith('#'))
210+
return line.TrimStart('#').Trim();
211+
212+
if (line.Length > 4 && line.StartsWith("**") && (line.EndsWith("**") || line.EndsWith("**:")))
213+
return line.Trim('*', ':', ' ');
214+
215+
return IsColonHeading(line) ? line.TrimEnd(':').Trim() : null;
216+
}
217+
218+
private static bool IsColonHeading(string line) =>
219+
line.EndsWith(':') && line.Length <= MaxColonHeadingLength && StripListMarker(line) == line;
220+
221+
private static ChangeCategory? SectionOf(string heading)
222+
{
223+
var lower = heading.ToLowerInvariant();
224+
foreach (var (keywords, category) in SectionRules)
225+
{
226+
if (keywords.Any(lower.Contains))
227+
return category;
228+
}
229+
return null;
230+
}
231+
166232
private static string StripListMarker(string line)
167233
{
168234
var trimmed = line.TrimStart();

‎src/backend/PkgDiff.Infrastructure/Services/GitHubScraperService.cs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -421,16 +421,20 @@ private static IEnumerable<string> TagCandidates(string? packageId, string versi
421421
yield return $"{name}/v{version}";
422422
yield return $"{name}/{version}";
423423
yield return $"{name}_v{version}";
424+
yield return $"{name}-parent-{version}";
424425
}
425426
yield return $"release-{version}";
426427
yield return $"release/{version}";
427428
yield return $"rel_{version.Replace('.', '_')}";
429+
yield return $"parent-{version}";
430+
yield return $"r{version}";
431+
yield return $"v_{version}";
428432
}
429433

430434
[System.Text.RegularExpressions.GeneratedRegex(@"^[vV]?\d")]
431435
private static partial System.Text.RegularExpressions.Regex PlainVersionTag();
432436

433-
// 6.4.0.Final is tagged 6.4.0
434-
[System.Text.RegularExpressions.GeneratedRegex(@"[.-](?:final|release|ga)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
437+
// 6.4.0.Final and 33.0.0-jre are tagged 6.4.0 and v33.0.0
438+
[System.Text.RegularExpressions.GeneratedRegex(@"[.-](?:final|release|ga|jre|android)$", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
435439
private static partial System.Text.RegularExpressions.Regex ReleaseSuffix();
436440
}

‎src/backend/PkgDiff.Shared/Constants/SettingKeys.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public static class SettingKeys
1515
public const string MaxConsecutiveEmptyVersions = "MaxConsecutiveEmptyVersions";
1616
public const string ScrapingTimeout = "ScrapingTimeout";
1717
public const string HangfireWorkerCount = "Hangfire.WorkerCount";
18+
public const string ReleaseNoteParserVersion = "Analyzer.ParserVersion";
1819

1920
public const string AutoPilotEnabled = "AutoPilot:Enabled";
2021
public const string AutoPilotLlmModel = "AutoPilot:LlmModel";
@@ -51,6 +52,7 @@ public static class SettingKeys
5152
new SettingDefinition(MaxConsecutiveEmptyVersions, "10", "Stop after N consecutive empty release notes", TypeInt, CategoryGeneral, false),
5253
new SettingDefinition(ScrapingTimeout, "30", "Scraping timeout in seconds", TypeInt, CategoryGeneral, false),
5354
new SettingDefinition(HangfireWorkerCount, "10", "Number of Hangfire background workers (informational; the server does not read this yet)", TypeInt, CategoryGeneral, false),
55+
new SettingDefinition(ReleaseNoteParserVersion, "1", "Release-note parser the stored categories were built with (set by the system)", TypeInt, CategoryGeneral, false),
5456

5557
new SettingDefinition(AutoPilotEnabled, "true", "Enable/disable AutoPilot blog generation job", TypeBool, CategoryAutoPilot, false),
5658
new SettingDefinition(AutoPilotLlmModel, "qwen2:0.5b", "Ollama model name used by AutoPilot for blog generation", TypeString, CategoryAutoPilot, false),

0 commit comments

Comments
 (0)