clean up providers/use zeit
This commit is contained in:
parent
7a5d10bc82
commit
e192a3f9c5
6 changed files with 636 additions and 182 deletions
|
@ -11,13 +11,13 @@ vtable: *const VTable,
|
||||||
const Provider = @This();
|
const Provider = @This();
|
||||||
|
|
||||||
pub const VTable = struct {
|
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,
|
getName: *const fn (ptr: *anyopaque) []const u8,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Fetch releases from this provider
|
/// Fetch releases from this provider
|
||||||
pub fn fetchReleases(self: Provider, allocator: Allocator, token: []const u8) !ArrayList(Release) {
|
pub fn fetchReleases(self: Provider, allocator: Allocator) !ArrayList(Release) {
|
||||||
return self.vtable.fetchReleases(self.ptr, allocator, token);
|
return self.vtable.fetchReleases(self.ptr, allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the name of this provider
|
/// 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");
|
if (ptr_info.pointer.size != .one) @compileError("Provider.init expects a single-item pointer");
|
||||||
|
|
||||||
const gen = struct {
|
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));
|
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 {
|
fn getNameImpl(ptr: *anyopaque) []const u8 {
|
||||||
|
@ -60,9 +60,8 @@ test "Provider interface" {
|
||||||
const TestProvider = struct {
|
const TestProvider = struct {
|
||||||
name: []const u8,
|
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;
|
_ = self;
|
||||||
_ = token;
|
|
||||||
return ArrayList(Release).init(allocator);
|
return ArrayList(Release).init(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,7 +74,7 @@ test "Provider interface" {
|
||||||
const provider = Provider.init(&test_provider);
|
const provider = Provider.init(&test_provider);
|
||||||
|
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const releases = try provider.fetchReleases(allocator, "token");
|
const releases = try provider.fetchReleases(allocator);
|
||||||
defer releases.deinit();
|
defer releases.deinit();
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("test", provider.getName());
|
try std.testing.expectEqualStrings("test", provider.getName());
|
||||||
|
|
145
src/main.zig
145
src/main.zig
|
@ -11,6 +11,7 @@ const codeberg = @import("providers/codeberg.zig");
|
||||||
const sourcehut = @import("providers/sourcehut.zig");
|
const sourcehut = @import("providers/sourcehut.zig");
|
||||||
const atom = @import("atom.zig");
|
const atom = @import("atom.zig");
|
||||||
const config = @import("config.zig");
|
const config = @import("config.zig");
|
||||||
|
const zeit = @import("zeit");
|
||||||
|
|
||||||
const Provider = @import("Provider.zig");
|
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 {
|
const ProviderResult = struct {
|
||||||
provider_name: []const u8,
|
provider_name: []const u8,
|
||||||
releases: ArrayList(Release),
|
releases: ArrayList(Release),
|
||||||
|
@ -45,7 +40,7 @@ const ProviderResult = struct {
|
||||||
};
|
};
|
||||||
|
|
||||||
const ThreadContext = struct {
|
const ThreadContext = struct {
|
||||||
provider_config: ProviderConfig,
|
provider: Provider,
|
||||||
latest_release_date: i64,
|
latest_release_date: i64,
|
||||||
result: *ProviderResult,
|
result: *ProviderResult,
|
||||||
allocator: Allocator,
|
allocator: Allocator,
|
||||||
|
@ -100,22 +95,38 @@ pub fn main() !void {
|
||||||
|
|
||||||
print("Fetching releases from all providers concurrently...\n", .{});
|
print("Fetching releases from all providers concurrently...\n", .{});
|
||||||
|
|
||||||
// Initialize all providers
|
// Create providers list
|
||||||
var github_provider = github.GitHubProvider{};
|
var providers = std.ArrayList(Provider).init(allocator);
|
||||||
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);
|
|
||||||
defer providers.deinit();
|
defer providers.deinit();
|
||||||
|
|
||||||
try providers.append(.{ .provider = Provider.init(&github_provider), .token = app_config.github_token, .name = "github" });
|
// Initialize providers with their tokens (need to persist for the lifetime of the program)
|
||||||
try providers.append(.{ .provider = Provider.init(&gitlab_provider), .token = app_config.gitlab_token, .name = "gitlab" });
|
var github_provider: ?github.GitHubProvider = null;
|
||||||
try providers.append(.{ .provider = Provider.init(&codeberg_provider), .token = app_config.codeberg_token, .name = "codeberg" });
|
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
|
// Fetch releases from all providers concurrently using thread pool
|
||||||
const provider_results = try fetchReleasesFromAllProviders(allocator, providers.items, existing_releases.items);
|
const provider_results = try fetchReleasesFromAllProviders(allocator, providers.items, existing_releases.items);
|
||||||
|
@ -131,23 +142,6 @@ pub fn main() !void {
|
||||||
allocator.free(provider_results);
|
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
|
// Combine all new releases from threaded providers
|
||||||
for (provider_results) |result| {
|
for (provider_results) |result| {
|
||||||
try new_releases.appendSlice(result.releases.items);
|
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);
|
const existing_to_add = @min(existing_releases.items.len, remaining_slots);
|
||||||
try all_releases.appendSlice(existing_releases.items[0..existing_to_add]);
|
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
|
// Generate Atom feed
|
||||||
const atom_content = try atom.generateFeed(allocator, all_releases.items);
|
const atom_content = try atom.generateFeed(allocator, all_releases.items);
|
||||||
defer allocator.free(atom_content);
|
defer allocator.free(atom_content);
|
||||||
|
@ -182,7 +179,10 @@ pub fn main() !void {
|
||||||
};
|
};
|
||||||
defer atom_file.close();
|
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("Atom feed generated: releases.xml\n", .{});
|
||||||
print("Found {} new releases\n", .{new_releases.items.len});
|
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 {
|
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
|
// Try parsing as direct timestamp first
|
||||||
if (std.fmt.parseInt(i64, date_str, 10)) |timestamp| {
|
if (std.fmt.parseInt(i64, date_str, 10)) |timestamp| {
|
||||||
return timestamp;
|
return timestamp;
|
||||||
} else |_| {
|
} else |_| {
|
||||||
// Try parsing ISO 8601 format (basic implementation)
|
// Try parsing as ISO 8601 format using Zeit
|
||||||
if (std.mem.indexOf(u8, date_str, "T")) |t_pos| {
|
const instant = zeit.instant(.{
|
||||||
const date_part = date_str[0..t_pos];
|
.source = .{ .iso8601 = date_str },
|
||||||
var date_parts = std.mem.splitScalar(u8, date_part, '-');
|
}) catch return 0;
|
||||||
|
return @intCast(instant.timestamp);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
fn formatTimestampForDisplay(allocator: Allocator, timestamp: i64) ![]const u8 {
|
||||||
|
@ -389,7 +375,7 @@ fn formatTimestampForDisplay(allocator: Allocator, timestamp: i64) ![]const u8 {
|
||||||
|
|
||||||
fn fetchReleasesFromAllProviders(
|
fn fetchReleasesFromAllProviders(
|
||||||
allocator: Allocator,
|
allocator: Allocator,
|
||||||
providers: []const ProviderConfig,
|
providers: []const Provider,
|
||||||
existing_releases: []const Release,
|
existing_releases: []const Release,
|
||||||
) ![]ProviderResult {
|
) ![]ProviderResult {
|
||||||
var results = try allocator.alloc(ProviderResult, providers.len);
|
var results = try allocator.alloc(ProviderResult, providers.len);
|
||||||
|
@ -397,7 +383,7 @@ fn fetchReleasesFromAllProviders(
|
||||||
// Initialize results
|
// Initialize results
|
||||||
for (results, 0..) |*result, i| {
|
for (results, 0..) |*result, i| {
|
||||||
result.* = ProviderResult{
|
result.* = ProviderResult{
|
||||||
.provider_name = providers[i].name,
|
.provider_name = providers[i].getName(),
|
||||||
.releases = ArrayList(Release).init(allocator),
|
.releases = ArrayList(Release).init(allocator),
|
||||||
.error_msg = null,
|
.error_msg = null,
|
||||||
};
|
};
|
||||||
|
@ -412,12 +398,11 @@ fn fetchReleasesFromAllProviders(
|
||||||
defer allocator.free(contexts);
|
defer allocator.free(contexts);
|
||||||
|
|
||||||
// Calculate the latest release date for each provider from existing releases
|
// Calculate the latest release date for each provider from existing releases
|
||||||
for (providers, 0..) |provider_config, i| {
|
for (providers, 0..) |provider, i| {
|
||||||
if (provider_config.token) |_| {
|
|
||||||
// Find the latest release date for this provider
|
// Find the latest release date for this provider
|
||||||
var latest_date: i64 = 0;
|
var latest_date: i64 = 0;
|
||||||
for (existing_releases) |release| {
|
for (existing_releases) |release| {
|
||||||
if (std.mem.eql(u8, release.provider, provider_config.name)) {
|
if (std.mem.eql(u8, release.provider, provider.getName())) {
|
||||||
const release_time = parseReleaseTimestamp(release.published_at) catch 0;
|
const release_time = parseReleaseTimestamp(release.published_at) catch 0;
|
||||||
if (release_time > latest_date) {
|
if (release_time > latest_date) {
|
||||||
latest_date = release_time;
|
latest_date = release_time;
|
||||||
|
@ -426,41 +411,34 @@ fn fetchReleasesFromAllProviders(
|
||||||
}
|
}
|
||||||
|
|
||||||
contexts[i] = ThreadContext{
|
contexts[i] = ThreadContext{
|
||||||
.provider_config = provider_config,
|
.provider = provider,
|
||||||
.latest_release_date = latest_date,
|
.latest_release_date = latest_date,
|
||||||
.result = &results[i],
|
.result = &results[i],
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
};
|
};
|
||||||
|
|
||||||
threads[i] = try Thread.spawn(.{}, fetchProviderReleases, .{&contexts[i]});
|
threads[i] = try Thread.spawn(.{}, fetchProviderReleases, .{&contexts[i]});
|
||||||
} else {
|
|
||||||
// No token, skip this provider
|
|
||||||
print("Skipping {s} - no token provided\n", .{provider_config.name});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all threads to complete
|
// Wait for all threads to complete
|
||||||
for (providers, 0..) |provider_config, i| {
|
for (providers, 0..) |_, i| {
|
||||||
if (provider_config.token != null) {
|
|
||||||
threads[i].join();
|
threads[i].join();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetchProviderReleases(context: *const ThreadContext) void {
|
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 latest_release_date = context.latest_release_date;
|
||||||
const result = context.result;
|
const result = context.result;
|
||||||
const allocator = context.allocator;
|
const allocator = context.allocator;
|
||||||
|
|
||||||
const since_str = formatTimestampForDisplay(allocator, latest_release_date) catch "unknown";
|
const since_str = formatTimestampForDisplay(allocator, latest_release_date) catch "unknown";
|
||||||
defer if (!std.mem.eql(u8, since_str, "unknown")) allocator.free(since_str);
|
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.fetchReleases(allocator)) |all_releases| {
|
||||||
if (provider_config.provider.fetchReleases(allocator, token)) |all_releases| {
|
|
||||||
defer {
|
defer {
|
||||||
for (all_releases.items) |release| {
|
for (all_releases.items) |release| {
|
||||||
release.deinit(allocator);
|
release.deinit(allocator);
|
||||||
|
@ -476,13 +454,10 @@ fn fetchProviderReleases(context: *const ThreadContext) void {
|
||||||
};
|
};
|
||||||
|
|
||||||
result.releases = filtered;
|
result.releases = filtered;
|
||||||
print("✓ {s}: Found {} new releases\n", .{ provider_config.name, filtered.items.len });
|
print("✓ {s}: Found {} new releases\n", .{ provider.getName(), filtered.items.len });
|
||||||
} else |err| {
|
} else |err| {
|
||||||
const error_msg = std.fmt.allocPrint(allocator, "Error fetching releases: {}", .{err}) catch "Unknown fetch error";
|
const error_msg = std.fmt.allocPrint(allocator, "Error fetching releases: {}", .{err}) catch "Unknown fetch error";
|
||||||
result.error_msg = error_msg;
|
result.error_msg = error_msg;
|
||||||
print("✗ {s}: {s}\n", .{ provider_config.name, error_msg });
|
print("✗ {s}: {s}\n", .{ provider.getName(), error_msg });
|
||||||
}
|
|
||||||
} else {
|
|
||||||
print("Skipping {s} - no token provided\n", .{provider_config.name});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,19 +3,25 @@ const http = std.http;
|
||||||
const json = std.json;
|
const json = std.json;
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const ArrayList = std.ArrayList;
|
const ArrayList = std.ArrayList;
|
||||||
|
const zeit = @import("zeit");
|
||||||
|
|
||||||
const Release = @import("../main.zig").Release;
|
const Release = @import("../main.zig").Release;
|
||||||
|
|
||||||
pub const CodebergProvider = struct {
|
pub const CodebergProvider = struct {
|
||||||
pub fn fetchReleases(self: *@This(), allocator: Allocator, token: []const u8) !ArrayList(Release) {
|
token: []const u8,
|
||||||
_ = self;
|
|
||||||
|
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 };
|
var client = http.Client{ .allocator = allocator };
|
||||||
defer client.deinit();
|
defer client.deinit();
|
||||||
|
|
||||||
var releases = ArrayList(Release).init(allocator);
|
var releases = ArrayList(Release).init(allocator);
|
||||||
|
|
||||||
// Get starred repositories (Codeberg uses Gitea API)
|
// 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 {
|
defer {
|
||||||
for (starred_repos.items) |repo| {
|
for (starred_repos.items) |repo| {
|
||||||
allocator.free(repo);
|
allocator.free(repo);
|
||||||
|
@ -25,7 +31,7 @@ pub const CodebergProvider = struct {
|
||||||
|
|
||||||
// Get releases for each repo
|
// Get releases for each repo
|
||||||
for (starred_repos.items) |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 });
|
std.debug.print("Error fetching Codeberg releases for {s}: {}\n", .{ repo, err });
|
||||||
continue;
|
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;
|
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" {
|
test "codeberg provider" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
var provider = CodebergProvider{};
|
var provider = CodebergProvider.init("");
|
||||||
|
|
||||||
// Test with empty token (should fail gracefully)
|
// 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);
|
try std.testing.expect(err == error.Unauthorized or err == error.HttpRequestFailed);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
@ -249,3 +278,75 @@ test "codeberg provider" {
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("codeberg", provider.getName());
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -3,19 +3,25 @@ const http = std.http;
|
||||||
const json = std.json;
|
const json = std.json;
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const ArrayList = std.ArrayList;
|
const ArrayList = std.ArrayList;
|
||||||
|
const zeit = @import("zeit");
|
||||||
|
|
||||||
const Release = @import("../main.zig").Release;
|
const Release = @import("../main.zig").Release;
|
||||||
|
|
||||||
pub const GitHubProvider = struct {
|
pub const GitHubProvider = struct {
|
||||||
pub fn fetchReleases(self: *@This(), allocator: Allocator, token: []const u8) !ArrayList(Release) {
|
token: []const u8,
|
||||||
_ = self;
|
|
||||||
|
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 };
|
var client = http.Client{ .allocator = allocator };
|
||||||
defer client.deinit();
|
defer client.deinit();
|
||||||
|
|
||||||
var releases = ArrayList(Release).init(allocator);
|
var releases = ArrayList(Release).init(allocator);
|
||||||
|
|
||||||
// First, get starred repositories
|
// First, get starred repositories
|
||||||
const starred_repos = try getStarredRepos(allocator, &client, token);
|
const starred_repos = try getStarredRepos(allocator, &client, self.token);
|
||||||
defer {
|
defer {
|
||||||
for (starred_repos.items) |repo| {
|
for (starred_repos.items) |repo| {
|
||||||
allocator.free(repo);
|
allocator.free(repo);
|
||||||
|
@ -25,7 +31,7 @@ pub const GitHubProvider = struct {
|
||||||
|
|
||||||
// Then get releases for each repo
|
// Then get releases for each repo
|
||||||
for (starred_repos.items) |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 });
|
std.debug.print("Error fetching releases for {s}: {}\n", .{ repo, err });
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
@ -139,16 +145,39 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8
|
||||||
try releases.append(release);
|
try releases.append(release);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort releases by date (most recent first)
|
||||||
|
std.mem.sort(Release, releases.items, {}, compareReleasesByDate);
|
||||||
|
|
||||||
return releases;
|
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" {
|
test "github provider" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
var provider = GitHubProvider{};
|
var provider = GitHubProvider.init("");
|
||||||
|
|
||||||
// Test with empty token (should fail gracefully)
|
// 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);
|
try std.testing.expect(err == error.HttpRequestFailed);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
@ -161,3 +190,72 @@ test "github provider" {
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("github", provider.getName());
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -3,19 +3,25 @@ const http = std.http;
|
||||||
const json = std.json;
|
const json = std.json;
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const ArrayList = std.ArrayList;
|
const ArrayList = std.ArrayList;
|
||||||
|
const zeit = @import("zeit");
|
||||||
|
|
||||||
const Release = @import("../main.zig").Release;
|
const Release = @import("../main.zig").Release;
|
||||||
|
|
||||||
pub const GitLabProvider = struct {
|
pub const GitLabProvider = struct {
|
||||||
pub fn fetchReleases(self: *@This(), allocator: Allocator, token: []const u8) !ArrayList(Release) {
|
token: []const u8,
|
||||||
_ = self;
|
|
||||||
|
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 };
|
var client = http.Client{ .allocator = allocator };
|
||||||
defer client.deinit();
|
defer client.deinit();
|
||||||
|
|
||||||
var releases = ArrayList(Release).init(allocator);
|
var releases = ArrayList(Release).init(allocator);
|
||||||
|
|
||||||
// Get starred projects
|
// Get starred projects
|
||||||
const starred_projects = try getStarredProjects(allocator, &client, token);
|
const starred_projects = try getStarredProjects(allocator, &client, self.token);
|
||||||
defer {
|
defer {
|
||||||
for (starred_projects.items) |project| {
|
for (starred_projects.items) |project| {
|
||||||
allocator.free(project);
|
allocator.free(project);
|
||||||
|
@ -25,7 +31,7 @@ pub const GitLabProvider = struct {
|
||||||
|
|
||||||
// Get releases for each project
|
// Get releases for each project
|
||||||
for (starred_projects.items) |project_id| {
|
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 });
|
std.debug.print("Error fetching GitLab releases for project {s}: {}\n", .{ project_id, err });
|
||||||
continue;
|
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;
|
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" {
|
test "gitlab provider" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
var provider = GitLabProvider{};
|
var provider = GitLabProvider.init("");
|
||||||
|
|
||||||
// Test with empty token (should fail gracefully)
|
// 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);
|
try std.testing.expect(err == error.HttpRequestFailed);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
@ -245,3 +274,81 @@ test "gitlab provider" {
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("gitlab", provider.getName());
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -3,14 +3,20 @@ const http = std.http;
|
||||||
const json = std.json;
|
const json = std.json;
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const ArrayList = std.ArrayList;
|
const ArrayList = std.ArrayList;
|
||||||
|
const zeit = @import("zeit");
|
||||||
|
|
||||||
const Release = @import("../main.zig").Release;
|
const Release = @import("../main.zig").Release;
|
||||||
|
|
||||||
pub const SourceHutProvider = struct {
|
pub const SourceHutProvider = struct {
|
||||||
pub fn fetchReleases(self: *@This(), allocator: Allocator, token: []const u8) !ArrayList(Release) {
|
repositories: [][]const u8,
|
||||||
_ = self;
|
token: []const u8,
|
||||||
_ = token;
|
|
||||||
return ArrayList(Release).init(allocator);
|
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) {
|
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 graphql_url = "https://git.sr.ht/query";
|
||||||
const uri = try std.Uri.parse(graphql_url);
|
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,
|
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 });
|
, .{ username, reponame });
|
||||||
defer allocator.free(request_body);
|
defer allocator.free(request_body);
|
||||||
|
|
||||||
|
@ -224,18 +230,43 @@ fn parseGraphQLResponse(allocator: Allocator, response_body: []const u8, usernam
|
||||||
else
|
else
|
||||||
ref_name.string;
|
ref_name.string;
|
||||||
|
|
||||||
// For now, use current timestamp since we can't get commit date from this simple query
|
// Extract commit date from the target commit
|
||||||
// In a real implementation, we'd need a separate query to get commit details
|
var commit_date: []const u8 = "";
|
||||||
const current_time = std.time.timestamp();
|
var commit_id: []const u8 = "";
|
||||||
const timestamp_str = try std.fmt.allocPrint(allocator, "{d}", .{current_time});
|
|
||||||
defer allocator.free(timestamp_str);
|
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{
|
const release = Release{
|
||||||
.repo_name = try std.fmt.allocPrint(allocator, "~{s}/{s}", .{ username, reponame }),
|
.repo_name = try std.fmt.allocPrint(allocator, "~{s}/{s}", .{ username, reponame }),
|
||||||
.tag_name = try allocator.dupe(u8, tag_name),
|
.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 }),
|
.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"),
|
.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;
|
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 {
|
fn parseReleaseTimestamp(date_str: []const u8) !i64 {
|
||||||
// Handle different date formats from different providers
|
return parseTimestamp(date_str);
|
||||||
// GitHub/GitLab: "2024-01-01T00:00:00Z"
|
}
|
||||||
// Simple fallback: if it's a number, treat as timestamp
|
|
||||||
|
|
||||||
if (date_str.len == 0) return 0;
|
|
||||||
|
|
||||||
|
fn parseTimestamp(date_str: []const u8) !i64 {
|
||||||
// Try parsing as direct timestamp first
|
// Try parsing as direct timestamp first
|
||||||
if (std.fmt.parseInt(i64, date_str, 10)) |timestamp| {
|
if (std.fmt.parseInt(i64, date_str, 10)) |timestamp| {
|
||||||
return timestamp;
|
return timestamp;
|
||||||
} else |_| {
|
} else |_| {
|
||||||
// Try parsing ISO 8601 format (basic implementation)
|
// Try parsing as ISO 8601 format using Zeit
|
||||||
if (std.mem.indexOf(u8, date_str, "T")) |t_pos| {
|
const instant = zeit.instant(.{
|
||||||
const date_part = date_str[0..t_pos];
|
.source = .{ .iso8601 = date_str },
|
||||||
var date_parts = std.mem.splitScalar(u8, date_part, '-');
|
}) catch return 0;
|
||||||
|
return @intCast(instant.timestamp);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return 0; // Default to epoch if we can't parse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn filterNewReleases(allocator: Allocator, all_releases: []const Release, since_timestamp: i64) !ArrayList(Release) {
|
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" {
|
test "sourcehut provider" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
var provider = SourceHutProvider{};
|
const repos = [_][]const u8{};
|
||||||
|
var provider = SourceHutProvider.init("", &repos);
|
||||||
|
|
||||||
// Test with empty token (should fail gracefully)
|
// 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);
|
try std.testing.expect(err == error.HttpRequestFailed);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
@ -334,3 +359,152 @@ test "sourcehut provider" {
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("sourcehut", provider.getName());
|
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"));
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue