release-tracker/src/providers/SourceHut.zig
2025-07-13 10:33:11 -07:00

477 lines
17 KiB
Zig

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());
}