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} [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, "") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "Repository Releases") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "New releases from starred repositories") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "https://releases.lerch.org") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); // Check entry structure try std.testing.expect(std.mem.indexOf(u8, atom_content, "test/repo - v1.0.0") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "https://github.com/test/repo/releases/tag/v1.0.0") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "2024-01-01T00:00:00Z") != null); // Check for author - be flexible about exact format try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "github") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "<p>Test release</p>") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != 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")); }