release-tracker/src/main.zig
Emil Lerch 6ad55474fa
All checks were successful
Build and Release / build (push) Successful in 2m16s
Build and Release / sign (push) Successful in 2m6s
pre-release filtering
2025-07-20 13:31:42 -07:00

571 lines
21 KiB
Zig

const std = @import("std");
const builtin = @import("builtin");
const ArrayList = std.ArrayList;
const Allocator = std.mem.Allocator;
const Thread = std.Thread;
const GitHub = @import("providers/GitHub.zig");
const GitLab = @import("providers/GitLab.zig");
const Codeberg = @import("providers/Codeberg.zig");
const SourceHut = @import("providers/SourceHut.zig");
const atom = @import("atom.zig");
const config = @import("config.zig");
const zeit = @import("zeit");
const utils = @import("utils.zig");
const Provider = @import("Provider.zig");
// Configuration: Only include releases from the last n days
const RELEASE_AGE_LIMIT_SECONDS: i64 = 90 * std.time.s_per_day;
fn print(comptime fmt: []const u8, args: anytype) void {
if (comptime @import("builtin").is_test) {
const build_options = @import("build_options");
if (build_options.test_debug) {
std.debug.print(fmt, args);
}
} else {
std.debug.print(fmt, args);
}
}
// Error output functions that work in release mode
/// Check if file size exceeds 10MB threshold and warn user if so.
/// Returns true if warning was triggered, false otherwise.
/// Only prints to stderr in production (not during tests).
fn checkFileSizeAndWarn(file_size: usize) bool {
const ten_mb = 10 * 1024 * 1024; // 10MB in bytes
if (file_size > ten_mb) {
// Only print warning if not in test mode
if (!builtin.is_test) {
const size_mb = @as(f64, @floatFromInt(file_size)) / (1024.0 * 1024.0);
printError("⚠️ WARNING: Feed file is {d:.1} MB, which exceeds 10MB\n", .{size_mb});
printError(" Large feeds may cause issues with some feed readers\n", .{});
printError(" Consider reducing the RELEASE_AGE_LIMIT_SECONDS to show fewer releases\n", .{});
}
return true; // File size exceeded threshold
}
return false; // File size is within acceptable limits
}
fn printError(comptime fmt: []const u8, args: anytype) void {
const stderr = std.io.getStdErr().writer();
stderr.print(fmt, args) catch {};
}
fn printInfo(comptime fmt: []const u8, args: anytype) void {
const stderr = std.io.getStdErr().writer();
if (!builtin.is_test)
stderr.print(fmt, args) catch {};
}
pub const Release = struct {
repo_name: []const u8,
tag_name: []const u8,
published_at: i64,
html_url: []const u8,
description: []const u8,
provider: []const u8,
is_tag: bool = false,
is_prerelease: bool = false, // Track if this is a prerelease/draft
pub fn deinit(self: Release, allocator: Allocator) void {
allocator.free(self.repo_name);
allocator.free(self.tag_name);
allocator.free(self.html_url);
allocator.free(self.description);
allocator.free(self.provider);
}
};
const ProviderResult = struct {
provider_name: []const u8,
releases: ArrayList(Release),
error_msg: ?[]const u8 = null,
duration_ms: u64 = 0,
};
const ThreadContext = struct {
provider: Provider,
result: *ProviderResult,
allocator: Allocator,
};
pub fn main() !u8 {
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
const gpa, const is_debug = gpa: {
if (builtin.os.tag == .wasi) break :gpa .{ std.heap.wasm_allocator, false };
break :gpa switch (builtin.mode) {
.Debug, .ReleaseSafe => .{ debug_allocator.allocator(), true },
.ReleaseFast, .ReleaseSmall => .{ std.heap.smp_allocator, false },
};
};
defer if (is_debug) {
_ = debug_allocator.deinit();
};
var tsa = std.heap.ThreadSafeAllocator{ .child_allocator = gpa };
const allocator = tsa.allocator();
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len < 2) {
const stdout = std.io.getStdOut().writer();
try stdout.print("Usage: {s} <config-file> [atom-feed-file]\n", .{args[0]});
return 0;
}
const config_path = args[1];
const output_file = if (args.len >= 3) args[2] else "releases.xml";
var app_config = config.loadConfig(allocator, config_path) catch |err| {
printError("Error loading config: {}\n", .{err});
return 1;
};
defer app_config.deinit();
var all_releases = ArrayList(Release).init(allocator);
defer all_releases.deinit();
printInfo("Fetching releases from all providers concurrently...\n", .{});
// Create providers list
var providers = std.ArrayList(Provider).init(allocator);
defer providers.deinit();
// Initialize providers with their tokens (need to persist for the lifetime of the program)
var github_provider: ?GitHub = null;
var gitlab_provider: ?GitLab = null;
var codeberg_provider: ?Codeberg = null;
var sourcehut_provider: ?SourceHut = null;
if (app_config.github_token) |token| {
github_provider = GitHub.init(token);
try providers.append(github_provider.?.provider());
}
if (app_config.gitlab_token) |token| {
gitlab_provider = GitLab.init(token);
try providers.append(gitlab_provider.?.provider());
}
if (app_config.codeberg_token) |token| {
codeberg_provider = Codeberg.init(token);
try providers.append(codeberg_provider.?.provider());
}
if (app_config.sourcehut) |sh_config| if (sh_config.repositories.len > 0 and sh_config.token != null) {
sourcehut_provider = SourceHut.init(sh_config.token.?, sh_config.repositories);
try providers.append(sourcehut_provider.?.provider());
};
// Fetch releases from all providers concurrently using thread pool
const provider_results = try fetchReleasesFromAllProviders(allocator, providers.items);
defer {
for (provider_results) |*result| {
// Free error messages if they exist
if (result.error_msg) |error_msg|
allocator.free(error_msg);
for (result.releases.items) |release|
release.deinit(allocator);
result.releases.deinit();
}
allocator.free(provider_results);
}
const now = std.time.timestamp();
const cutoff_time = now - RELEASE_AGE_LIMIT_SECONDS;
var has_errors = false;
for (provider_results) |result| {
if (result.error_msg) |error_msg| {
printError("✗ {s}: {s} (in {d}ms)\n", .{ result.provider_name, error_msg, result.duration_ms });
has_errors = true;
}
}
// If any provider failed, exit with error code
if (has_errors) {
printError("One or more providers failed to fetch releases\n", .{});
return 1;
}
var original_count: usize = 0;
// Combine all releases from threaded providers
for (provider_results) |result| {
original_count += result.releases.items.len;
// Results should be sorted already...we will find the oldest applicable release,
// then copy into all_releases
var last_index: usize = 0;
for (result.releases.items) |release| {
if (release.published_at >= cutoff_time) {
last_index += 1;
} else break;
}
try all_releases.appendSlice(result.releases.items[0..last_index]);
}
// Sort all releases by published date (most recent first)
std.mem.sort(Release, all_releases.items, {}, utils.compareReleasesByDate);
// Filter out prereleases after duplicate detection is complete
// Exception: Keep git-style version prereleases (like kraftkit)
var filtered_releases = std.ArrayList(Release).init(allocator);
defer filtered_releases.deinit();
for (all_releases.items) |release| {
if (!release.is_prerelease) {
try filtered_releases.append(release);
} else {
// Check if this is a git-style version (e.g., v1.2.3-123-g1234567)
// These should be included even if marked as prerelease
if (isGitStyleVersion(release.tag_name)) {
try filtered_releases.append(release);
}
}
}
// Generate Atom feed from filtered releases
const atom_content = try atom.generateFeed(allocator, filtered_releases.items);
defer allocator.free(atom_content);
// Write to output file
const file = try std.fs.cwd().createFile(output_file, .{});
defer file.close();
try file.writeAll(atom_content);
// Check file size and warn if over 10MB
_ = checkFileSizeAndWarn(atom_content.len);
// Log to stderr for user feedback
printInfo("Total releases in feed: {} of {} total in last {} days\n", .{ filtered_releases.items.len, original_count, @divTrunc(RELEASE_AGE_LIMIT_SECONDS, std.time.s_per_day) });
printInfo("Updated feed written to: {s}\n", .{output_file});
return 0;
}
/// Check if a tag contains git-style version pattern like v1.2.3-123-g1234567
fn isGitStyleVersion(tag_name: []const u8) bool {
// Convert to lowercase for comparison
var tag_lower_buf: [256]u8 = undefined;
if (tag_name.len >= tag_lower_buf.len) return false;
const tag_lower = std.ascii.lowerString(tag_lower_buf[0..tag_name.len], tag_name);
// Look for pattern: -number-g followed by hex characters
var i: usize = 0;
while (i < tag_lower.len) {
if (tag_lower[i] == '-') {
// Found a dash, check if followed by digits
var j = i + 1;
var has_digits = false;
// Skip digits
while (j < tag_lower.len and tag_lower[j] >= '0' and tag_lower[j] <= '9') {
has_digits = true;
j += 1;
}
// Check if followed by -g and hex characters
if (has_digits and j + 2 < tag_lower.len and
tag_lower[j] == '-' and tag_lower[j + 1] == 'g')
{
// Check if followed by hex characters
var k = j + 2;
var hex_count: usize = 0;
while (k < tag_lower.len and
((tag_lower[k] >= '0' and tag_lower[k] <= '9') or
(tag_lower[k] >= 'a' and tag_lower[k] <= 'f')))
{
hex_count += 1;
k += 1;
}
// If we found hex characters, this looks like git describe format
if (hex_count >= 4) { // At least 4 hex chars for a reasonable commit hash
return true;
}
}
}
i += 1;
}
return false;
}
fn formatTimestampForDisplay(allocator: Allocator, timestamp: i64) ![]const u8 {
if (timestamp == 0) {
return try allocator.dupe(u8, "beginning of time");
}
// Use zeit to format the timestamp properly
const instant = zeit.instant(.{ .source = .{ .unix_timestamp = timestamp } }) catch {
// Fallback to simple approximation if zeit fails
const days_since_epoch = @divTrunc(timestamp, 24 * 60 * 60);
const years_since_1970 = @divTrunc(days_since_epoch, 365);
const remaining_days = @mod(days_since_epoch, 365);
const months = @divTrunc(remaining_days, 30);
const days = @mod(remaining_days, 30);
const year = 1970 + years_since_1970;
const month = 1 + months;
const day = 1 + days;
return try std.fmt.allocPrint(allocator, "{d:0>4}-{d:0>2}-{d:0>2}T00:00:00Z", .{ year, month, day });
};
const time = instant.time();
var buf: [64]u8 = undefined;
const formatted = try time.bufPrint(&buf, .rfc3339);
return try allocator.dupe(u8, formatted);
}
fn fetchReleasesFromAllProviders(
allocator: Allocator,
providers: []const Provider,
) ![]ProviderResult {
var results = try allocator.alloc(ProviderResult, providers.len);
// Initialize results
for (results, 0..) |*result, i| {
result.* = ProviderResult{
.provider_name = providers[i].getName(),
.releases = ArrayList(Release).init(allocator),
.error_msg = null,
};
}
// Create thread pool context
var threads = try allocator.alloc(Thread, providers.len);
defer allocator.free(threads);
var contexts = try allocator.alloc(ThreadContext, providers.len);
defer allocator.free(contexts);
// Calculate the latest release date for each provider from existing releases
for (providers, 0..) |provider, i| {
contexts[i] = ThreadContext{
.provider = provider,
.result = &results[i],
.allocator = allocator,
};
threads[i] = try Thread.spawn(.{}, fetchProviderReleases, .{&contexts[i]});
}
// Wait for all threads to complete
for (providers, 0..) |_, i| {
threads[i].join();
}
return results;
}
fn fetchProviderReleases(context: *const ThreadContext) void {
const provider = context.provider;
const result = context.result;
const allocator = context.allocator;
printInfo("Fetching releases from {s}...\n", .{provider.getName()});
// Start timing
const start_time = std.time.milliTimestamp();
const releases_or_err = provider.fetchReleases(allocator);
const end_time = std.time.milliTimestamp();
const duration_ms: u64 = @intCast(end_time - start_time);
result.duration_ms = duration_ms;
if (releases_or_err) |all_releases| {
result.releases = all_releases;
printInfo("✓ {s}: Found {} releases in {d}ms\n", .{ provider.getName(), result.releases.items.len, duration_ms });
} else |err| {
const error_msg = std.fmt.allocPrint(allocator, "Error fetching releases: {}", .{err}) catch "Unknown fetch error";
result.error_msg = error_msg;
// Don't print error here - it will be handled in main function
}
}
test "main functionality" {
// Basic test to ensure compilation
const allocator = std.testing.allocator;
var releases = ArrayList(Release).init(allocator);
defer releases.deinit();
try std.testing.expect(releases.items.len == 0);
}
test "file size warning for large feeds" {
// Test that files under 10MB don't trigger warning
const result1 = checkFileSizeAndWarn(5 * 1024 * 1024); // 5MB - should not warn
try std.testing.expect(result1 == false);
// Test that files over 10MB do trigger warning
const result2 = checkFileSizeAndWarn(15 * 1024 * 1024); // 15MB - should warn
try std.testing.expect(result2 == true);
// Test edge case - exactly 10MB should not warn
const result3 = checkFileSizeAndWarn(10 * 1024 * 1024); // 10MB exactly - should not warn
try std.testing.expect(result3 == false);
// Test just over 10MB should warn
const result4 = checkFileSizeAndWarn(10 * 1024 * 1024 + 1); // 10MB + 1 byte - should warn
try std.testing.expect(result4 == true);
// Test various sizes around the threshold
try std.testing.expect(!checkFileSizeAndWarn(9 * 1024 * 1024)); // 9MB
try std.testing.expect(checkFileSizeAndWarn(11 * 1024 * 1024)); // 11MB
try std.testing.expect(!checkFileSizeAndWarn(1 * 1024 * 1024)); // 1MB
try std.testing.expect(checkFileSizeAndWarn(50 * 1024 * 1024)); // 50MB
}
test "atom feed generation" {
const allocator = std.testing.allocator;
const releases = [_]Release{
Release{
.repo_name = "test/repo",
.tag_name = "v1.0.0",
.published_at = @intCast(@divTrunc(
(try zeit.instant(.{ .source = .{ .iso8601 = "2024-01-01T00:00:00Z" } })).timestamp,
std.time.ns_per_s,
)),
.html_url = "https://github.com/test/repo/releases/tag/v1.0.0",
.description = "Test release",
.provider = "github",
.is_tag = false,
},
};
const atom_content = try atom.generateFeed(allocator, &releases);
defer allocator.free(atom_content);
// Check for required Atom elements
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<feed xmlns=\"http://www.w3.org/2005/Atom\">") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<title>Repository Releases</title>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<subtitle>New releases from starred repositories</subtitle>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<link href=\"https://releases.lerch.org\" rel=\"alternate\"/>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<link href=\"https://releases.lerch.org/atom.xml\" rel=\"self\"/>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<id>https://releases.lerch.org</id>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<updated>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<entry>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "</feed>") != null);
// Check entry structure
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<title>test/repo - v1.0.0</title>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<link href=\"https://github.com/test/repo/releases/tag/v1.0.0\"/>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<id>https://github.com/test/repo/releases/tag/v1.0.0</id>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<updated>2024-01-01T00:00:00Z</updated>") != null);
// Check for author - be flexible about exact format
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<author>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "github") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "</author>") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<content type=\"html\">&lt;p&gt;Test release&lt;/p&gt;") != null);
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<category term=\"github\"/>") != null);
}
test "formatTimestampForDisplay produces valid ISO dates" {
const allocator = std.testing.allocator;
// Test with zero timestamp
const zero_result = try formatTimestampForDisplay(allocator, 0);
defer allocator.free(zero_result);
try std.testing.expectEqualStrings("beginning of time", zero_result);
// Test with known timestamp (2024-01-01T00:00:00Z = 1704067200)
const known_result = try formatTimestampForDisplay(allocator, 1704067200);
defer allocator.free(known_result);
try std.testing.expect(std.mem.startsWith(u8, known_result, "20"));
try std.testing.expect(std.mem.endsWith(u8, known_result, "Z"));
try std.testing.expect(std.mem.indexOf(u8, known_result, "T") != null);
}
test "Age-based release filtering" {
const allocator = std.testing.allocator;
const now = std.time.timestamp();
const one_year_ago = now - RELEASE_AGE_LIMIT_SECONDS;
const two_years_ago = now - (2 * RELEASE_AGE_LIMIT_SECONDS);
// Create releases with different ages
const recent_release = Release{
.repo_name = "test/recent",
.tag_name = "v1.0.0",
.published_at = now - std.time.s_per_day, // 1 day ago
.html_url = "https://github.com/test/recent/releases/tag/v1.0.0",
.description = "Recent release",
.provider = "github",
.is_tag = false,
};
const old_release = Release{
.repo_name = "test/old",
.tag_name = "v0.1.0",
.published_at = two_years_ago,
.html_url = "https://github.com/test/old/releases/tag/v0.1.0",
.description = "Old release",
.provider = "github",
.is_tag = false,
};
const borderline_release = Release{
.repo_name = "test/borderline",
.tag_name = "v0.5.0",
.published_at = one_year_ago + std.time.s_per_hour, // 1 hour within limit
.html_url = "https://github.com/test/borderline/releases/tag/v0.5.0",
.description = "Borderline release",
.provider = "github",
.is_tag = false,
};
const releases = [_]Release{ recent_release, old_release, borderline_release };
// Test filtering logic
var filtered = ArrayList(Release).init(allocator);
defer filtered.deinit();
const cutoff_time = now - RELEASE_AGE_LIMIT_SECONDS;
for (releases) |release| {
const release_time = release.published_at;
if (release_time >= cutoff_time) {
try filtered.append(release);
}
}
// Should include recent and borderline, but not old
try std.testing.expectEqual(@as(usize, 2), filtered.items.len);
// Verify the correct releases were included
var found_recent = false;
var found_borderline = false;
var found_old = false;
for (filtered.items) |release| {
if (std.mem.eql(u8, release.repo_name, "test/recent")) {
found_recent = true;
} else if (std.mem.eql(u8, release.repo_name, "test/borderline")) {
found_borderline = true;
} else if (std.mem.eql(u8, release.repo_name, "test/old")) {
found_old = true;
}
}
try std.testing.expect(found_recent);
try std.testing.expect(found_borderline);
try std.testing.expect(!found_old);
}
// Import others
test {
std.testing.refAllDecls(@import("timestamp_tests.zig"));
std.testing.refAllDecls(@import("atom.zig"));
std.testing.refAllDecls(@import("utils.zig"));
std.testing.refAllDecls(@import("tag_filter.zig"));
std.testing.refAllDecls(@import("providers/GitHub.zig"));
std.testing.refAllDecls(@import("providers/GitLab.zig"));
std.testing.refAllDecls(@import("providers/SourceHut.zig"));
std.testing.refAllDecls(@import("providers/Codeberg.zig"));
}