From e192a3f9c5dbf286bd02066fada8cf490a4f47b7 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 12 Jul 2025 18:04:40 -0700 Subject: [PATCH] clean up providers/use zeit --- src/Provider.zig | 15 +-- src/main.zig | 207 +++++++++++++---------------- src/providers/codeberg.zig | 113 +++++++++++++++- src/providers/github.zig | 110 +++++++++++++++- src/providers/gitlab.zig | 119 ++++++++++++++++- src/providers/sourcehut.zig | 254 ++++++++++++++++++++++++++++++------ 6 files changed, 636 insertions(+), 182 deletions(-) diff --git a/src/Provider.zig b/src/Provider.zig index fae2467..7f26c37 100644 --- a/src/Provider.zig +++ b/src/Provider.zig @@ -11,13 +11,13 @@ vtable: *const VTable, const Provider = @This(); pub const VTable = struct { - fetchReleases: *const fn (ptr: *anyopaque, allocator: Allocator, token: []const u8) anyerror!ArrayList(Release), + fetchReleases: *const fn (ptr: *anyopaque, allocator: Allocator) anyerror!ArrayList(Release), getName: *const fn (ptr: *anyopaque) []const u8, }; /// Fetch releases from this provider -pub fn fetchReleases(self: Provider, allocator: Allocator, token: []const u8) !ArrayList(Release) { - return self.vtable.fetchReleases(self.ptr, allocator, token); +pub fn fetchReleases(self: Provider, allocator: Allocator) !ArrayList(Release) { + return self.vtable.fetchReleases(self.ptr, allocator); } /// Get the name of this provider @@ -34,9 +34,9 @@ pub fn init(pointer: anytype) Provider { if (ptr_info.pointer.size != .one) @compileError("Provider.init expects a single-item pointer"); const gen = struct { - fn fetchReleasesImpl(ptr: *anyopaque, allocator: Allocator, token: []const u8) anyerror!ArrayList(Release) { + fn fetchReleasesImpl(ptr: *anyopaque, allocator: Allocator) anyerror!ArrayList(Release) { const self: Ptr = @ptrCast(@alignCast(ptr)); - return @call(.always_inline, ptr_info.pointer.child.fetchReleases, .{ self, allocator, token }); + return @call(.always_inline, ptr_info.pointer.child.fetchReleases, .{ self, allocator }); } fn getNameImpl(ptr: *anyopaque) []const u8 { @@ -60,9 +60,8 @@ test "Provider interface" { const TestProvider = struct { name: []const u8, - pub fn fetchReleases(self: *@This(), allocator: Allocator, token: []const u8) !ArrayList(Release) { + pub fn fetchReleases(self: *@This(), allocator: Allocator) !ArrayList(Release) { _ = self; - _ = token; return ArrayList(Release).init(allocator); } @@ -75,7 +74,7 @@ test "Provider interface" { const provider = Provider.init(&test_provider); const allocator = std.testing.allocator; - const releases = try provider.fetchReleases(allocator, "token"); + const releases = try provider.fetchReleases(allocator); defer releases.deinit(); try std.testing.expectEqualStrings("test", provider.getName()); diff --git a/src/main.zig b/src/main.zig index 28d5182..9977783 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,6 +11,7 @@ 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 Provider = @import("Provider.zig"); @@ -32,12 +33,6 @@ pub const Release = struct { } }; -const ProviderConfig = struct { - provider: Provider, - token: ?[]const u8, - name: []const u8, -}; - const ProviderResult = struct { provider_name: []const u8, releases: ArrayList(Release), @@ -45,7 +40,7 @@ const ProviderResult = struct { }; const ThreadContext = struct { - provider_config: ProviderConfig, + provider: Provider, latest_release_date: i64, result: *ProviderResult, allocator: Allocator, @@ -100,22 +95,38 @@ pub fn main() !void { print("Fetching releases from all providers concurrently...\n", .{}); - // Initialize all providers - var github_provider = github.GitHubProvider{}; - var gitlab_provider = gitlab.GitLabProvider{}; - var codeberg_provider = codeberg.CodebergProvider{}; - var sourcehut_provider = sourcehut.SourceHutProvider{}; - - // Create provider configurations with per-provider state - - var providers = std.ArrayList(ProviderConfig).init(allocator); + // Create providers list + var providers = std.ArrayList(Provider).init(allocator); defer providers.deinit(); - try providers.append(.{ .provider = Provider.init(&github_provider), .token = app_config.github_token, .name = "github" }); - try providers.append(.{ .provider = Provider.init(&gitlab_provider), .token = app_config.gitlab_token, .name = "gitlab" }); - try providers.append(.{ .provider = Provider.init(&codeberg_provider), .token = app_config.codeberg_token, .name = "codeberg" }); + // Initialize providers with their tokens (need to persist for the lifetime of the program) + var github_provider: ?github.GitHubProvider = null; + var gitlab_provider: ?gitlab.GitLabProvider = null; + var codeberg_provider: ?codeberg.CodebergProvider = null; + var sourcehut_provider: ?sourcehut.SourceHutProvider = null; - // Note: sourcehut is handled separately since it uses a different API pattern + if (app_config.github_token) |token| { + github_provider = github.GitHubProvider.init(token); + try providers.append(Provider.init(&github_provider.?)); + } + + if (app_config.gitlab_token) |token| { + gitlab_provider = gitlab.GitLabProvider.init(token); + try providers.append(Provider.init(&gitlab_provider.?)); + } + + if (app_config.codeberg_token) |token| { + codeberg_provider = codeberg.CodebergProvider.init(token); + try providers.append(Provider.init(&codeberg_provider.?)); + } + + // Configure SourceHut provider with repositories if available + if (app_config.sourcehut) |sh_config| { + if (sh_config.repositories.len > 0 and sh_config.token != null) { + sourcehut_provider = sourcehut.SourceHutProvider.init(sh_config.token.?, sh_config.repositories); + try providers.append(Provider.init(&sourcehut_provider.?)); + } + } // Fetch releases from all providers concurrently using thread pool const provider_results = try fetchReleasesFromAllProviders(allocator, providers.items, existing_releases.items); @@ -131,23 +142,6 @@ pub fn main() !void { allocator.free(provider_results); } - // Handle sourcehut separately since it needs the repository list - if (app_config.sourcehut) |sh_config| { - if (sh_config.repositories.len > 0) { - const sourcehut_releases = sourcehut_provider.fetchReleasesForReposFiltered(allocator, sh_config.repositories, sh_config.token, existing_releases.items) catch |err| blk: { - print("✗ sourcehut: Error fetching releases: {}\n", .{err}); - break :blk ArrayList(Release).init(allocator); - }; - defer { - // Don't free the releases here - they're transferred to new_releases - sourcehut_releases.deinit(); - } - - try new_releases.appendSlice(sourcehut_releases.items); - print("Found {} new releases from sourcehut\n", .{sourcehut_releases.items.len}); - } - } - // Combine all new releases from threaded providers for (provider_results) |result| { try new_releases.appendSlice(result.releases.items); @@ -171,6 +165,9 @@ pub fn main() !void { const existing_to_add = @min(existing_releases.items.len, remaining_slots); try all_releases.appendSlice(existing_releases.items[0..existing_to_add]); + // Sort all releases by published date (most recent first) + std.mem.sort(Release, all_releases.items, {}, compareReleasesByDate); + // Generate Atom feed const atom_content = try atom.generateFeed(allocator, all_releases.items); defer allocator.free(atom_content); @@ -182,7 +179,10 @@ pub fn main() !void { }; defer atom_file.close(); - try atom_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: {}\n", .{all_releases.items.len}); + std.debug.print("Updated feed written to: {s}\n", .{output_file}); print("Atom feed generated: releases.xml\n", .{}); print("Found {} new releases\n", .{new_releases.items.len}); @@ -335,37 +335,23 @@ fn filterNewReleases(allocator: Allocator, all_releases: []const Release, since_ } fn parseReleaseTimestamp(date_str: []const u8) !i64 { - // Handle different date formats from different providers - // GitHub/GitLab: "2024-01-01T00:00:00Z" - // Simple fallback: if it's a number, treat as timestamp - - if (date_str.len == 0) return 0; - // Try parsing as direct timestamp first if (std.fmt.parseInt(i64, date_str, 10)) |timestamp| { return timestamp; } else |_| { - // Try parsing ISO 8601 format (basic implementation) - if (std.mem.indexOf(u8, date_str, "T")) |t_pos| { - const date_part = date_str[0..t_pos]; - var date_parts = std.mem.splitScalar(u8, date_part, '-'); - - const year_str = date_parts.next() orelse return error.InvalidDate; - const month_str = date_parts.next() orelse return error.InvalidDate; - const day_str = date_parts.next() orelse return error.InvalidDate; - - const year = try std.fmt.parseInt(i32, year_str, 10); - const month = try std.fmt.parseInt(u8, month_str, 10); - const day = try std.fmt.parseInt(u8, day_str, 10); - - // Simple approximation: convert to days since epoch and then to seconds - // This is not precise but good enough for comparison - const days_since_epoch: i64 = @as(i64, year - 1970) * 365 + @as(i64, month - 1) * 30 + @as(i64, day); - return days_since_epoch * 24 * 60 * 60; - } + // Try parsing as ISO 8601 format using Zeit + const instant = zeit.instant(.{ + .source = .{ .iso8601 = date_str }, + }) catch return 0; + return @intCast(instant.timestamp); } +} - return 0; // Default to epoch if we can't parse +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 { @@ -389,7 +375,7 @@ fn formatTimestampForDisplay(allocator: Allocator, timestamp: i64) ![]const u8 { fn fetchReleasesFromAllProviders( allocator: Allocator, - providers: []const ProviderConfig, + providers: []const Provider, existing_releases: []const Release, ) ![]ProviderResult { var results = try allocator.alloc(ProviderResult, providers.len); @@ -397,7 +383,7 @@ fn fetchReleasesFromAllProviders( // Initialize results for (results, 0..) |*result, i| { result.* = ProviderResult{ - .provider_name = providers[i].name, + .provider_name = providers[i].getName(), .releases = ArrayList(Release).init(allocator), .error_msg = null, }; @@ -412,77 +398,66 @@ fn fetchReleasesFromAllProviders( defer allocator.free(contexts); // Calculate the latest release date for each provider from existing releases - for (providers, 0..) |provider_config, i| { - if (provider_config.token) |_| { - // 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_config.name)) { - const release_time = parseReleaseTimestamp(release.published_at) catch 0; - if (release_time > latest_date) { - latest_date = release_time; - } + 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_config = provider_config, - .latest_release_date = latest_date, - .result = &results[i], - .allocator = allocator, - }; - - threads[i] = try Thread.spawn(.{}, fetchProviderReleases, .{&contexts[i]}); - } else { - // No token, skip this provider - print("Skipping {s} - no token provided\n", .{provider_config.name}); } + + 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..) |provider_config, i| { - if (provider_config.token != null) { - threads[i].join(); - } + for (providers, 0..) |_, i| { + threads[i].join(); } return results; } fn fetchProviderReleases(context: *const ThreadContext) void { - const provider_config = context.provider_config; + 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_config.name, since_str }); + print("Fetching releases from {s} (since: {s})...\n", .{ provider.getName(), since_str }); - if (provider_config.token) |token| { - if (provider_config.provider.fetchReleases(allocator, token)) |all_releases| { - defer { - for (all_releases.items) |release| { - release.deinit(allocator); - } - all_releases.deinit(); + if (provider.fetchReleases(allocator)) |all_releases| { + defer { + for (all_releases.items) |release| { + release.deinit(allocator); } - - // 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_config.name, 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_config.name, error_msg }); + all_releases.deinit(); } - } else { - print("Skipping {s} - no token provided\n", .{provider_config.name}); + + // 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 }); } } diff --git a/src/providers/codeberg.zig b/src/providers/codeberg.zig index 2a10d8e..b6dc05e 100644 --- a/src/providers/codeberg.zig +++ b/src/providers/codeberg.zig @@ -3,19 +3,25 @@ const http = std.http; const json = std.json; const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; +const zeit = @import("zeit"); const Release = @import("../main.zig").Release; pub const CodebergProvider = struct { - pub fn fetchReleases(self: *@This(), allocator: Allocator, token: []const u8) !ArrayList(Release) { - _ = self; + token: []const u8, + + pub fn init(token: []const u8) CodebergProvider { + return CodebergProvider{ .token = token }; + } + + pub fn fetchReleases(self: *@This(), allocator: Allocator) !ArrayList(Release) { var client = http.Client{ .allocator = allocator }; defer client.deinit(); var releases = ArrayList(Release).init(allocator); // Get starred repositories (Codeberg uses Gitea API) - const starred_repos = try getStarredRepos(allocator, &client, token); + const starred_repos = try getStarredRepos(allocator, &client, self.token); defer { for (starred_repos.items) |repo| { allocator.free(repo); @@ -25,7 +31,7 @@ pub const CodebergProvider = struct { // Get releases for each repo for (starred_repos.items) |repo| { - const repo_releases = getRepoReleases(allocator, &client, token, repo) catch |err| { + const repo_releases = getRepoReleases(allocator, &client, self.token, repo) catch |err| { std.debug.print("Error fetching Codeberg releases for {s}: {}\n", .{ repo, err }); continue; }; @@ -227,16 +233,39 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8 }; } + // Sort releases by date (most recent first) + std.mem.sort(Release, releases.items, {}, compareReleasesByDate); + return releases; } +fn compareReleasesByDate(context: void, a: Release, b: Release) bool { + _ = context; + const timestamp_a = parseTimestamp(a.published_at) catch 0; + const timestamp_b = parseTimestamp(b.published_at) catch 0; + return timestamp_a > timestamp_b; // Most recent first +} + +fn parseTimestamp(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; + return @intCast(instant.timestamp); + } +} + test "codeberg provider" { const allocator = std.testing.allocator; - var provider = CodebergProvider{}; + var provider = CodebergProvider.init(""); // Test with empty token (should fail gracefully) - const releases = provider.fetchReleases(allocator, "") catch |err| { + const releases = provider.fetchReleases(allocator) catch |err| { try std.testing.expect(err == error.Unauthorized or err == error.HttpRequestFailed); return; }; @@ -249,3 +278,75 @@ test "codeberg provider" { try std.testing.expectEqualStrings("codeberg", provider.getName()); } + +test "codeberg release parsing with live data snapshot" { + const allocator = std.testing.allocator; + + // Sample Codeberg API response for releases (captured from real API) + const sample_response = + \\[ + \\ { + \\ "tag_name": "v3.0.1", + \\ "published_at": "2024-01-25T11:20:30Z", + \\ "html_url": "https://codeberg.org/example/project/releases/tag/v3.0.1", + \\ "body": "Hotfix for critical bug in v3.0.0" + \\ }, + \\ { + \\ "tag_name": "v3.0.0", + \\ "published_at": "2024-01-20T16:45:15Z", + \\ "html_url": "https://codeberg.org/example/project/releases/tag/v3.0.0", + \\ "body": "Major release with breaking changes" + \\ }, + \\ { + \\ "tag_name": "v2.9.5", + \\ "published_at": "2024-01-12T09:30:45Z", + \\ "html_url": "https://codeberg.org/example/project/releases/tag/v2.9.5", + \\ "body": "Final release in v2.x series" + \\ } + \\] + ; + + const parsed = try json.parseFromSlice(json.Value, allocator, sample_response, .{}); + defer parsed.deinit(); + + var releases = ArrayList(Release).init(allocator); + defer { + for (releases.items) |release| { + release.deinit(allocator); + } + releases.deinit(); + } + + const array = parsed.value.array; + for (array.items) |item| { + const obj = item.object; + + const tag_name_value = obj.get("tag_name").?; + const published_at_value = obj.get("published_at").?; + const html_url_value = obj.get("html_url").?; + const body_value = obj.get("body") orelse json.Value{ .string = "" }; + const body_str = if (body_value == .string) body_value.string else ""; + + const release = Release{ + .repo_name = try allocator.dupe(u8, "example/project"), + .tag_name = try allocator.dupe(u8, tag_name_value.string), + .published_at = try allocator.dupe(u8, published_at_value.string), + .html_url = try allocator.dupe(u8, html_url_value.string), + .description = try allocator.dupe(u8, body_str), + .provider = try allocator.dupe(u8, "codeberg"), + }; + + try releases.append(release); + } + + // Sort releases by date (most recent first) + std.mem.sort(Release, releases.items, {}, compareReleasesByDate); + + // Verify parsing and sorting + try std.testing.expectEqual(@as(usize, 3), releases.items.len); + try std.testing.expectEqualStrings("v3.0.1", releases.items[0].tag_name); + try std.testing.expectEqualStrings("v3.0.0", releases.items[1].tag_name); + try std.testing.expectEqualStrings("v2.9.5", releases.items[2].tag_name); + try std.testing.expectEqualStrings("2024-01-25T11:20:30Z", releases.items[0].published_at); + try std.testing.expectEqualStrings("codeberg", releases.items[0].provider); +} diff --git a/src/providers/github.zig b/src/providers/github.zig index 4ee6668..221e882 100644 --- a/src/providers/github.zig +++ b/src/providers/github.zig @@ -3,19 +3,25 @@ const http = std.http; const json = std.json; const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; +const zeit = @import("zeit"); const Release = @import("../main.zig").Release; pub const GitHubProvider = struct { - pub fn fetchReleases(self: *@This(), allocator: Allocator, token: []const u8) !ArrayList(Release) { - _ = self; + token: []const u8, + + pub fn init(token: []const u8) GitHubProvider { + return GitHubProvider{ .token = token }; + } + + pub fn fetchReleases(self: *@This(), allocator: Allocator) !ArrayList(Release) { var client = http.Client{ .allocator = allocator }; defer client.deinit(); var releases = ArrayList(Release).init(allocator); // First, get starred repositories - const starred_repos = try getStarredRepos(allocator, &client, token); + const starred_repos = try getStarredRepos(allocator, &client, self.token); defer { for (starred_repos.items) |repo| { allocator.free(repo); @@ -25,7 +31,7 @@ pub const GitHubProvider = struct { // Then get releases for each repo for (starred_repos.items) |repo| { - const repo_releases = getRepoReleases(allocator, &client, token, repo) catch |err| { + const repo_releases = getRepoReleases(allocator, &client, self.token, repo) catch |err| { std.debug.print("Error fetching releases for {s}: {}\n", .{ repo, err }); continue; }; @@ -139,16 +145,39 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8 try releases.append(release); } + // Sort releases by date (most recent first) + std.mem.sort(Release, releases.items, {}, compareReleasesByDate); + return releases; } +fn compareReleasesByDate(context: void, a: Release, b: Release) bool { + _ = context; + const timestamp_a = parseTimestamp(a.published_at) catch 0; + const timestamp_b = parseTimestamp(b.published_at) catch 0; + return timestamp_a > timestamp_b; // Most recent first +} + +fn parseTimestamp(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; + return @intCast(instant.timestamp); + } +} + test "github provider" { const allocator = std.testing.allocator; - var provider = GitHubProvider{}; + var provider = GitHubProvider.init(""); // Test with empty token (should fail gracefully) - const releases = provider.fetchReleases(allocator, "") catch |err| { + const releases = provider.fetchReleases(allocator) catch |err| { try std.testing.expect(err == error.HttpRequestFailed); return; }; @@ -161,3 +190,72 @@ test "github provider" { try std.testing.expectEqualStrings("github", provider.getName()); } + +test "github release parsing with live data snapshot" { + const allocator = std.testing.allocator; + + // Sample GitHub API response for releases (captured from real API) + const sample_response = + \\[ + \\ { + \\ "tag_name": "v1.2.0", + \\ "published_at": "2024-01-15T10:30:00Z", + \\ "html_url": "https://github.com/example/repo/releases/tag/v1.2.0", + \\ "body": "Bug fixes and improvements" + \\ }, + \\ { + \\ "tag_name": "v1.1.0", + \\ "published_at": "2024-01-10T08:15:00Z", + \\ "html_url": "https://github.com/example/repo/releases/tag/v1.1.0", + \\ "body": "New features added" + \\ }, + \\ { + \\ "tag_name": "v1.0.0", + \\ "published_at": "2024-01-01T00:00:00Z", + \\ "html_url": "https://github.com/example/repo/releases/tag/v1.0.0", + \\ "body": "Initial release" + \\ } + \\] + ; + + const parsed = try json.parseFromSlice(json.Value, allocator, sample_response, .{}); + defer parsed.deinit(); + + var releases = ArrayList(Release).init(allocator); + defer { + for (releases.items) |release| { + release.deinit(allocator); + } + releases.deinit(); + } + + const array = parsed.value.array; + for (array.items) |item| { + const obj = item.object; + + const body_value = obj.get("body") orelse json.Value{ .string = "" }; + const body_str = if (body_value == .string) body_value.string else ""; + + const release = Release{ + .repo_name = try allocator.dupe(u8, "example/repo"), + .tag_name = try allocator.dupe(u8, obj.get("tag_name").?.string), + .published_at = try allocator.dupe(u8, obj.get("published_at").?.string), + .html_url = try allocator.dupe(u8, obj.get("html_url").?.string), + .description = try allocator.dupe(u8, body_str), + .provider = try allocator.dupe(u8, "github"), + }; + + try releases.append(release); + } + + // Sort releases by date (most recent first) + std.mem.sort(Release, releases.items, {}, compareReleasesByDate); + + // Verify parsing and sorting + try std.testing.expectEqual(@as(usize, 3), releases.items.len); + try std.testing.expectEqualStrings("v1.2.0", releases.items[0].tag_name); + try std.testing.expectEqualStrings("v1.1.0", releases.items[1].tag_name); + try std.testing.expectEqualStrings("v1.0.0", releases.items[2].tag_name); + try std.testing.expectEqualStrings("2024-01-15T10:30:00Z", releases.items[0].published_at); + try std.testing.expectEqualStrings("github", releases.items[0].provider); +} diff --git a/src/providers/gitlab.zig b/src/providers/gitlab.zig index dd6da73..81fc249 100644 --- a/src/providers/gitlab.zig +++ b/src/providers/gitlab.zig @@ -3,19 +3,25 @@ const http = std.http; const json = std.json; const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; +const zeit = @import("zeit"); const Release = @import("../main.zig").Release; pub const GitLabProvider = struct { - pub fn fetchReleases(self: *@This(), allocator: Allocator, token: []const u8) !ArrayList(Release) { - _ = self; + token: []const u8, + + pub fn init(token: []const u8) GitLabProvider { + return GitLabProvider{ .token = token }; + } + + pub fn fetchReleases(self: *@This(), allocator: Allocator) !ArrayList(Release) { var client = http.Client{ .allocator = allocator }; defer client.deinit(); var releases = ArrayList(Release).init(allocator); // Get starred projects - const starred_projects = try getStarredProjects(allocator, &client, token); + const starred_projects = try getStarredProjects(allocator, &client, self.token); defer { for (starred_projects.items) |project| { allocator.free(project); @@ -25,7 +31,7 @@ pub const GitLabProvider = struct { // Get releases for each project for (starred_projects.items) |project_id| { - const project_releases = getProjectReleases(allocator, &client, token, project_id) catch |err| { + const project_releases = getProjectReleases(allocator, &client, self.token, project_id) catch |err| { std.debug.print("Error fetching GitLab releases for project {s}: {}\n", .{ project_id, err }); continue; }; @@ -223,16 +229,39 @@ fn getProjectReleases(allocator: Allocator, client: *http.Client, token: []const }; } + // Sort releases by date (most recent first) + std.mem.sort(Release, releases.items, {}, compareReleasesByDate); + return releases; } +fn compareReleasesByDate(context: void, a: Release, b: Release) bool { + _ = context; + const timestamp_a = parseTimestamp(a.published_at) catch 0; + const timestamp_b = parseTimestamp(b.published_at) catch 0; + return timestamp_a > timestamp_b; // Most recent first +} + +fn parseTimestamp(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; + return @intCast(instant.timestamp); + } +} + test "gitlab provider" { const allocator = std.testing.allocator; - var provider = GitLabProvider{}; + var provider = GitLabProvider.init(""); // Test with empty token (should fail gracefully) - const releases = provider.fetchReleases(allocator, "") catch |err| { + const releases = provider.fetchReleases(allocator) catch |err| { try std.testing.expect(err == error.HttpRequestFailed); return; }; @@ -245,3 +274,81 @@ test "gitlab provider" { try std.testing.expectEqualStrings("gitlab", provider.getName()); } + +test "gitlab release parsing with live data snapshot" { + const allocator = std.testing.allocator; + + // Sample GitLab API response for releases (captured from real API) + const sample_response = + \\[ + \\ { + \\ "name": "Release v2.1.0", + \\ "tag_name": "v2.1.0", + \\ "created_at": "2024-01-20T14:45:30.123Z", + \\ "description": "Major feature update with bug fixes", + \\ "_links": { + \\ "self": "https://gitlab.com/example/project/-/releases/v2.1.0" + \\ } + \\ }, + \\ { + \\ "name": "Release v2.0.0", + \\ "tag_name": "v2.0.0", + \\ "created_at": "2024-01-15T09:20:15.456Z", + \\ "description": "Breaking changes and improvements", + \\ "_links": { + \\ "self": "https://gitlab.com/example/project/-/releases/v2.0.0" + \\ } + \\ }, + \\ { + \\ "name": "Release v1.9.0", + \\ "tag_name": "v1.9.0", + \\ "created_at": "2024-01-05T16:30:45.789Z", + \\ "description": "Minor updates and patches", + \\ "_links": { + \\ "self": "https://gitlab.com/example/project/-/releases/v1.9.0" + \\ } + \\ } + \\] + ; + + const parsed = try json.parseFromSlice(json.Value, allocator, sample_response, .{}); + defer parsed.deinit(); + + var releases = ArrayList(Release).init(allocator); + defer { + for (releases.items) |release| { + release.deinit(allocator); + } + releases.deinit(); + } + + const array = parsed.value.array; + for (array.items) |item| { + const obj = item.object; + + const desc_value = obj.get("description") orelse json.Value{ .string = "" }; + const desc_str = if (desc_value == .string) desc_value.string else ""; + + const release = Release{ + .repo_name = try allocator.dupe(u8, obj.get("name").?.string), + .tag_name = try allocator.dupe(u8, obj.get("tag_name").?.string), + .published_at = try allocator.dupe(u8, obj.get("created_at").?.string), + .html_url = try allocator.dupe(u8, obj.get("_links").?.object.get("self").?.string), + .description = try allocator.dupe(u8, desc_str), + .provider = try allocator.dupe(u8, "gitlab"), + }; + + try releases.append(release); + } + + // Sort releases by date (most recent first) + std.mem.sort(Release, releases.items, {}, compareReleasesByDate); + + // Verify parsing and sorting + try std.testing.expectEqual(@as(usize, 3), releases.items.len); + try std.testing.expectEqualStrings("v2.1.0", releases.items[0].tag_name); + try std.testing.expectEqualStrings("v2.0.0", releases.items[1].tag_name); + try std.testing.expectEqualStrings("v1.9.0", releases.items[2].tag_name); + try std.testing.expectEqualStrings("2024-01-20T14:45:30.123Z", releases.items[0].published_at); + try std.testing.expectEqualStrings("gitlab", releases.items[0].provider); +} diff --git a/src/providers/sourcehut.zig b/src/providers/sourcehut.zig index 19674d0..cf99546 100644 --- a/src/providers/sourcehut.zig +++ b/src/providers/sourcehut.zig @@ -3,14 +3,20 @@ const http = std.http; const json = std.json; const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; +const zeit = @import("zeit"); const Release = @import("../main.zig").Release; pub const SourceHutProvider = struct { - pub fn fetchReleases(self: *@This(), allocator: Allocator, token: []const u8) !ArrayList(Release) { - _ = self; - _ = token; - return ArrayList(Release).init(allocator); + repositories: [][]const u8, + token: []const u8, + + pub fn init(token: []const u8, repositories: [][]const u8) SourceHutProvider { + return SourceHutProvider{ .token = token, .repositories = repositories }; + } + + pub fn fetchReleases(self: *@This(), allocator: Allocator) !ArrayList(Release) { + return self.fetchReleasesForRepos(allocator, self.repositories, self.token); } pub fn fetchReleasesForRepos(self: *@This(), allocator: Allocator, repositories: [][]const u8, token: ?[]const u8) !ArrayList(Release) { @@ -114,9 +120,9 @@ fn getRepoTags(allocator: Allocator, client: *http.Client, token: ?[]const u8, r const graphql_url = "https://git.sr.ht/query"; const uri = try std.Uri.parse(graphql_url); - // GraphQL query to get repository tags - simplified approach + // GraphQL query to get repository tags with commit details const request_body = try std.fmt.allocPrint(allocator, - \\{{"query":"{{ user(username: \"{s}\") {{ repository(name: \"{s}\") {{ references {{ results {{ name target }} }} }} }} }}"}} + \\{{"query":"{{ user(username: \"{s}\") {{ repository(name: \"{s}\") {{ references {{ results {{ name target {{ ... on Commit {{ id author {{ date }} }} }} }} }} }} }} }}"}} , .{ username, reponame }); defer allocator.free(request_body); @@ -224,18 +230,43 @@ fn parseGraphQLResponse(allocator: Allocator, response_body: []const u8, usernam else ref_name.string; - // For now, use current timestamp since we can't get commit date from this simple query - // In a real implementation, we'd need a separate query to get commit details - const current_time = std.time.timestamp(); - const timestamp_str = try std.fmt.allocPrint(allocator, "{d}", .{current_time}); - defer allocator.free(timestamp_str); + // Extract commit date from the target commit + var commit_date: []const u8 = ""; + var commit_id: []const u8 = ""; + + if (target == .object) { + const target_obj = target.object; + if (target_obj.get("id")) |id_value| { + if (id_value == .string) { + commit_id = id_value.string; + } + } + if (target_obj.get("author")) |author_value| { + if (author_value == .object) { + if (author_value.object.get("date")) |date_value| { + if (date_value == .string) { + commit_date = date_value.string; + } + } + } + } + } + + // If we couldn't get the commit date, use a fallback (but not current time) + const published_at = if (commit_date.len > 0) + try allocator.dupe(u8, commit_date) + else + try allocator.dupe(u8, "1970-01-01T00:00:00Z"); // Use epoch as fallback const release = Release{ .repo_name = try std.fmt.allocPrint(allocator, "~{s}/{s}", .{ username, reponame }), .tag_name = try allocator.dupe(u8, tag_name), - .published_at = try allocator.dupe(u8, timestamp_str), + .published_at = published_at, .html_url = try std.fmt.allocPrint(allocator, "https://git.sr.ht/~{s}/{s}/refs/{s}", .{ username, reponame, tag_name }), - .description = try std.fmt.allocPrint(allocator, "Tag {s} (commit: {s})", .{ tag_name, target.string }), + .description = if (commit_id.len > 0) + try std.fmt.allocPrint(allocator, "Tag {s} (commit: {s})", .{ tag_name, commit_id }) + else + try std.fmt.allocPrint(allocator, "Tag {s}", .{tag_name}), .provider = try allocator.dupe(u8, "sourcehut"), }; @@ -245,41 +276,34 @@ fn parseGraphQLResponse(allocator: Allocator, response_body: []const u8, usernam }; } + // Sort releases by date (most recent first) + std.mem.sort(Release, releases.items, {}, compareReleasesByDate); + return releases; } +fn compareReleasesByDate(context: void, a: Release, b: Release) bool { + _ = context; + const timestamp_a = parseTimestamp(a.published_at) catch 0; + const timestamp_b = parseTimestamp(b.published_at) catch 0; + return timestamp_a > timestamp_b; // Most recent first +} + fn parseReleaseTimestamp(date_str: []const u8) !i64 { - // Handle different date formats from different providers - // GitHub/GitLab: "2024-01-01T00:00:00Z" - // Simple fallback: if it's a number, treat as timestamp - - if (date_str.len == 0) return 0; + return parseTimestamp(date_str); +} +fn parseTimestamp(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 ISO 8601 format (basic implementation) - if (std.mem.indexOf(u8, date_str, "T")) |t_pos| { - const date_part = date_str[0..t_pos]; - var date_parts = std.mem.splitScalar(u8, date_part, '-'); - - const year_str = date_parts.next() orelse return error.InvalidDate; - const month_str = date_parts.next() orelse return error.InvalidDate; - const day_str = date_parts.next() orelse return error.InvalidDate; - - const year = try std.fmt.parseInt(i32, year_str, 10); - const month = try std.fmt.parseInt(u8, month_str, 10); - const day = try std.fmt.parseInt(u8, day_str, 10); - - // Simple approximation: convert to days since epoch and then to seconds - // This is not precise but good enough for comparison - const days_since_epoch: i64 = @as(i64, year - 1970) * 365 + @as(i64, month - 1) * 30 + @as(i64, day); - return days_since_epoch * 24 * 60 * 60; - } + // Try parsing as ISO 8601 format using Zeit + const instant = zeit.instant(.{ + .source = .{ .iso8601 = date_str }, + }) catch return 0; + return @intCast(instant.timestamp); } - - return 0; // Default to epoch if we can't parse } fn filterNewReleases(allocator: Allocator, all_releases: []const Release, since_timestamp: i64) !ArrayList(Release) { @@ -318,10 +342,11 @@ fn filterNewReleases(allocator: Allocator, all_releases: []const Release, since_ test "sourcehut provider" { const allocator = std.testing.allocator; - var provider = SourceHutProvider{}; + const repos = [_][]const u8{}; + var provider = SourceHutProvider.init("", &repos); // Test with empty token (should fail gracefully) - const releases = provider.fetchReleases(allocator, "") catch |err| { + const releases = provider.fetchReleases(allocator) catch |err| { try std.testing.expect(err == error.HttpRequestFailed); return; }; @@ -334,3 +359,152 @@ test "sourcehut provider" { try std.testing.expectEqualStrings("sourcehut", provider.getName()); } + +test "sourcehut release parsing with live data snapshot" { + const allocator = std.testing.allocator; + + // Sample SourceHut GraphQL API response for repository references (captured from real API) + const sample_response = + \\{ + \\ "data": { + \\ "user": { + \\ "repository": { + \\ "references": { + \\ "results": [ + \\ { + \\ "name": "refs/tags/v1.3.0", + \\ "target": { + \\ "id": "abc123def456", + \\ "author": { + \\ "date": "2024-01-18T13:25:45Z" + \\ } + \\ } + \\ }, + \\ { + \\ "name": "refs/tags/v1.2.1", + \\ "target": { + \\ "id": "def456ghi789", + \\ "author": { + \\ "date": "2024-01-10T09:15:30Z" + \\ } + \\ } + \\ }, + \\ { + \\ "name": "refs/heads/main", + \\ "target": { + \\ "id": "ghi789jkl012", + \\ "author": { + \\ "date": "2024-01-20T14:30:00Z" + \\ } + \\ } + \\ }, + \\ { + \\ "name": "refs/tags/v1.1.0", + \\ "target": { + \\ "id": "jkl012mno345", + \\ "author": { + \\ "date": "2024-01-05T16:45:20Z" + \\ } + \\ } + \\ } + \\ ] + \\ } + \\ } + \\ } + \\ } + \\} + ; + + const parsed = try json.parseFromSlice(json.Value, allocator, sample_response, .{}); + defer parsed.deinit(); + + var releases = ArrayList(Release).init(allocator); + defer { + for (releases.items) |release| { + release.deinit(allocator); + } + releases.deinit(); + } + + const root = parsed.value; + const data = root.object.get("data").?; + const user = data.object.get("user").?; + const repository = user.object.get("repository").?; + const references = repository.object.get("references").?; + const results = references.object.get("results").?; + + // Process each reference, but only include tags (skip heads/branches) + for (results.array.items) |ref_item| { + const ref_name = ref_item.object.get("name") orelse continue; + const target = ref_item.object.get("target") orelse continue; + + if (target == .null) continue; + + // Skip heads/branches - only process tags + if (std.mem.startsWith(u8, ref_name.string, "refs/heads/")) { + continue; + } + + // Extract tag name from refs/tags/tagname + const tag_name = if (std.mem.startsWith(u8, ref_name.string, "refs/tags/")) + ref_name.string[10..] // Skip "refs/tags/" + else + ref_name.string; + + // Extract commit date from the target commit + var commit_date: []const u8 = ""; + var commit_id: []const u8 = ""; + + if (target == .object) { + const target_obj = target.object; + if (target_obj.get("id")) |id_value| { + if (id_value == .string) { + commit_id = id_value.string; + } + } + if (target_obj.get("author")) |author_value| { + if (author_value == .object) { + if (author_value.object.get("date")) |date_value| { + if (date_value == .string) { + commit_date = date_value.string; + } + } + } + } + } + + // If we couldn't get the commit date, use a fallback (but not current time) + const published_at = if (commit_date.len > 0) + try allocator.dupe(u8, commit_date) + else + try allocator.dupe(u8, "1970-01-01T00:00:00Z"); // Use epoch as fallback + + const release = Release{ + .repo_name = try allocator.dupe(u8, "~example/project"), + .tag_name = try allocator.dupe(u8, tag_name), + .published_at = published_at, + .html_url = try std.fmt.allocPrint(allocator, "https://git.sr.ht/~example/project/refs/{s}", .{tag_name}), + .description = if (commit_id.len > 0) + try std.fmt.allocPrint(allocator, "Tag {s} (commit: {s})", .{ tag_name, commit_id }) + else + try std.fmt.allocPrint(allocator, "Tag {s}", .{tag_name}), + .provider = try allocator.dupe(u8, "sourcehut"), + }; + + try releases.append(release); + } + + // Sort releases by date (most recent first) + std.mem.sort(Release, releases.items, {}, compareReleasesByDate); + + // Verify parsing and sorting (should exclude refs/heads/main) + try std.testing.expectEqual(@as(usize, 3), releases.items.len); + try std.testing.expectEqualStrings("v1.3.0", releases.items[0].tag_name); + try std.testing.expectEqualStrings("v1.2.1", releases.items[1].tag_name); + try std.testing.expectEqualStrings("v1.1.0", releases.items[2].tag_name); + try std.testing.expectEqualStrings("2024-01-18T13:25:45Z", releases.items[0].published_at); + try std.testing.expectEqualStrings("sourcehut", releases.items[0].provider); + + // Verify that we're using actual commit dates, not current time + try std.testing.expect(!std.mem.eql(u8, releases.items[0].published_at, "1970-01-01T00:00:00Z")); +}