Compare commits

..

No commits in common. "102522f373b86d131db96e921569f642667ccc49" and "e2199c26364384abaa92759b9c65febe44cc4ddb" have entirely different histories.

8 changed files with 165 additions and 279 deletions

View file

@ -37,13 +37,7 @@ 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",
"forgejo": [ "codeberg_token": "your_codeberg_token",
{
"name": "codeberg",
"base_url": "https://codeberg.org",
"token": "your_codeberg_access_token_here"
}
],
"sourcehut": { "sourcehut": {
"repositories": [ "repositories": [
"~sircmpwn/aerc", "~sircmpwn/aerc",
@ -57,8 +51,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_user` and `read_api` scopes - **GitLab**: Create a Personal Access Token with `read_api` scope
- **Codeberg**: Create an Access Token in your account settings. read:repository and read:user - **Codeberg**: Create an Access Token in your account settings
- **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, forgejo, sourcehut)"); const provider = b.option([]const u8, "provider", "Test specific provider (github, gitlab, codeberg, 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 forgejo_step = b.step("test-forgejo", "Test Forgejo provider only"); const codeberg_step = b.step("test-codeberg", "Test Codeberg 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 forgejo_tests = b.addTest(.{ const codeberg_tests = b.addTest(.{
.name = "forgejo-tests", .name = "codeberg-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{"Forgejo provider"}, .filters = &[_][]const u8{"Codeberg provider"},
}); });
forgejo_tests.root_module.addImport("zeit", zeit_dep.module("zeit")); codeberg_tests.root_module.addImport("zeit", zeit_dep.module("zeit"));
const forgejo_test_debug_option = b.addOptions(); const codeberg_test_debug_option = b.addOptions();
forgejo_test_debug_option.addOption(bool, "test_debug", test_debug); codeberg_test_debug_option.addOption(bool, "test_debug", test_debug);
forgejo_tests.root_module.addOptions("build_options", forgejo_test_debug_option); codeberg_tests.root_module.addOptions("build_options", codeberg_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);
forgejo_step.dependOn(&b.addRunArtifact(forgejo_tests).step); codeberg_step.dependOn(&b.addRunArtifact(codeberg_tests).step);
sourcehut_step.dependOn(&b.addRunArtifact(sourcehut_tests).step); sourcehut_step.dependOn(&b.addRunArtifact(sourcehut_tests).step);
} }

View file

@ -1,13 +1,7 @@
{ {
"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",
"forgejo": [ "codeberg_token": "your_codeberg_access_token_here",
{
"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,31 +2,6 @@ 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,
@ -44,8 +19,7 @@ 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, // Legacy support codeberg_token: ?[]const u8 = null,
forgejo: ?ForgejoConfig = null,
sourcehut: ?SourceHutConfig = null, sourcehut: ?SourceHutConfig = null,
allocator: Allocator, allocator: Allocator,
@ -53,7 +27,6 @@ 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();
} }
}; };
@ -99,28 +72,6 @@ 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,
@ -137,7 +88,6 @@ 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 Forgejo = @import("providers/Forgejo.zig"); const Codeberg = @import("providers/Codeberg.zig");
const SourceHut = @import("providers/SourceHut.zig"); const SourceHut = @import("providers/SourceHut.zig");
const config = @import("config.zig"); const config = @import("config.zig");
@ -436,46 +436,24 @@ test "GitLab provider integration" {
} }
} }
test "GitLab provider with empty token" { test "Codeberg provider integration" {
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 Forgejo test - config not available: {}\n", .{err}); testPrint("Skipping Codeberg 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 Forgejo test - no token configured\n", .{}); testPrint("Skipping Codeberg test - no token configured\n", .{});
return; return;
} }
var provider = Forgejo.init("codeberg", "https://codeberg.org", app_config.codeberg_token.?); var provider = Codeberg.init(app_config.codeberg_token.?);
const releases = provider.fetchReleases(allocator) catch |err| { const releases = provider.fetchReleases(allocator) catch |err| {
testPrint("Forgejo provider error: {}\n", .{err}); testPrint("Codeberg provider error: {}\n", .{err});
return; // Skip test if provider fails return; // Skip test if provider fails
}; };
defer { defer {
@ -485,7 +463,7 @@ test "Forgejo provider integration" {
releases.deinit(); releases.deinit();
} }
testPrint("Forgejo: Found {} releases\n", .{releases.items.len}); testPrint("Codeberg: 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 forgejo_registry: ?ForgejoRegistry = null; var codeberg_provider: ?Codeberg = null;
var sourcehut_provider: ?SourceHut = null; var sourcehut_provider: ?SourceHut = null;
if (app_config.github_token) |token| { if (app_config.github_token) |token| {
@ -147,32 +147,15 @@ 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| {
// Handle Forgejo instances (including legacy codeberg_token) codeberg_provider = Codeberg.init(token);
forgejo_registry = ForgejoRegistry.init(allocator, &app_config) catch |err| { try providers.append(codeberg_provider.?.provider());
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 {
@ -584,6 +567,5 @@ 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/Forgejo.zig")); std.testing.refAllDecls(@import("providers/Codeberg.zig"));
std.testing.refAllDecls(@import("ForgejoRegistry.zig"));
} }

View file

@ -9,18 +9,12 @@ 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(name: []const u8, base_url: []const u8, token: []const u8) Self { pub fn init(token: []const u8) Self {
return Self{ return Self{ .token = token };
.name = name,
.base_url = base_url,
.token = token,
};
} }
pub fn provider(self: *Self) Provider { pub fn provider(self: *Self) Provider {
@ -32,10 +26,9 @@ 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 (uses Forgejo/Gitea API) // Get starred repositories (Codeberg uses Gitea API)
const starred_repos = try getStarredRepos(allocator, &client, self.base_url, self.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);
@ -46,8 +39,9 @@ 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.base_url, self.token, self.name, repo) catch |err| { const repo_releases = getRepoReleases(allocator, &client, self.token, repo) catch |err| {
stderr.print("Error fetching {s} releases for {s}: {}\n", .{ self.name, repo, err }) catch {}; const stderr = std.io.getStdErr().writer();
stderr.print("Error fetching Codeberg releases for {s}: {}\n", .{ repo, err }) catch {};
continue; continue;
}; };
defer repo_releases.deinit(); defer repo_releases.deinit();
@ -62,12 +56,12 @@ pub fn fetchReleases(self: *Self, allocator: Allocator) !ArrayList(Release) {
} }
pub fn getName(self: *Self) []const u8 { pub fn getName(self: *Self) []const u8 {
return self.name; _ = self;
return "codeberg";
} }
fn getStarredRepos(allocator: Allocator, client: *http.Client, base_url: []const u8, token: []const u8) !ArrayList([]const u8) { fn getStarredRepos(allocator: Allocator, client: *http.Client, 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| {
@ -83,14 +77,8 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, base_url: []const
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, "{s}/api/v1/user/starred?limit={d}&page={d}", .{ normalized_base_url, per_page, page }); const url = try std.fmt.allocPrint(allocator, "https://codeberg.org/api/v1/user/starred?limit={d}&page={d}", .{ 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);
@ -110,13 +98,16 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, base_url: []const
if (req.response.status != .ok) { if (req.response.status != .ok) {
if (req.response.status == .unauthorized) { if (req.response.status == .unauthorized) {
stderr.print("Forgejo API: Unauthorized - check your token and scopes\n", .{}) catch {}; const stderr = std.io.getStdErr().writer();
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) {
stderr.print("Forgejo API: Forbidden - token may lack required scopes (read:repository)\n", .{}) catch {}; const stderr = std.io.getStdErr().writer();
stderr.print("Codeberg API: Forbidden - token may lack required scopes (read:repository)\n", .{}) catch {};
return error.Forbidden; return error.Forbidden;
} }
stderr.print("Forgejo API request failed with status: {}\n", .{req.response.status}) catch {}; const stderr = std.io.getStdErr().writer();
stderr.print("Codeberg API request failed with status: {}\n", .{req.response.status}) catch {};
return error.HttpRequestFailed; return error.HttpRequestFailed;
} }
@ -124,7 +115,8 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, base_url: []const
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| {
stderr.print("Error parsing Forgejo starred repos JSON (page {d}): {}\n", .{ page, err }) catch {}; const stderr = std.io.getStdErr().writer();
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();
@ -160,9 +152,8 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, base_url: []const
return repos; return repos;
} }
fn getRepoReleases(allocator: Allocator, client: *http.Client, base_url: []const u8, token: []const u8, provider_name: []const u8, repo: []const u8) !ArrayList(Release) { fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []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| {
@ -171,25 +162,14 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, base_url: []const
releases.deinit(); releases.deinit();
} }
// Normalize base_url by removing trailing slash if present const url = try std.fmt.allocPrint(allocator, "https://codeberg.org/api/v1/repos/{s}/releases", .{repo});
const normalized_base_url = if (std.mem.endsWith(u8, base_url, "/"))
base_url[0 .. base_url.len - 1]
else
base_url;
const auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token});
defer allocator.free(auth_header);
// Paginate through all releases
var page: u32 = 1;
const per_page: u32 = 100;
while (true) {
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); defer allocator.free(url);
const uri = try std.Uri.parse(url); const uri = try std.Uri.parse(url);
const auth_header = try std.fmt.allocPrint(allocator, "Bearer {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,
@ -205,16 +185,20 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, base_url: []const
if (req.response.status != .ok) { if (req.response.status != .ok) {
if (req.response.status == .unauthorized) { if (req.response.status == .unauthorized) {
stderr.print("Forgejo API: Unauthorized for repo {s} - check your token and scopes\n", .{repo}) catch {}; 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; return error.Unauthorized;
} else if (req.response.status == .forbidden) { } else if (req.response.status == .forbidden) {
stderr.print("Forgejo API: Forbidden for repo {s} - token may lack required scopes\n", .{repo}) catch {}; 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; return error.Forbidden;
} else if (req.response.status == .not_found) { } else if (req.response.status == .not_found) {
stderr.print("Forgejo API: Repository {s} not found or no releases\n", .{repo}) catch {}; const stderr = std.io.getStdErr().writer();
stderr.print("Codeberg API: Repository {s} not found or no releases\n", .{repo}) catch {};
return error.NotFound; return error.NotFound;
} }
stderr.print("Forgejo API request failed for repo {s} with status: {}\n", .{ repo, req.response.status }) catch {}; 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; return error.HttpRequestFailed;
} }
@ -222,7 +206,8 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, base_url: []const
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| {
stderr.print("Error parsing Forgejo releases JSON for {s}: {}\n", .{ repo, err }) catch {}; const stderr = std.io.getStdErr().writer();
stderr.print("Error parsing Codeberg releases JSON for {s}: {}\n", .{ repo, err }) catch {};
return error.JsonParseError; return error.JsonParseError;
}; };
defer parsed.deinit(); defer parsed.deinit();
@ -232,12 +217,6 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, base_url: []const
} }
const array = parsed.value.array; 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| { for (array.items) |item| {
if (item != .object) continue; if (item != .object) continue;
const obj = item.object; const obj = item.object;
@ -268,7 +247,7 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, base_url: []const
.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, provider_name), .provider = try allocator.dupe(u8, "codeberg"),
.is_tag = false, .is_tag = false,
}; };
@ -279,29 +258,21 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, base_url: []const
}; };
} }
// 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)
std.mem.sort(Release, releases.items, {}, utils.compareReleasesByDate); std.mem.sort(Release, releases.items, {}, utils.compareReleasesByDate);
return releases; return releases;
} }
test "forgejo provider name" { test "codeberg provider name" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
_ = allocator; _ = allocator;
var forgejo_provider = init("codeberg", "https://codeberg.org", "dummy_token"); var codeberg_provider = init("dummy_token");
try std.testing.expectEqualStrings("codeberg", forgejo_provider.getName()); try std.testing.expectEqualStrings("codeberg", codeberg_provider.getName());
} }
test "forgejo release parsing with live data snapshot" { test "codeberg 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)
@ -355,7 +326,7 @@ test "forgejo 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, "test-forgejo"), .provider = try allocator.dupe(u8, "codeberg"),
.is_tag = false, .is_tag = false,
}; };
@ -377,13 +348,13 @@ test "forgejo release parsing with live data snapshot" {
))), ))),
releases.items[0].published_at, releases.items[0].published_at,
); );
try std.testing.expectEqualStrings("test-forgejo", releases.items[0].provider); try std.testing.expectEqualStrings("codeberg", releases.items[0].provider);
} }
test "Forgejo tag filtering" { test "Codeberg tag filtering" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
// Test that Forgejo now uses the same filtering as other providers // Test that Codeberg 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,6 +73,9 @@ 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
@ -87,7 +90,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 = "Private-Token", .value = token }, .{ .name = "Authorization", .value = auth_header },
.{ .name = "User-Agent", .value = "release-tracker/1.0" }, .{ .name = "User-Agent", .value = "release-tracker/1.0" },
}, },
}); });
@ -104,8 +107,7 @@ fn getStarredProjects(allocator: Allocator, client: *http.Client, token: []const
return error.Unauthorized; return error.Unauthorized;
}, },
.forbidden => { .forbidden => {
stderr.print("GitLab API: Access forbidden - token lacks required scopes (HTTP 403)\n", .{}) catch {}; stderr.print("GitLab API: Access forbidden - token may lack required permissions (HTTP 403)\n", .{}) catch {};
stderr.print("Required scopes: read_user and read_api\n", .{}) catch {};
return error.Forbidden; return error.Forbidden;
}, },
else => { else => {
@ -154,11 +156,14 @@ 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 = "Private-Token", .value = token }, .{ .name = "Authorization", .value = auth_header },
.{ .name = "User-Agent", .value = "release-tracker/1.0" }, .{ .name = "User-Agent", .value = "release-tracker/1.0" },
}, },
}); });
@ -175,8 +180,7 @@ fn getCurrentUsername(allocator: Allocator, client: *http.Client, token: []const
return error.Unauthorized; return error.Unauthorized;
}, },
.forbidden => { .forbidden => {
stderr.print("GitLab API: Access forbidden - token lacks required scopes (HTTP 403)\n", .{}) catch {}; stderr.print("GitLab API: Access forbidden - token may lack required permissions (HTTP 403)\n", .{}) catch {};
stderr.print("Required scopes: read_user and read_api\n", .{}) catch {};
return error.Forbidden; return error.Forbidden;
}, },
else => { else => {
@ -210,11 +214,14 @@ 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 = "Private-Token", .value = token }, .{ .name = "Authorization", .value = auth_header },
.{ .name = "User-Agent", .value = "release-tracker/1.0" }, .{ .name = "User-Agent", .value = "release-tracker/1.0" },
}, },
}); });
@ -285,13 +292,23 @@ fn getProjectReleases(allocator: Allocator, client: *http.Client, token: []const
} }
test "gitlab provider" { test "gitlab provider" {
var gitlab_provider = init("test-token"); const allocator = std.testing.allocator;
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" {