Compare commits

..

5 commits

Author SHA1 Message Date
102522f373
fix gitlab provider
Some checks failed
Build and Release / build (push) Failing after 33s
Build and Release / sign (push) Has been skipped
2025-07-20 15:53:54 -07:00
22f24c1ddb
handle trailing slash 2025-07-20 15:24:14 -07:00
981a46da54
pagination support for forgejo 2025-07-20 15:23:49 -07:00
02584db325
fix stderr debacle (partial) 2025-07-20 15:23:23 -07:00
00eacdf8fc
forgejo refactor 2025-07-20 15:18:10 -07:00
8 changed files with 279 additions and 165 deletions

View file

@ -37,7 +37,13 @@ Create a `config.json` file with your API tokens:
{ {
"github_token": "your_github_token", "github_token": "your_github_token",
"gitlab_token": "your_gitlab_token", "gitlab_token": "your_gitlab_token",
"codeberg_token": "your_codeberg_token", "forgejo": [
{
"name": "codeberg",
"base_url": "https://codeberg.org",
"token": "your_codeberg_access_token_here"
}
],
"sourcehut": { "sourcehut": {
"repositories": [ "repositories": [
"~sircmpwn/aerc", "~sircmpwn/aerc",
@ -51,8 +57,8 @@ Create a `config.json` file with your API tokens:
### API Token Setup ### API Token Setup
- **GitHub**: Create a Personal Access Token with and `user:read` scope. Classic is preferred (see note) - **GitHub**: Create a Personal Access Token with and `user:read` scope. Classic is preferred (see note)
- **GitLab**: Create a Personal Access Token with `read_api` scope - **GitLab**: Create a Personal Access Token with `read_user` and `read_api` scopes
- **Codeberg**: Create an Access Token in your account settings - **Codeberg**: Create an Access Token in your account settings. read:repository and read:user
- **SourceHut**: No token required for public repositories. Specify repositories to track in the configuration. - **SourceHut**: No token required for public repositories. Specify repositories to track in the configuration.
Note on GitHub PATs. Some GitHub orgs will place additional restrictions on Note on GitHub PATs. Some GitHub orgs will place additional restrictions on

View file

@ -4,7 +4,7 @@ pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{}); const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{}); const optimize = b.standardOptimizeOption(.{});
const integration = b.option(bool, "integration", "Run integration tests") orelse false; const integration = b.option(bool, "integration", "Run integration tests") orelse false;
const provider = b.option([]const u8, "provider", "Test specific provider (github, gitlab, codeberg, sourcehut)"); const provider = b.option([]const u8, "provider", "Test specific provider (github, gitlab, forgejo, sourcehut)");
const test_debug = b.option(bool, "test-debug", "Enable debug output in tests") orelse false; const test_debug = b.option(bool, "test-debug", "Enable debug output in tests") orelse false;
// Add Zeit dependency // Add Zeit dependency
@ -79,7 +79,7 @@ pub fn build(b: *std.Build) void {
// Individual provider test steps // Individual provider test steps
const github_step = b.step("test-github", "Test GitHub provider only"); const github_step = b.step("test-github", "Test GitHub provider only");
const gitlab_step = b.step("test-gitlab", "Test GitLab provider only"); const gitlab_step = b.step("test-gitlab", "Test GitLab provider only");
const codeberg_step = b.step("test-codeberg", "Test Codeberg provider only"); const forgejo_step = b.step("test-forgejo", "Test Forgejo provider only");
const sourcehut_step = b.step("test-sourcehut", "Test SourceHut provider only"); const sourcehut_step = b.step("test-sourcehut", "Test SourceHut provider only");
const github_tests = b.addTest(.{ const github_tests = b.addTest(.{
@ -106,17 +106,17 @@ pub fn build(b: *std.Build) void {
gitlab_test_debug_option.addOption(bool, "test_debug", test_debug); gitlab_test_debug_option.addOption(bool, "test_debug", test_debug);
gitlab_tests.root_module.addOptions("build_options", gitlab_test_debug_option); gitlab_tests.root_module.addOptions("build_options", gitlab_test_debug_option);
const codeberg_tests = b.addTest(.{ const forgejo_tests = b.addTest(.{
.name = "codeberg-tests", .name = "forgejo-tests",
.root_source_file = b.path("src/integration_tests.zig"), .root_source_file = b.path("src/integration_tests.zig"),
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
.filters = &[_][]const u8{"Codeberg provider"}, .filters = &[_][]const u8{"Forgejo provider"},
}); });
codeberg_tests.root_module.addImport("zeit", zeit_dep.module("zeit")); forgejo_tests.root_module.addImport("zeit", zeit_dep.module("zeit"));
const codeberg_test_debug_option = b.addOptions(); const forgejo_test_debug_option = b.addOptions();
codeberg_test_debug_option.addOption(bool, "test_debug", test_debug); forgejo_test_debug_option.addOption(bool, "test_debug", test_debug);
codeberg_tests.root_module.addOptions("build_options", codeberg_test_debug_option); forgejo_tests.root_module.addOptions("build_options", forgejo_test_debug_option);
const sourcehut_tests = b.addTest(.{ const sourcehut_tests = b.addTest(.{
.name = "sourcehut-tests", .name = "sourcehut-tests",
@ -132,6 +132,6 @@ pub fn build(b: *std.Build) void {
github_step.dependOn(&b.addRunArtifact(github_tests).step); github_step.dependOn(&b.addRunArtifact(github_tests).step);
gitlab_step.dependOn(&b.addRunArtifact(gitlab_tests).step); gitlab_step.dependOn(&b.addRunArtifact(gitlab_tests).step);
codeberg_step.dependOn(&b.addRunArtifact(codeberg_tests).step); forgejo_step.dependOn(&b.addRunArtifact(forgejo_tests).step);
sourcehut_step.dependOn(&b.addRunArtifact(sourcehut_tests).step); sourcehut_step.dependOn(&b.addRunArtifact(sourcehut_tests).step);
} }

View file

@ -1,7 +1,13 @@
{ {
"github_token": "ghp_your_github_personal_access_token_here", "github_token": "ghp_your_github_personal_access_token_here",
"gitlab_token": "glpat-your_gitlab_personal_access_token_here", "gitlab_token": "glpat-your_gitlab_personal_access_token_here",
"codeberg_token": "your_codeberg_access_token_here", "forgejo": [
{
"name": "codeberg",
"base_url": "https://codeberg.org",
"token": "your_codeberg_access_token_here"
}
],
"sourcehut": { "sourcehut": {
"token": "your_sourcehut_token_here", "token": "your_sourcehut_token_here",
"repositories": [ "repositories": [

View file

@ -2,6 +2,31 @@ const std = @import("std");
const json = std.json; const json = std.json;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
pub const ForgejoInstance = struct {
name: []const u8,
base_url: []const u8,
token: []const u8,
allocator: Allocator,
pub fn deinit(self: *const ForgejoInstance) void {
self.allocator.free(self.name);
self.allocator.free(self.base_url);
self.allocator.free(self.token);
}
};
pub const ForgejoConfig = struct {
instances: []ForgejoInstance,
allocator: Allocator,
pub fn deinit(self: *const ForgejoConfig) void {
for (self.instances) |*instance| {
instance.deinit();
}
self.allocator.free(self.instances);
}
};
pub const SourceHutConfig = struct { pub const SourceHutConfig = struct {
token: ?[]const u8 = null, token: ?[]const u8 = null,
repositories: [][]const u8, repositories: [][]const u8,
@ -19,7 +44,8 @@ pub const SourceHutConfig = struct {
pub const Config = struct { pub const Config = struct {
github_token: ?[]const u8 = null, github_token: ?[]const u8 = null,
gitlab_token: ?[]const u8 = null, gitlab_token: ?[]const u8 = null,
codeberg_token: ?[]const u8 = null, codeberg_token: ?[]const u8 = null, // Legacy support
forgejo: ?ForgejoConfig = null,
sourcehut: ?SourceHutConfig = null, sourcehut: ?SourceHutConfig = null,
allocator: Allocator, allocator: Allocator,
@ -27,6 +53,7 @@ pub const Config = struct {
if (self.github_token) |token| self.allocator.free(token); if (self.github_token) |token| self.allocator.free(token);
if (self.gitlab_token) |token| self.allocator.free(token); if (self.gitlab_token) |token| self.allocator.free(token);
if (self.codeberg_token) |token| self.allocator.free(token); if (self.codeberg_token) |token| self.allocator.free(token);
if (self.forgejo) |*forgejo_config| forgejo_config.deinit();
if (self.sourcehut) |*sh_config| sh_config.deinit(); if (self.sourcehut) |*sh_config| sh_config.deinit();
} }
}; };
@ -72,6 +99,28 @@ pub fn parseConfigFromJson(allocator: Allocator, json_content: []const u8) !Conf
}; };
} }
// Parse forgejo instances
var forgejo_config: ?ForgejoConfig = null;
if (root.get("forgejo")) |forgejo_obj| {
const forgejo_array = forgejo_obj.array;
var instances = try allocator.alloc(ForgejoInstance, forgejo_array.items.len);
for (forgejo_array.items, 0..) |instance_obj, i| {
const instance = instance_obj.object;
instances[i] = ForgejoInstance{
.name = try allocator.dupe(u8, instance.get("name").?.string),
.base_url = try allocator.dupe(u8, instance.get("base_url").?.string),
.token = try allocator.dupe(u8, instance.get("token").?.string),
.allocator = allocator,
};
}
forgejo_config = ForgejoConfig{
.instances = instances,
.allocator = allocator,
};
}
return Config{ return Config{
.github_token = if (root.get("github_token")) |v| switch (v) { .github_token = if (root.get("github_token")) |v| switch (v) {
.string => |s| if (s.len > 0) try allocator.dupe(u8, s) else null, .string => |s| if (s.len > 0) try allocator.dupe(u8, s) else null,
@ -88,6 +137,7 @@ pub fn parseConfigFromJson(allocator: Allocator, json_content: []const u8) !Conf
.null => null, .null => null,
else => null, else => null,
} else null, } else null,
.forgejo = forgejo_config,
.sourcehut = sourcehut_config, .sourcehut = sourcehut_config,
.allocator = allocator, .allocator = allocator,
}; };

View file

@ -6,7 +6,7 @@ const atom = @import("atom.zig");
const Release = @import("main.zig").Release; const Release = @import("main.zig").Release;
const GitHub = @import("providers/GitHub.zig"); const GitHub = @import("providers/GitHub.zig");
const GitLab = @import("providers/GitLab.zig"); const GitLab = @import("providers/GitLab.zig");
const Codeberg = @import("providers/Codeberg.zig"); const Forgejo = @import("providers/Forgejo.zig");
const SourceHut = @import("providers/SourceHut.zig"); const SourceHut = @import("providers/SourceHut.zig");
const config = @import("config.zig"); const config = @import("config.zig");
@ -436,24 +436,46 @@ test "GitLab provider integration" {
} }
} }
test "Codeberg provider integration" { test "GitLab provider with empty token" {
const allocator = testing.allocator;
var gitlab_provider = GitLab.init("");
// Test with empty token (should fail gracefully)
const releases = gitlab_provider.fetchReleases(allocator) catch |err| {
try testing.expect(err == error.Unauthorized or err == error.HttpRequestFailed);
testPrint("GitLab provider correctly failed with empty token: {}\n", .{err});
return;
};
defer {
for (releases.items) |release| {
release.deinit(allocator);
}
releases.deinit();
}
// If we get here, something is wrong - empty token should fail
try testing.expect(false);
}
test "Forgejo provider integration" {
const allocator = testing.allocator; const allocator = testing.allocator;
// Load config to get token // Load config to get token
const app_config = config.loadConfig(allocator, "config.json") catch |err| { const app_config = config.loadConfig(allocator, "config.json") catch |err| {
testPrint("Skipping Codeberg test - config not available: {}\n", .{err}); testPrint("Skipping Forgejo test - config not available: {}\n", .{err});
return; return;
}; };
defer app_config.deinit(); defer app_config.deinit();
if (app_config.codeberg_token == null) { if (app_config.codeberg_token == null) {
testPrint("Skipping Codeberg test - no token configured\n", .{}); testPrint("Skipping Forgejo test - no token configured\n", .{});
return; return;
} }
var provider = Codeberg.init(app_config.codeberg_token.?); var provider = Forgejo.init("codeberg", "https://codeberg.org", app_config.codeberg_token.?);
const releases = provider.fetchReleases(allocator) catch |err| { const releases = provider.fetchReleases(allocator) catch |err| {
testPrint("Codeberg provider error: {}\n", .{err}); testPrint("Forgejo provider error: {}\n", .{err});
return; // Skip test if provider fails return; // Skip test if provider fails
}; };
defer { defer {
@ -463,7 +485,7 @@ test "Codeberg provider integration" {
releases.deinit(); releases.deinit();
} }
testPrint("Codeberg: Found {} releases\n", .{releases.items.len}); testPrint("Forgejo: Found {} releases\n", .{releases.items.len});
// Verify releases have required fields // Verify releases have required fields
for (releases.items) |release| { for (releases.items) |release| {

View file

@ -6,8 +6,8 @@ const Thread = std.Thread;
const GitHub = @import("providers/GitHub.zig"); const GitHub = @import("providers/GitHub.zig");
const GitLab = @import("providers/GitLab.zig"); const GitLab = @import("providers/GitLab.zig");
const Codeberg = @import("providers/Codeberg.zig");
const SourceHut = @import("providers/SourceHut.zig"); const SourceHut = @import("providers/SourceHut.zig");
const ForgejoRegistry = @import("ForgejoRegistry.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 zeit = @import("zeit");
@ -136,7 +136,7 @@ pub fn main() !u8 {
// Initialize providers with their tokens (need to persist for the lifetime of the program) // Initialize providers with their tokens (need to persist for the lifetime of the program)
var github_provider: ?GitHub = null; var github_provider: ?GitHub = null;
var gitlab_provider: ?GitLab = null; var gitlab_provider: ?GitLab = null;
var codeberg_provider: ?Codeberg = null; var forgejo_registry: ?ForgejoRegistry = null;
var sourcehut_provider: ?SourceHut = null; var sourcehut_provider: ?SourceHut = null;
if (app_config.github_token) |token| { if (app_config.github_token) |token| {
@ -147,15 +147,32 @@ pub fn main() !u8 {
gitlab_provider = GitLab.init(token); gitlab_provider = GitLab.init(token);
try providers.append(gitlab_provider.?.provider()); try providers.append(gitlab_provider.?.provider());
} }
if (app_config.codeberg_token) |token| {
codeberg_provider = Codeberg.init(token); // Handle Forgejo instances (including legacy codeberg_token)
try providers.append(codeberg_provider.?.provider()); forgejo_registry = ForgejoRegistry.init(allocator, &app_config) catch |err| {
const stderr = std.io.getStdErr().writer();
stderr.print("Error initializing Forgejo registry: {}\n", .{err}) catch {};
return err;
};
if (forgejo_registry) |*registry| {
const forgejo_providers = try registry.providers();
defer registry.deinitProviders(forgejo_providers);
for (forgejo_providers) |provider| {
try providers.append(provider);
}
} }
if (app_config.sourcehut) |sh_config| if (sh_config.repositories.len > 0 and sh_config.token != null) { if (app_config.sourcehut) |sh_config| if (sh_config.repositories.len > 0 and sh_config.token != null) {
sourcehut_provider = SourceHut.init(sh_config.token.?, sh_config.repositories); sourcehut_provider = SourceHut.init(sh_config.token.?, sh_config.repositories);
try providers.append(sourcehut_provider.?.provider()); try providers.append(sourcehut_provider.?.provider());
}; };
// Cleanup forgejo registry when done
defer if (forgejo_registry) |*registry| {
registry.deinit();
};
// 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); const provider_results = try fetchReleasesFromAllProviders(allocator, providers.items);
defer { defer {
@ -567,5 +584,6 @@ test {
std.testing.refAllDecls(@import("providers/GitHub.zig")); std.testing.refAllDecls(@import("providers/GitHub.zig"));
std.testing.refAllDecls(@import("providers/GitLab.zig")); std.testing.refAllDecls(@import("providers/GitLab.zig"));
std.testing.refAllDecls(@import("providers/SourceHut.zig")); std.testing.refAllDecls(@import("providers/SourceHut.zig"));
std.testing.refAllDecls(@import("providers/Codeberg.zig")); std.testing.refAllDecls(@import("providers/Forgejo.zig"));
std.testing.refAllDecls(@import("ForgejoRegistry.zig"));
} }

View file

@ -9,12 +9,18 @@ const tag_filter = @import("../tag_filter.zig");
const Release = @import("../main.zig").Release; const Release = @import("../main.zig").Release;
const Provider = @import("../Provider.zig"); const Provider = @import("../Provider.zig");
name: []const u8,
base_url: []const u8,
token: []const u8, token: []const u8,
const Self = @This(); const Self = @This();
pub fn init(token: []const u8) Self { pub fn init(name: []const u8, base_url: []const u8, token: []const u8) Self {
return Self{ .token = token }; return Self{
.name = name,
.base_url = base_url,
.token = token,
};
} }
pub fn provider(self: *Self) Provider { pub fn provider(self: *Self) Provider {
@ -26,9 +32,10 @@ pub fn fetchReleases(self: *Self, allocator: Allocator) !ArrayList(Release) {
defer client.deinit(); defer client.deinit();
var releases = ArrayList(Release).init(allocator); var releases = ArrayList(Release).init(allocator);
const stderr = std.io.getStdErr().writer();
// Get starred repositories (Codeberg uses Gitea API) // Get starred repositories (uses Forgejo/Gitea API)
const starred_repos = try getStarredRepos(allocator, &client, self.token); const starred_repos = try getStarredRepos(allocator, &client, self.base_url, self.token);
defer { defer {
for (starred_repos.items) |repo| { for (starred_repos.items) |repo| {
allocator.free(repo); allocator.free(repo);
@ -39,9 +46,8 @@ pub fn fetchReleases(self: *Self, allocator: Allocator) !ArrayList(Release) {
// Get releases for each repo // Get releases for each repo
for (starred_repos.items) |repo| { for (starred_repos.items) |repo| {
// TODO: Investigate the tags/releases situation similar to GitHub // TODO: Investigate the tags/releases situation similar to GitHub
const repo_releases = getRepoReleases(allocator, &client, self.token, repo) catch |err| { const repo_releases = getRepoReleases(allocator, &client, self.base_url, self.token, self.name, repo) catch |err| {
const stderr = std.io.getStdErr().writer(); stderr.print("Error fetching {s} releases for {s}: {}\n", .{ self.name, repo, err }) catch {};
stderr.print("Error fetching Codeberg releases for {s}: {}\n", .{ repo, err }) catch {};
continue; continue;
}; };
defer repo_releases.deinit(); defer repo_releases.deinit();
@ -56,12 +62,12 @@ pub fn fetchReleases(self: *Self, allocator: Allocator) !ArrayList(Release) {
} }
pub fn getName(self: *Self) []const u8 { pub fn getName(self: *Self) []const u8 {
_ = self; return self.name;
return "codeberg";
} }
fn getStarredRepos(allocator: Allocator, client: *http.Client, token: []const u8) !ArrayList([]const u8) { fn getStarredRepos(allocator: Allocator, client: *http.Client, base_url: []const u8, token: []const u8) !ArrayList([]const u8) {
var repos = ArrayList([]const u8).init(allocator); var repos = ArrayList([]const u8).init(allocator);
const stderr = std.io.getStdErr().writer();
errdefer { errdefer {
// Clean up any allocated repo names if we fail // Clean up any allocated repo names if we fail
for (repos.items) |repo| { for (repos.items) |repo| {
@ -77,8 +83,14 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, token: []const u8
var page: u32 = 1; var page: u32 = 1;
const per_page: u32 = 100; const per_page: u32 = 100;
// Normalize base_url by removing trailing slash if present
const normalized_base_url = if (std.mem.endsWith(u8, base_url, "/"))
base_url[0 .. base_url.len - 1]
else
base_url;
while (true) { while (true) {
const url = try std.fmt.allocPrint(allocator, "https://codeberg.org/api/v1/user/starred?limit={d}&page={d}", .{ per_page, page }); const url = try std.fmt.allocPrint(allocator, "{s}/api/v1/user/starred?limit={d}&page={d}", .{ normalized_base_url, per_page, page });
defer allocator.free(url); defer allocator.free(url);
const uri = try std.Uri.parse(url); const uri = try std.Uri.parse(url);
@ -98,16 +110,13 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, token: []const u8
if (req.response.status != .ok) { if (req.response.status != .ok) {
if (req.response.status == .unauthorized) { if (req.response.status == .unauthorized) {
const stderr = std.io.getStdErr().writer(); stderr.print("Forgejo API: Unauthorized - check your token and scopes\n", .{}) catch {};
stderr.print("Codeberg API: Unauthorized - check your token and scopes\n", .{}) catch {};
return error.Unauthorized; return error.Unauthorized;
} else if (req.response.status == .forbidden) { } else if (req.response.status == .forbidden) {
const stderr = std.io.getStdErr().writer(); stderr.print("Forgejo API: Forbidden - token may lack required scopes (read:repository)\n", .{}) catch {};
stderr.print("Codeberg API: Forbidden - token may lack required scopes (read:repository)\n", .{}) catch {};
return error.Forbidden; return error.Forbidden;
} }
const stderr = std.io.getStdErr().writer(); stderr.print("Forgejo API request failed with status: {}\n", .{req.response.status}) catch {};
stderr.print("Codeberg API request failed with status: {}\n", .{req.response.status}) catch {};
return error.HttpRequestFailed; return error.HttpRequestFailed;
} }
@ -115,8 +124,7 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, token: []const u8
defer allocator.free(body); defer allocator.free(body);
const parsed = json.parseFromSlice(json.Value, allocator, body, .{}) catch |err| { const parsed = json.parseFromSlice(json.Value, allocator, body, .{}) catch |err| {
const stderr = std.io.getStdErr().writer(); stderr.print("Error parsing Forgejo starred repos JSON (page {d}): {}\n", .{ page, err }) catch {};
stderr.print("Error parsing Codeberg starred repos JSON (page {d}): {}\n", .{ page, err }) catch {};
return error.JsonParseError; return error.JsonParseError;
}; };
defer parsed.deinit(); defer parsed.deinit();
@ -152,8 +160,9 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, token: []const u8
return repos; return repos;
} }
fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8, repo: []const u8) !ArrayList(Release) { fn getRepoReleases(allocator: Allocator, client: *http.Client, base_url: []const u8, token: []const u8, provider_name: []const u8, repo: []const u8) !ArrayList(Release) {
var releases = ArrayList(Release).init(allocator); var releases = ArrayList(Release).init(allocator);
const stderr = std.io.getStdErr().writer();
errdefer { errdefer {
// Clean up any allocated releases if we fail // Clean up any allocated releases if we fail
for (releases.items) |release| { for (releases.items) |release| {
@ -162,100 +171,120 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8
releases.deinit(); releases.deinit();
} }
const url = try std.fmt.allocPrint(allocator, "https://codeberg.org/api/v1/repos/{s}/releases", .{repo}); // Normalize base_url by removing trailing slash if present
defer allocator.free(url); const normalized_base_url = if (std.mem.endsWith(u8, base_url, "/"))
base_url[0 .. base_url.len - 1]
const uri = try std.Uri.parse(url); else
base_url;
const auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token}); const auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token});
defer allocator.free(auth_header); defer allocator.free(auth_header);
var server_header_buffer: [16 * 1024]u8 = undefined; // Paginate through all releases
var req = try client.open(.GET, uri, .{ var page: u32 = 1;
.server_header_buffer = &server_header_buffer, const per_page: u32 = 100;
.extra_headers = &.{
.{ .name = "Authorization", .value = auth_header },
.{ .name = "User-Agent", .value = "release-tracker/1.0" },
},
});
defer req.deinit();
try req.send(); while (true) {
try req.wait(); const url = try std.fmt.allocPrint(allocator, "{s}/api/v1/repos/{s}/releases?limit={d}&page={d}", .{ normalized_base_url, repo, per_page, page });
defer allocator.free(url);
if (req.response.status != .ok) { const uri = try std.Uri.parse(url);
if (req.response.status == .unauthorized) {
const stderr = std.io.getStdErr().writer();
stderr.print("Codeberg API: Unauthorized for repo {s} - check your token and scopes\n", .{repo}) catch {};
return error.Unauthorized;
} else if (req.response.status == .forbidden) {
const stderr = std.io.getStdErr().writer();
stderr.print("Codeberg API: Forbidden for repo {s} - token may lack required scopes\n", .{repo}) catch {};
return error.Forbidden;
} else if (req.response.status == .not_found) {
const stderr = std.io.getStdErr().writer();
stderr.print("Codeberg API: Repository {s} not found or no releases\n", .{repo}) catch {};
return error.NotFound;
}
const stderr = std.io.getStdErr().writer();
stderr.print("Codeberg API request failed for repo {s} with status: {}\n", .{ repo, req.response.status }) catch {};
return error.HttpRequestFailed;
}
const body = try req.reader().readAllAlloc(allocator, 10 * 1024 * 1024); var server_header_buffer: [16 * 1024]u8 = undefined;
defer allocator.free(body); var req = try client.open(.GET, uri, .{
.server_header_buffer = &server_header_buffer,
.extra_headers = &.{
.{ .name = "Authorization", .value = auth_header },
.{ .name = "User-Agent", .value = "release-tracker/1.0" },
},
});
defer req.deinit();
const parsed = json.parseFromSlice(json.Value, allocator, body, .{}) catch |err| { try req.send();
const stderr = std.io.getStdErr().writer(); try req.wait();
stderr.print("Error parsing Codeberg releases JSON for {s}: {}\n", .{ repo, err }) catch {};
return error.JsonParseError;
};
defer parsed.deinit();
if (parsed.value != .array) { if (req.response.status != .ok) {
return error.UnexpectedJsonFormat; if (req.response.status == .unauthorized) {
} stderr.print("Forgejo API: Unauthorized for repo {s} - check your token and scopes\n", .{repo}) catch {};
return error.Unauthorized;
const array = parsed.value.array; } else if (req.response.status == .forbidden) {
for (array.items) |item| { stderr.print("Forgejo API: Forbidden for repo {s} - token may lack required scopes\n", .{repo}) catch {};
if (item != .object) continue; return error.Forbidden;
const obj = item.object; } else if (req.response.status == .not_found) {
stderr.print("Forgejo API: Repository {s} not found or no releases\n", .{repo}) catch {};
// Safely extract required fields return error.NotFound;
const tag_name_value = obj.get("tag_name") orelse continue; }
if (tag_name_value != .string) continue; stderr.print("Forgejo API request failed for repo {s} with status: {}\n", .{ repo, req.response.status }) catch {};
return error.HttpRequestFailed;
const tag_name = tag_name_value.string;
// Skip problematic tags
if (tag_filter.shouldSkipTag(allocator, tag_name)) {
continue;
} }
const published_at_value = obj.get("published_at") orelse continue; const body = try req.reader().readAllAlloc(allocator, 10 * 1024 * 1024);
if (published_at_value != .string) continue; defer allocator.free(body);
const html_url_value = obj.get("html_url") orelse continue; const parsed = json.parseFromSlice(json.Value, allocator, body, .{}) catch |err| {
if (html_url_value != .string) continue; stderr.print("Error parsing Forgejo releases JSON for {s}: {}\n", .{ repo, err }) catch {};
return error.JsonParseError;
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, repo),
.tag_name = try allocator.dupe(u8, tag_name),
.published_at = try utils.parseReleaseTimestamp(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"),
.is_tag = false,
}; };
defer parsed.deinit();
releases.append(release) catch |err| { if (parsed.value != .array) {
// If append fails, clean up the release we just created return error.UnexpectedJsonFormat;
release.deinit(allocator); }
return err;
}; const array = parsed.value.array;
// If we got no results, we've reached the end
if (array.items.len == 0) {
break;
}
for (array.items) |item| {
if (item != .object) continue;
const obj = item.object;
// Safely extract required fields
const tag_name_value = obj.get("tag_name") orelse continue;
if (tag_name_value != .string) continue;
const tag_name = tag_name_value.string;
// Skip problematic tags
if (tag_filter.shouldSkipTag(allocator, tag_name)) {
continue;
}
const published_at_value = obj.get("published_at") orelse continue;
if (published_at_value != .string) continue;
const html_url_value = obj.get("html_url") orelse continue;
if (html_url_value != .string) continue;
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, repo),
.tag_name = try allocator.dupe(u8, tag_name),
.published_at = try utils.parseReleaseTimestamp(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, provider_name),
.is_tag = false,
};
releases.append(release) catch |err| {
// If append fails, clean up the release we just created
release.deinit(allocator);
return err;
};
}
// If we got fewer results than requested, we've reached the end
if (array.items.len < per_page) {
break;
}
page += 1;
} }
// Sort releases by date (most recent first) // Sort releases by date (most recent first)
@ -264,15 +293,15 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8
return releases; return releases;
} }
test "codeberg provider name" { test "forgejo provider name" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
_ = allocator; _ = allocator;
var codeberg_provider = init("dummy_token"); var forgejo_provider = init("codeberg", "https://codeberg.org", "dummy_token");
try std.testing.expectEqualStrings("codeberg", codeberg_provider.getName()); try std.testing.expectEqualStrings("codeberg", forgejo_provider.getName());
} }
test "codeberg release parsing with live data snapshot" { test "forgejo release parsing with live data snapshot" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
// Sample Codeberg API response for releases (captured from real API) // Sample Codeberg API response for releases (captured from real API)
@ -326,7 +355,7 @@ test "codeberg release parsing with live data snapshot" {
.published_at = try utils.parseReleaseTimestamp(published_at_value.string), .published_at = try utils.parseReleaseTimestamp(published_at_value.string),
.html_url = try allocator.dupe(u8, html_url_value.string), .html_url = try allocator.dupe(u8, html_url_value.string),
.description = try allocator.dupe(u8, body_str), .description = try allocator.dupe(u8, body_str),
.provider = try allocator.dupe(u8, "codeberg"), .provider = try allocator.dupe(u8, "test-forgejo"),
.is_tag = false, .is_tag = false,
}; };
@ -348,13 +377,13 @@ test "codeberg release parsing with live data snapshot" {
))), ))),
releases.items[0].published_at, releases.items[0].published_at,
); );
try std.testing.expectEqualStrings("codeberg", releases.items[0].provider); try std.testing.expectEqualStrings("test-forgejo", releases.items[0].provider);
} }
test "Codeberg tag filtering" { test "Forgejo tag filtering" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
// Test that Codeberg now uses the same filtering as other providers // Test that Forgejo now uses the same filtering as other providers
const problematic_tags = [_][]const u8{ const problematic_tags = [_][]const u8{
"nightly", "prerelease", "latest", "edge", "canary", "dev-branch", "nightly", "prerelease", "latest", "edge", "canary", "dev-branch",
}; };

View file

@ -73,9 +73,6 @@ fn getStarredProjects(allocator: Allocator, client: *http.Client, token: []const
const username = try getCurrentUsername(allocator, client, token); const username = try getCurrentUsername(allocator, client, token);
defer allocator.free(username); defer allocator.free(username);
const auth_header = try std.fmt.allocPrint(allocator, "Private-Token {s}", .{token});
defer allocator.free(auth_header);
// Paginate through all starred projects // Paginate through all starred projects
var page: u32 = 1; var page: u32 = 1;
const per_page: u32 = 100; // Use 100 per page for efficiency const per_page: u32 = 100; // Use 100 per page for efficiency
@ -90,7 +87,7 @@ fn getStarredProjects(allocator: Allocator, client: *http.Client, token: []const
var req = try client.open(.GET, uri, .{ var req = try client.open(.GET, uri, .{
.server_header_buffer = &server_header_buffer, .server_header_buffer = &server_header_buffer,
.extra_headers = &.{ .extra_headers = &.{
.{ .name = "Authorization", .value = auth_header }, .{ .name = "Private-Token", .value = token },
.{ .name = "User-Agent", .value = "release-tracker/1.0" }, .{ .name = "User-Agent", .value = "release-tracker/1.0" },
}, },
}); });
@ -107,7 +104,8 @@ fn getStarredProjects(allocator: Allocator, client: *http.Client, token: []const
return error.Unauthorized; return error.Unauthorized;
}, },
.forbidden => { .forbidden => {
stderr.print("GitLab API: Access forbidden - token may lack required permissions (HTTP 403)\n", .{}) catch {}; stderr.print("GitLab API: Access forbidden - token lacks required scopes (HTTP 403)\n", .{}) catch {};
stderr.print("Required scopes: read_user and read_api\n", .{}) catch {};
return error.Forbidden; return error.Forbidden;
}, },
else => { else => {
@ -156,14 +154,11 @@ fn getCurrentUsername(allocator: Allocator, client: *http.Client, token: []const
// Try to get user info first // Try to get user info first
const uri = try std.Uri.parse("https://gitlab.com/api/v4/user"); const uri = try std.Uri.parse("https://gitlab.com/api/v4/user");
const auth_header = try std.fmt.allocPrint(allocator, "Private-Token {s}", .{token});
defer allocator.free(auth_header);
var server_header_buffer: [16 * 1024]u8 = undefined; var server_header_buffer: [16 * 1024]u8 = undefined;
var req = try client.open(.GET, uri, .{ var req = try client.open(.GET, uri, .{
.server_header_buffer = &server_header_buffer, .server_header_buffer = &server_header_buffer,
.extra_headers = &.{ .extra_headers = &.{
.{ .name = "Authorization", .value = auth_header }, .{ .name = "Private-Token", .value = token },
.{ .name = "User-Agent", .value = "release-tracker/1.0" }, .{ .name = "User-Agent", .value = "release-tracker/1.0" },
}, },
}); });
@ -180,7 +175,8 @@ fn getCurrentUsername(allocator: Allocator, client: *http.Client, token: []const
return error.Unauthorized; return error.Unauthorized;
}, },
.forbidden => { .forbidden => {
stderr.print("GitLab API: Access forbidden - token may lack required permissions (HTTP 403)\n", .{}) catch {}; stderr.print("GitLab API: Access forbidden - token lacks required scopes (HTTP 403)\n", .{}) catch {};
stderr.print("Required scopes: read_user and read_api\n", .{}) catch {};
return error.Forbidden; return error.Forbidden;
}, },
else => { else => {
@ -214,14 +210,11 @@ fn getProjectReleases(allocator: Allocator, client: *http.Client, token: []const
const uri = try std.Uri.parse(url); const uri = try std.Uri.parse(url);
const auth_header = try std.fmt.allocPrint(allocator, "Private-Token {s}", .{token});
defer allocator.free(auth_header);
var server_header_buffer: [16 * 1024]u8 = undefined; var server_header_buffer: [16 * 1024]u8 = undefined;
var req = try client.open(.GET, uri, .{ var req = try client.open(.GET, uri, .{
.server_header_buffer = &server_header_buffer, .server_header_buffer = &server_header_buffer,
.extra_headers = &.{ .extra_headers = &.{
.{ .name = "Authorization", .value = auth_header }, .{ .name = "Private-Token", .value = token },
.{ .name = "User-Agent", .value = "release-tracker/1.0" }, .{ .name = "User-Agent", .value = "release-tracker/1.0" },
}, },
}); });
@ -292,23 +285,13 @@ fn getProjectReleases(allocator: Allocator, client: *http.Client, token: []const
} }
test "gitlab provider" { test "gitlab provider" {
const allocator = std.testing.allocator; var gitlab_provider = init("test-token");
var gitlab_provider = init("");
// Test with empty token (should fail gracefully)
const releases = gitlab_provider.fetchReleases(allocator) catch |err| {
try std.testing.expect(err == error.Unauthorized or err == error.HttpRequestFailed);
return;
};
defer {
for (releases.items) |release| {
release.deinit(allocator);
}
releases.deinit();
}
// Test provider name
try std.testing.expectEqualStrings("gitlab", gitlab_provider.getName()); try std.testing.expectEqualStrings("gitlab", gitlab_provider.getName());
// Test provider initialization
try std.testing.expectEqualStrings("test-token", gitlab_provider.token);
} }
test "gitlab release parsing with live data snapshot" { test "gitlab release parsing with live data snapshot" {