867 lines
32 KiB
Zig
867 lines
32 KiB
Zig
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const print = std.debug.print;
|
|
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 Config = config.Config;
|
|
const SourceHutConfig = config.SourceHutConfig;
|
|
const xml_parser = @import("xml_parser.zig");
|
|
const zeit = @import("zeit");
|
|
|
|
const Provider = @import("Provider.zig");
|
|
|
|
// Configuration: Only include releases from the last year in the output
|
|
const RELEASE_AGE_LIMIT_SECONDS: i64 = 365 * 24 * 60 * 60; // 1 year in seconds
|
|
|
|
pub const Release = struct {
|
|
repo_name: []const u8,
|
|
tag_name: []const u8,
|
|
published_at: []const u8,
|
|
html_url: []const u8,
|
|
description: []const u8,
|
|
provider: []const u8,
|
|
|
|
pub fn deinit(self: Release, allocator: Allocator) void {
|
|
allocator.free(self.repo_name);
|
|
allocator.free(self.tag_name);
|
|
allocator.free(self.published_at);
|
|
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,
|
|
};
|
|
|
|
const ThreadContext = struct {
|
|
provider: Provider,
|
|
latest_release_date: i64,
|
|
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();
|
|
};
|
|
const allocator = gpa;
|
|
|
|
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| {
|
|
print("Error loading config: {}\n", .{err});
|
|
return 1;
|
|
};
|
|
defer app_config.deinit();
|
|
|
|
// Load existing releases to determine last check time per provider
|
|
var existing_releases = loadExistingReleases(allocator, output_file) catch ArrayList(Release).init(allocator);
|
|
defer {
|
|
for (existing_releases.items) |release| {
|
|
release.deinit(allocator);
|
|
}
|
|
existing_releases.deinit();
|
|
}
|
|
|
|
var new_releases = ArrayList(Release).init(allocator);
|
|
defer {
|
|
for (new_releases.items) |release| {
|
|
release.deinit(allocator);
|
|
}
|
|
new_releases.deinit();
|
|
}
|
|
|
|
print("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, existing_releases.items);
|
|
defer {
|
|
for (provider_results) |*result| {
|
|
// Don't free the releases here - they're transferred to new_releases
|
|
result.releases.deinit();
|
|
// Free error messages if they exist
|
|
if (result.error_msg) |error_msg| {
|
|
allocator.free(error_msg);
|
|
}
|
|
}
|
|
allocator.free(provider_results);
|
|
}
|
|
|
|
// Combine all new releases from threaded providers
|
|
for (provider_results) |result| {
|
|
try new_releases.appendSlice(result.releases.items);
|
|
print("Found {} new releases from {s}\n", .{ result.releases.items.len, result.provider_name });
|
|
}
|
|
|
|
// Combine all releases (existing and new)
|
|
var all_releases = ArrayList(Release).init(allocator);
|
|
defer all_releases.deinit();
|
|
|
|
// Add new releases
|
|
try all_releases.appendSlice(new_releases.items);
|
|
|
|
// Add all existing releases
|
|
try all_releases.appendSlice(existing_releases.items);
|
|
|
|
// Sort all releases by published date (most recent first)
|
|
std.mem.sort(Release, all_releases.items, {}, compareReleasesByDate);
|
|
|
|
// Filter releases by age in-place - zero extra allocations
|
|
const now = std.time.timestamp();
|
|
const cutoff_time = now - RELEASE_AGE_LIMIT_SECONDS;
|
|
|
|
var write_index: usize = 0;
|
|
const original_count = all_releases.items.len;
|
|
|
|
for (all_releases.items) |release| {
|
|
const release_time = parseReleaseTimestamp(release.published_at) catch 0;
|
|
if (release_time >= cutoff_time) {
|
|
all_releases.items[write_index] = release;
|
|
write_index += 1;
|
|
}
|
|
}
|
|
|
|
// Shrink the array to only include filtered items
|
|
all_releases.shrinkRetainingCapacity(write_index);
|
|
|
|
// Generate Atom feed from filtered releases
|
|
const atom_content = try atom.generateFeed(allocator, all_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);
|
|
|
|
// Log to stderr for user feedback
|
|
std.debug.print("Found {} new releases\n", .{new_releases.items.len});
|
|
std.debug.print("Total releases in feed: {} (filtered from {} total, showing last {} days)\n", .{ all_releases.items.len, original_count, @divTrunc(RELEASE_AGE_LIMIT_SECONDS, 24 * 60 * 60) });
|
|
std.debug.print("Updated feed written to: {s}\n", .{output_file});
|
|
|
|
return 0;
|
|
}
|
|
|
|
fn loadExistingReleases(allocator: Allocator, filename: []const u8) !ArrayList(Release) {
|
|
const file = std.fs.cwd().openFile(filename, .{}) catch |err| switch (err) {
|
|
error.FileNotFound => {
|
|
print("No existing releases file found, starting fresh\n", .{});
|
|
return ArrayList(Release).init(allocator);
|
|
},
|
|
else => return err,
|
|
};
|
|
defer file.close();
|
|
|
|
const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
|
|
defer allocator.free(content);
|
|
|
|
print("Loading existing releases from {s}...\n", .{filename});
|
|
|
|
const releases = xml_parser.parseAtomFeed(allocator, content) catch |err| {
|
|
print("Warning: Failed to parse existing releases file: {}\n", .{err});
|
|
print("Starting fresh with no existing releases\n", .{});
|
|
return ArrayList(Release).init(allocator);
|
|
};
|
|
|
|
print("Loaded {} existing releases\n", .{releases.items.len});
|
|
return releases;
|
|
}
|
|
|
|
pub fn filterNewReleases(allocator: Allocator, all_releases: []const Release, since_timestamp: i64) !ArrayList(Release) {
|
|
var new_releases = ArrayList(Release).init(allocator);
|
|
|
|
for (all_releases) |release| {
|
|
// Parse the published_at timestamp
|
|
const release_time = parseReleaseTimestamp(release.published_at) catch continue;
|
|
|
|
if (release_time > since_timestamp) {
|
|
// This is a new release, duplicate it for our list
|
|
const new_release = Release{
|
|
.repo_name = try allocator.dupe(u8, release.repo_name),
|
|
.tag_name = try allocator.dupe(u8, release.tag_name),
|
|
.published_at = try allocator.dupe(u8, release.published_at),
|
|
.html_url = try allocator.dupe(u8, release.html_url),
|
|
.description = try allocator.dupe(u8, release.description),
|
|
.provider = try allocator.dupe(u8, release.provider),
|
|
};
|
|
try new_releases.append(new_release);
|
|
}
|
|
}
|
|
|
|
return new_releases;
|
|
}
|
|
|
|
pub fn parseReleaseTimestamp(date_str: []const u8) !i64 {
|
|
// Try parsing as direct timestamp first
|
|
if (std.fmt.parseInt(i64, date_str, 10)) |timestamp| {
|
|
return timestamp;
|
|
} else |_| {
|
|
// Try parsing as ISO 8601 format using Zeit
|
|
const instant = zeit.instant(.{
|
|
.source = .{ .iso8601 = date_str },
|
|
}) catch return 0;
|
|
// Zeit returns nanoseconds, convert to seconds
|
|
const seconds = @divTrunc(instant.timestamp, 1_000_000_000);
|
|
return @intCast(seconds);
|
|
}
|
|
}
|
|
|
|
pub fn compareReleasesByDate(context: void, a: Release, b: Release) bool {
|
|
_ = context;
|
|
const timestamp_a = parseReleaseTimestamp(a.published_at) catch 0;
|
|
const timestamp_b = parseReleaseTimestamp(b.published_at) catch 0;
|
|
return timestamp_a > timestamp_b; // Most recent first
|
|
}
|
|
|
|
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,
|
|
existing_releases: []const Release,
|
|
) ![]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| {
|
|
// Find the latest release date for this provider
|
|
var latest_date: i64 = 0;
|
|
for (existing_releases) |release| {
|
|
if (std.mem.eql(u8, release.provider, provider.getName())) {
|
|
const release_time = parseReleaseTimestamp(release.published_at) catch 0;
|
|
if (release_time > latest_date) {
|
|
latest_date = release_time;
|
|
}
|
|
}
|
|
}
|
|
|
|
contexts[i] = ThreadContext{
|
|
.provider = provider,
|
|
.latest_release_date = latest_date,
|
|
.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 latest_release_date = context.latest_release_date;
|
|
const result = context.result;
|
|
const allocator = context.allocator;
|
|
|
|
const since_str = formatTimestampForDisplay(allocator, latest_release_date) catch "unknown";
|
|
defer if (!std.mem.eql(u8, since_str, "unknown")) allocator.free(since_str);
|
|
print("Fetching releases from {s} (since: {s})...\n", .{ provider.getName(), since_str });
|
|
|
|
if (provider.fetchReleases(allocator)) |all_releases| {
|
|
defer {
|
|
for (all_releases.items) |release| {
|
|
release.deinit(allocator);
|
|
}
|
|
all_releases.deinit();
|
|
}
|
|
|
|
// Filter releases newer than latest known release
|
|
const filtered = filterNewReleases(allocator, all_releases.items, latest_release_date) catch |err| {
|
|
const error_msg = std.fmt.allocPrint(allocator, "Error filtering releases: {}", .{err}) catch "Unknown filter error";
|
|
result.error_msg = error_msg;
|
|
return;
|
|
};
|
|
|
|
result.releases = filtered;
|
|
print("✓ {s}: Found {} new releases\n", .{ provider.getName(), filtered.items.len });
|
|
} else |err| {
|
|
const error_msg = std.fmt.allocPrint(allocator, "Error fetching releases: {}", .{err}) catch "Unknown fetch error";
|
|
result.error_msg = error_msg;
|
|
print("✗ {s}: {s}\n", .{ provider.getName(), error_msg });
|
|
}
|
|
}
|
|
|
|
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 "Atom feed has correct structure" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
const releases = [_]Release{
|
|
Release{
|
|
.repo_name = "test/repo",
|
|
.tag_name = "v1.0.0",
|
|
.published_at = "2024-01-01T00:00:00Z",
|
|
.html_url = "https://github.com/test/repo/releases/tag/v1.0.0",
|
|
.description = "Test release",
|
|
.provider = "github",
|
|
},
|
|
};
|
|
|
|
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://github.com\" rel=\"alternate\"/>") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<link href=\"https://example.com/releases.xml\" rel=\"self\"/>") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<id>https://example.com/releases</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, "<summary>Test release</summary>") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, atom_content, "<category term=\"github\"/>") != null);
|
|
}
|
|
|
|
test "loadExistingReleases with valid XML" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
// Create a temporary file with valid Atom XML
|
|
const test_xml =
|
|
\\<?xml version="1.0" encoding="UTF-8"?>
|
|
\\<feed xmlns="http://www.w3.org/2005/Atom">
|
|
\\<title>Repository Releases</title>
|
|
\\<entry>
|
|
\\ <title>test/repo - v1.0.0</title>
|
|
\\ <link href="https://github.com/test/repo/releases/tag/v1.0.0"/>
|
|
\\ <updated>2024-01-01T00:00:00Z</updated>
|
|
\\ <summary>Test release</summary>
|
|
\\ <category term="github"/>
|
|
\\</entry>
|
|
\\</feed>
|
|
;
|
|
|
|
const temp_filename = "test_releases.xml";
|
|
|
|
// Write test XML to file
|
|
{
|
|
const file = try std.fs.cwd().createFile(temp_filename, .{});
|
|
defer file.close();
|
|
try file.writeAll(test_xml);
|
|
}
|
|
defer std.fs.cwd().deleteFile(temp_filename) catch {};
|
|
|
|
// Load existing releases
|
|
var releases = try loadExistingReleases(allocator, temp_filename);
|
|
defer {
|
|
for (releases.items) |release| {
|
|
release.deinit(allocator);
|
|
}
|
|
releases.deinit();
|
|
}
|
|
|
|
try std.testing.expectEqual(@as(usize, 1), releases.items.len);
|
|
try std.testing.expectEqualStrings("test/repo", releases.items[0].repo_name);
|
|
try std.testing.expectEqualStrings("v1.0.0", releases.items[0].tag_name);
|
|
}
|
|
|
|
test "loadExistingReleases with nonexistent file" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
var releases = try loadExistingReleases(allocator, "nonexistent_file.xml");
|
|
defer releases.deinit();
|
|
|
|
try std.testing.expectEqual(@as(usize, 0), releases.items.len);
|
|
}
|
|
|
|
test "loadExistingReleases with malformed XML" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
const malformed_xml = "This is not valid XML at all!";
|
|
const temp_filename = "test_malformed.xml";
|
|
|
|
// Write malformed XML to file
|
|
{
|
|
const file = try std.fs.cwd().createFile(temp_filename, .{});
|
|
defer file.close();
|
|
try file.writeAll(malformed_xml);
|
|
}
|
|
defer std.fs.cwd().deleteFile(temp_filename) catch {};
|
|
|
|
// Should handle gracefully and return empty list
|
|
var releases = try loadExistingReleases(allocator, temp_filename);
|
|
defer releases.deinit();
|
|
|
|
try std.testing.expectEqual(@as(usize, 0), releases.items.len);
|
|
}
|
|
|
|
test "parseReleaseTimestamp with various formats" {
|
|
// Test ISO 8601 format
|
|
const timestamp1 = try parseReleaseTimestamp("2024-01-01T00:00:00Z");
|
|
try std.testing.expect(timestamp1 > 0);
|
|
|
|
// Test direct timestamp
|
|
const timestamp2 = try parseReleaseTimestamp("1704067200");
|
|
try std.testing.expectEqual(@as(i64, 1704067200), timestamp2);
|
|
|
|
// Test invalid format (should return 0)
|
|
const timestamp3 = parseReleaseTimestamp("invalid") catch 0;
|
|
try std.testing.expectEqual(@as(i64, 0), timestamp3);
|
|
|
|
// Test empty string
|
|
const timestamp4 = parseReleaseTimestamp("") catch 0;
|
|
try std.testing.expectEqual(@as(i64, 0), timestamp4);
|
|
|
|
// Test different ISO formats
|
|
const timestamp5 = try parseReleaseTimestamp("2024-12-25T15:30:45Z");
|
|
try std.testing.expect(timestamp5 > timestamp1);
|
|
}
|
|
|
|
test "filterNewReleases correctly filters by timestamp" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
const old_release = Release{
|
|
.repo_name = "test/old",
|
|
.tag_name = "v1.0.0",
|
|
.published_at = "2024-01-01T00:00:00Z",
|
|
.html_url = "https://github.com/test/old/releases/tag/v1.0.0",
|
|
.description = "Old release",
|
|
.provider = "github",
|
|
};
|
|
|
|
const new_release = Release{
|
|
.repo_name = "test/new",
|
|
.tag_name = "v2.0.0",
|
|
.published_at = "2024-06-01T00:00:00Z",
|
|
.html_url = "https://github.com/test/new/releases/tag/v2.0.0",
|
|
.description = "New release",
|
|
.provider = "github",
|
|
};
|
|
|
|
const all_releases = [_]Release{ old_release, new_release };
|
|
|
|
// Filter with timestamp between the two releases
|
|
const march_timestamp = try parseReleaseTimestamp("2024-03-01T00:00:00Z");
|
|
var filtered = try filterNewReleases(allocator, &all_releases, march_timestamp);
|
|
defer {
|
|
for (filtered.items) |release| {
|
|
release.deinit(allocator);
|
|
}
|
|
filtered.deinit();
|
|
}
|
|
|
|
// Should only contain the new release
|
|
try std.testing.expectEqual(@as(usize, 1), filtered.items.len);
|
|
try std.testing.expectEqualStrings("test/new", filtered.items[0].repo_name);
|
|
}
|
|
|
|
test "loadExistingReleases handles various XML structures" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
// Test with minimal valid XML
|
|
const minimal_xml =
|
|
\\<?xml version="1.0" encoding="UTF-8"?>
|
|
\\<feed xmlns="http://www.w3.org/2005/Atom">
|
|
\\<title>Repository Releases</title>
|
|
\\<entry>
|
|
\\ <title>minimal/repo - v1.0.0</title>
|
|
\\ <link href="https://github.com/minimal/repo/releases/tag/v1.0.0"/>
|
|
\\ <updated>2024-01-01T00:00:00Z</updated>
|
|
\\</entry>
|
|
\\</feed>
|
|
;
|
|
|
|
const temp_filename = "test_minimal.xml";
|
|
|
|
// Write test XML to file
|
|
{
|
|
const file = try std.fs.cwd().createFile(temp_filename, .{});
|
|
defer file.close();
|
|
try file.writeAll(minimal_xml);
|
|
}
|
|
defer std.fs.cwd().deleteFile(temp_filename) catch {};
|
|
|
|
// Load existing releases
|
|
var releases = try loadExistingReleases(allocator, temp_filename);
|
|
defer {
|
|
for (releases.items) |release| {
|
|
release.deinit(allocator);
|
|
}
|
|
releases.deinit();
|
|
}
|
|
|
|
try std.testing.expectEqual(@as(usize, 1), releases.items.len);
|
|
try std.testing.expectEqualStrings("minimal/repo", releases.items[0].repo_name);
|
|
try std.testing.expectEqualStrings("v1.0.0", releases.items[0].tag_name);
|
|
try std.testing.expectEqualStrings("2024-01-01T00:00:00Z", releases.items[0].published_at);
|
|
}
|
|
|
|
test "loadExistingReleases with complex XML content" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
// Test with complex XML including escaped characters and multiple entries
|
|
const complex_xml =
|
|
\\<?xml version="1.0" encoding="UTF-8"?>
|
|
\\<feed xmlns="http://www.w3.org/2005/Atom">
|
|
\\<title>Repository Releases</title>
|
|
\\<subtitle>New releases from starred repositories</subtitle>
|
|
\\<link href="https://github.com" rel="alternate"/>
|
|
\\<link href="https://example.com/releases.xml" rel="self"/>
|
|
\\<id>https://example.com/releases</id>
|
|
\\<updated>2024-01-01T00:00:00Z</updated>
|
|
\\<entry>
|
|
\\ <title>complex/repo & more - v1.0.0 <beta></title>
|
|
\\ <link href="https://github.com/complex/repo/releases/tag/v1.0.0"/>
|
|
\\ <id>https://github.com/complex/repo/releases/tag/v1.0.0</id>
|
|
\\ <updated>2024-01-01T00:00:00Z</updated>
|
|
\\ <author><n>github</n></author>
|
|
\\ <summary>Release with "special" characters & symbols</summary>
|
|
\\ <category term="github"/>
|
|
\\</entry>
|
|
\\<entry>
|
|
\\ <title>another/repo - v2.0.0</title>
|
|
\\ <link href="https://gitlab.com/another/repo/-/releases/v2.0.0"/>
|
|
\\ <id>https://gitlab.com/another/repo/-/releases/v2.0.0</id>
|
|
\\ <updated>2024-01-02T12:30:45Z</updated>
|
|
\\ <author><n>gitlab</n></author>
|
|
\\ <summary>Another release</summary>
|
|
\\ <category term="gitlab"/>
|
|
\\</entry>
|
|
\\</feed>
|
|
;
|
|
|
|
const temp_filename = "test_complex.xml";
|
|
|
|
// Write test XML to file
|
|
{
|
|
const file = try std.fs.cwd().createFile(temp_filename, .{});
|
|
defer file.close();
|
|
try file.writeAll(complex_xml);
|
|
}
|
|
defer std.fs.cwd().deleteFile(temp_filename) catch {};
|
|
|
|
// Load existing releases
|
|
var releases = try loadExistingReleases(allocator, temp_filename);
|
|
defer {
|
|
for (releases.items) |release| {
|
|
release.deinit(allocator);
|
|
}
|
|
releases.deinit();
|
|
}
|
|
|
|
try std.testing.expectEqual(@as(usize, 2), releases.items.len);
|
|
|
|
// Check first release with escaped characters
|
|
try std.testing.expectEqualStrings("complex/repo & more", releases.items[0].repo_name);
|
|
try std.testing.expectEqualStrings("v1.0.0 <beta>", releases.items[0].tag_name);
|
|
try std.testing.expectEqualStrings("Release with \"special\" characters & symbols", releases.items[0].description);
|
|
try std.testing.expectEqualStrings("github", releases.items[0].provider);
|
|
|
|
// Check second release
|
|
try std.testing.expectEqualStrings("another/repo", releases.items[1].repo_name);
|
|
try std.testing.expectEqualStrings("v2.0.0", releases.items[1].tag_name);
|
|
try std.testing.expectEqualStrings("gitlab", releases.items[1].provider);
|
|
}
|
|
|
|
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 "XML parsing handles malformed entries gracefully" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
// Test with partially malformed XML (missing closing tags, etc.)
|
|
const malformed_xml =
|
|
\\<?xml version="1.0" encoding="UTF-8"?>
|
|
\\<feed xmlns="http://www.w3.org/2005/Atom">
|
|
\\<title>Repository Releases</title>
|
|
\\<entry>
|
|
\\ <title>good/repo - v1.0.0</title>
|
|
\\ <link href="https://github.com/good/repo/releases/tag/v1.0.0"/>
|
|
\\ <updated>2024-01-01T00:00:00Z</updated>
|
|
\\</entry>
|
|
\\<entry>
|
|
\\ <title>broken/repo - v2.0.0
|
|
\\ <link href="https://github.com/broken/repo/releases/tag/v2.0.0"/>
|
|
\\ <updated>2024-01-02T00:00:00Z</updated>
|
|
\\</entry>
|
|
\\<entry>
|
|
\\ <title>another/good - v3.0.0</title>
|
|
\\ <link href="https://github.com/another/good/releases/tag/v3.0.0"/>
|
|
\\ <updated>2024-01-03T00:00:00Z</updated>
|
|
\\</entry>
|
|
\\</feed>
|
|
;
|
|
|
|
var releases = try xml_parser.parseAtomFeed(allocator, malformed_xml);
|
|
defer {
|
|
for (releases.items) |release| {
|
|
release.deinit(allocator);
|
|
}
|
|
releases.deinit();
|
|
}
|
|
|
|
// Should parse the good entries and skip/handle the malformed one gracefully
|
|
try std.testing.expect(releases.items.len >= 2);
|
|
|
|
// Check that we got the good entries
|
|
var found_good = false;
|
|
var found_another_good = false;
|
|
for (releases.items) |release| {
|
|
if (std.mem.eql(u8, release.repo_name, "good/repo")) {
|
|
found_good = true;
|
|
}
|
|
if (std.mem.eql(u8, release.repo_name, "another/good")) {
|
|
found_another_good = true;
|
|
}
|
|
}
|
|
try std.testing.expect(found_good);
|
|
try std.testing.expect(found_another_good);
|
|
}
|
|
|
|
test "compareReleasesByDate" {
|
|
const release1 = Release{
|
|
.repo_name = "test/repo1",
|
|
.tag_name = "v1.0.0",
|
|
.published_at = "2024-01-01T00:00:00Z",
|
|
.html_url = "https://github.com/test/repo1/releases/tag/v1.0.0",
|
|
.description = "First release",
|
|
.provider = "github",
|
|
};
|
|
|
|
const release2 = Release{
|
|
.repo_name = "test/repo2",
|
|
.tag_name = "v2.0.0",
|
|
.published_at = "2024-01-02T00:00:00Z",
|
|
.html_url = "https://github.com/test/repo2/releases/tag/v2.0.0",
|
|
.description = "Second release",
|
|
.provider = "github",
|
|
};
|
|
|
|
// release2 should come before release1 (more recent first)
|
|
try std.testing.expect(compareReleasesByDate({}, release2, release1));
|
|
try std.testing.expect(!compareReleasesByDate({}, release1, release2));
|
|
}
|
|
|
|
// Import XML parser tests
|
|
test {
|
|
std.testing.refAllDecls(@import("xml_parser_tests.zig"));
|
|
}
|
|
|
|
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 = try std.fmt.allocPrint(allocator, "{}", .{now - 86400}), // 1 day ago
|
|
.html_url = "https://github.com/test/recent/releases/tag/v1.0.0",
|
|
.description = "Recent release",
|
|
.provider = "github",
|
|
};
|
|
defer allocator.free(recent_release.published_at);
|
|
|
|
const old_release = Release{
|
|
.repo_name = "test/old",
|
|
.tag_name = "v0.1.0",
|
|
.published_at = try std.fmt.allocPrint(allocator, "{}", .{two_years_ago}),
|
|
.html_url = "https://github.com/test/old/releases/tag/v0.1.0",
|
|
.description = "Old release",
|
|
.provider = "github",
|
|
};
|
|
defer allocator.free(old_release.published_at);
|
|
|
|
const borderline_release = Release{
|
|
.repo_name = "test/borderline",
|
|
.tag_name = "v0.5.0",
|
|
.published_at = try std.fmt.allocPrint(allocator, "{}", .{one_year_ago + 3600}), // 1 hour within limit
|
|
.html_url = "https://github.com/test/borderline/releases/tag/v0.5.0",
|
|
.description = "Borderline release",
|
|
.provider = "github",
|
|
};
|
|
defer allocator.free(borderline_release.published_at);
|
|
|
|
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 = parseReleaseTimestamp(release.published_at) catch 0;
|
|
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);
|
|
}
|
|
|
|
test "RELEASE_AGE_LIMIT_SECONDS constant verification" {
|
|
// Verify the constant is set to 1 year in seconds
|
|
const expected_year_in_seconds = 365 * 24 * 60 * 60;
|
|
try std.testing.expectEqual(expected_year_in_seconds, RELEASE_AGE_LIMIT_SECONDS);
|
|
|
|
// Verify it's approximately 31.5 million seconds (1 year)
|
|
try std.testing.expect(RELEASE_AGE_LIMIT_SECONDS > 31_000_000);
|
|
try std.testing.expect(RELEASE_AGE_LIMIT_SECONDS < 32_000_000);
|
|
}
|
|
|
|
// Import timestamp tests
|
|
test {
|
|
std.testing.refAllDecls(@import("timestamp_tests.zig"));
|
|
}
|