const std = @import("std"); 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; const Provider = @import("../Provider.zig"); repositories: [][]const u8, token: []const u8, const Self = @This(); pub fn init(token: []const u8, repositories: [][]const u8) Self { return Self{ .token = token, .repositories = repositories }; } pub fn provider(self: *Self) Provider { return Provider.init(self); } pub fn fetchReleases(self: *Self, allocator: Allocator) !ArrayList(Release) { return self.fetchReleasesForRepos(allocator, self.repositories, self.token); } pub fn fetchReleasesForRepos(self: *Self, allocator: Allocator, repositories: [][]const u8, token: ?[]const u8) !ArrayList(Release) { _ = self; var client = http.Client{ .allocator = allocator }; defer client.deinit(); var releases = ArrayList(Release).init(allocator); errdefer { for (releases.items) |release| { release.deinit(allocator); } releases.deinit(); } for (repositories) |repo| { const repo_tags = getRepoTags(allocator, &client, token, repo) catch |err| { std.debug.print("Error fetching SourceHut tags for {s}: {}\n", .{ repo, err }); continue; }; defer { for (repo_tags.items) |release| { release.deinit(allocator); } repo_tags.deinit(); } for (repo_tags.items) |release| { const duplicated_release = Release{ .repo_name = try allocator.dupe(u8, release.repo_name), .tag_name = try allocator.dupe(u8, release.tag_name), .published_at = try allocator.dupe(u8, release.published_at), .html_url = try allocator.dupe(u8, release.html_url), .description = try allocator.dupe(u8, release.description), .provider = try allocator.dupe(u8, release.provider), }; releases.append(duplicated_release) catch |err| { duplicated_release.deinit(allocator); return err; }; } } return releases; } pub fn fetchReleasesForReposFiltered(self: *Self, allocator: Allocator, repositories: [][]const u8, token: ?[]const u8, existing_releases: []const Release) !ArrayList(Release) { var latest_date: i64 = 0; for (existing_releases) |release| { if (std.mem.eql(u8, release.provider, "sourcehut")) { const release_time = parseReleaseTimestamp(release.published_at) catch 0; if (release_time > latest_date) { latest_date = release_time; } } } const all_releases = try self.fetchReleasesForRepos(allocator, repositories, token); defer { for (all_releases.items) |release| { release.deinit(allocator); } all_releases.deinit(); } return filterNewReleases(allocator, all_releases.items, latest_date); } pub fn getName(self: *Self) []const u8 { _ = self; return "sourcehut"; } fn getRepoTags(allocator: Allocator, client: *http.Client, token: ?[]const u8, repo: []const u8) !ArrayList(Release) { var releases = ArrayList(Release).init(allocator); errdefer { for (releases.items) |release| { release.deinit(allocator); } releases.deinit(); } // Parse repo format: "~username/reponame" or "username/reponame" const repo_clean = if (std.mem.startsWith(u8, repo, "~")) repo[1..] else repo; var parts = std.mem.splitScalar(u8, repo_clean, '/'); const username = parts.next() orelse return error.InvalidRepoFormat; const reponame = parts.next() orelse return error.InvalidRepoFormat; const auth_token = token orelse { std.debug.print("SourceHut: No token provided for {s}, skipping\n", .{repo}); return releases; }; if (auth_token.len == 0) { std.debug.print("SourceHut: Empty token for {s}, skipping\n", .{repo}); return releases; } // Use SourceHut's GraphQL API const graphql_url = "https://git.sr.ht/query"; const uri = try std.Uri.parse(graphql_url); // Use the exact same GraphQL query that worked in curl, with proper brace escaping const request_body = try std.fmt.allocPrint(allocator, "{{\"query\":\"query {{ user(username: \\\"{s}\\\") {{ repository(name: \\\"{s}\\\") {{ references {{ results {{ name target }} }} }} }} }}\"}}", .{ username, reponame }); defer allocator.free(request_body); const auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{auth_token}); defer allocator.free(auth_header); const headers: []const http.Header = &.{ .{ .name = "User-Agent", .value = "release-tracker/1.0" }, .{ .name = "Authorization", .value = auth_header }, .{ .name = "Content-Type", .value = "application/json" }, }; var server_header_buffer: [16 * 1024]u8 = undefined; var req = try client.open(.POST, uri, .{ .server_header_buffer = &server_header_buffer, .extra_headers = headers, }); defer req.deinit(); req.transfer_encoding = .{ .content_length = request_body.len }; try req.send(); try req.writeAll(request_body); try req.finish(); try req.wait(); if (req.response.status != .ok) { std.debug.print("SourceHut GraphQL API request failed with status: {} for {s}\n", .{ req.response.status, repo }); return error.HttpRequestFailed; } const body = try req.reader().readAllAlloc(allocator, 10 * 1024 * 1024); defer allocator.free(body); // First, get basic tag info const basic_releases = try parseBasicTagInfo(allocator, body, username, reponame); defer { for (basic_releases.items) |item| { allocator.free(item.tag_name); allocator.free(item.commit_id); } basic_releases.deinit(); } // If we have tags, fetch their commit dates individually if (basic_releases.items.len > 0) { return fetchCommitDatesIndividually(allocator, client, auth_token, username, reponame, basic_releases.items); } else { return ArrayList(Release).init(allocator); } } const TagInfo = struct { tag_name: []const u8, commit_id: []const u8, }; fn parseBasicTagInfo(allocator: Allocator, response_body: []const u8, username: []const u8, reponame: []const u8) !ArrayList(TagInfo) { _ = username; _ = reponame; var tag_infos = ArrayList(TagInfo).init(allocator); errdefer { for (tag_infos.items) |item| { allocator.free(item.tag_name); allocator.free(item.commit_id); } tag_infos.deinit(); } var parsed = json.parseFromSlice(json.Value, allocator, response_body, .{}) catch |err| { std.debug.print("SourceHut: Failed to parse JSON response: {}\n", .{err}); return tag_infos; }; defer parsed.deinit(); const root = parsed.value; // Check for GraphQL errors first if (root.object.get("errors")) |errors| { std.debug.print("GraphQL errors in tag parsing: ", .{}); for (errors.array.items) |error_item| { if (error_item.object.get("message")) |message| { std.debug.print("{s} ", .{message.string}); } } std.debug.print("\n", .{}); return tag_infos; } const data = root.object.get("data") orelse return tag_infos; const user = data.object.get("user") orelse return tag_infos; if (user == .null) return tag_infos; const repository = user.object.get("repository") orelse return tag_infos; if (repository == .null) return tag_infos; const references = repository.object.get("references") orelse return tag_infos; const results = references.object.get("results") orelse return tag_infos; 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; } // Only process tags if (!std.mem.startsWith(u8, ref_name.string, "refs/tags/")) { continue; } // Extract tag name from refs/tags/tagname const tag_name = ref_name.string[10..]; // Skip "refs/tags/" var commit_id: []const u8 = ""; if (target == .string) { commit_id = target.string; } // Skip if the target is not a commit ID (e.g., refs/heads/master) if (commit_id.len > 0 and !std.mem.startsWith(u8, commit_id, "refs/")) { const tag_info = TagInfo{ .tag_name = try allocator.dupe(u8, tag_name), .commit_id = try allocator.dupe(u8, commit_id), }; tag_infos.append(tag_info) catch |err| { allocator.free(tag_info.tag_name); allocator.free(tag_info.commit_id); return err; }; } } return tag_infos; } fn fetchCommitDatesIndividually(allocator: Allocator, client: *http.Client, token: []const u8, username: []const u8, reponame: []const u8, tag_infos: []const TagInfo) !ArrayList(Release) { var releases = ArrayList(Release).init(allocator); errdefer { for (releases.items) |release| { release.deinit(allocator); } releases.deinit(); } for (tag_infos) |tag_info| { const commit_date = getCommitDate(allocator, client, token, username, reponame, tag_info.commit_id) catch |err| blk: { std.debug.print("Failed to get commit date for {s}: {s}\n", .{ tag_info.commit_id, @errorName(err) }); break :blk ""; }; defer if (commit_date.len > 0) allocator.free(commit_date); const published_at = if (commit_date.len > 0) try allocator.dupe(u8, commit_date) else try allocator.dupe(u8, "1970-01-01T00:00:00Z"); const release = Release{ .repo_name = try std.fmt.allocPrint(allocator, "~{s}/{s}", .{ username, reponame }), .tag_name = try allocator.dupe(u8, tag_info.tag_name), .published_at = published_at, .html_url = try std.fmt.allocPrint(allocator, "https://git.sr.ht/~{s}/{s}/refs/{s}", .{ username, reponame, tag_info.tag_name }), .description = try std.fmt.allocPrint(allocator, "Tag {s} (commit: {s})", .{ tag_info.tag_name, tag_info.commit_id }), .provider = try allocator.dupe(u8, "sourcehut"), }; releases.append(release) catch |err| { release.deinit(allocator); return err; }; } // Sort releases by date (most recent first) std.mem.sort(Release, releases.items, {}, compareReleasesByDate); return releases; } fn getCommitDate(allocator: Allocator, client: *http.Client, token: []const u8, username: []const u8, reponame: []const u8, commit_id: []const u8) ![]const u8 { if (commit_id.len == 0) return ""; const graphql_url = "https://git.sr.ht/query"; const uri = try std.Uri.parse(graphql_url); // Use the exact same GraphQL query that worked in curl, with proper brace escaping const request_body = try std.fmt.allocPrint(allocator, "{{\"query\":\"query {{ user(username: \\\"{s}\\\") {{ repository(name: \\\"{s}\\\") {{ revparse_single(revspec: \\\"{s}\\\") {{ author {{ time }} committer {{ time }} }} }} }} }}\"}}", .{ username, reponame, commit_id }); defer allocator.free(request_body); const auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token}); defer allocator.free(auth_header); const headers: []const http.Header = &.{ .{ .name = "User-Agent", .value = "release-tracker/1.0" }, .{ .name = "Authorization", .value = auth_header }, .{ .name = "Content-Type", .value = "application/json" }, }; var server_header_buffer: [16 * 1024]u8 = undefined; var req = try client.open(.POST, uri, .{ .server_header_buffer = &server_header_buffer, .extra_headers = headers, }); defer req.deinit(); req.transfer_encoding = .{ .content_length = request_body.len }; try req.send(); try req.writeAll(request_body); try req.finish(); try req.wait(); if (req.response.status != .ok) { std.debug.print("SourceHut commit date query failed with status: {} for commit {s}\n", .{ req.response.status, commit_id }); return ""; } const body = try req.reader().readAllAlloc(allocator, 1024 * 1024); defer allocator.free(body); // Parse the response var parsed = json.parseFromSlice(json.Value, allocator, body, .{}) catch |err| { std.debug.print("Failed to parse commit date response: {}\n", .{err}); return ""; }; defer parsed.deinit(); const root = parsed.value; // Check for GraphQL errors first if (root.object.get("errors")) |errors| { std.debug.print("GraphQL errors for commit {s}: ", .{commit_id}); for (errors.array.items) |error_item| { if (error_item.object.get("message")) |message| { std.debug.print("{s} ", .{message.string}); } } std.debug.print("\n", .{}); return ""; } const data = root.object.get("data") orelse return ""; const user = data.object.get("user") orelse return ""; if (user == .null) return ""; const repository = user.object.get("repository") orelse return ""; if (repository == .null) return ""; const revparse_single = repository.object.get("revparse_single") orelse return ""; if (revparse_single == .null) return ""; // Try to get author time first, then committer time as fallback if (revparse_single.object.get("author")) |author| { if (author.object.get("time")) |time| { if (time == .string) { return try allocator.dupe(u8, time.string); } } } if (revparse_single.object.get("committer")) |committer| { if (committer.object.get("time")) |time| { if (time == .string) { return try allocator.dupe(u8, time.string); } } } return ""; } 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 { 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 as ISO 8601 format using Zeit const instant = zeit.instant(.{ .source = .{ .iso8601 = date_str }, }) catch return 0; return @intCast(instant.timestamp); } } fn filterNewReleases(allocator: Allocator, all_releases: []const Release, since_timestamp: i64) !ArrayList(Release) { var new_releases = ArrayList(Release).init(allocator); errdefer { for (new_releases.items) |release| { release.deinit(allocator); } new_releases.deinit(); } for (all_releases) |release| { // Parse the published_at timestamp const release_time = parseReleaseTimestamp(release.published_at) catch continue; if (release_time > since_timestamp) { // This is a new release, duplicate it for our list const new_release = Release{ .repo_name = try allocator.dupe(u8, release.repo_name), .tag_name = try allocator.dupe(u8, release.tag_name), .published_at = try allocator.dupe(u8, release.published_at), .html_url = try allocator.dupe(u8, release.html_url), .description = try allocator.dupe(u8, release.description), .provider = try allocator.dupe(u8, release.provider), }; new_releases.append(new_release) catch |err| { new_release.deinit(allocator); return err; }; } } return new_releases; } test "sourcehut provider" { const allocator = std.testing.allocator; const repos = [_][]const u8{}; var sourcehut_provider = init("", &repos); // Test with empty token (should fail gracefully) const releases = sourcehut_provider.fetchReleases(allocator) catch |err| { try std.testing.expect(err == error.HttpRequestFailed); return; }; defer { for (releases.items) |release| { release.deinit(allocator); } releases.deinit(); } try std.testing.expectEqualStrings("sourcehut", sourcehut_provider.getName()); }