diff --git a/README.md b/README.md index f7a4b53..716a20a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,13 @@ Create a `config.json` file with your API tokens: { "github_token": "your_github_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": { "repositories": [ "~sircmpwn/aerc", @@ -52,7 +58,7 @@ Create a `config.json` file with your API tokens: - **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 -- **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. Note on GitHub PATs. Some GitHub orgs will place additional restrictions on diff --git a/build.zig b/build.zig index 4c9b2f8..c3e918e 100644 --- a/build.zig +++ b/build.zig @@ -4,7 +4,7 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); 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; // Add Zeit dependency @@ -79,7 +79,7 @@ pub fn build(b: *std.Build) void { // Individual provider test steps const github_step = b.step("test-github", "Test GitHub 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 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_tests.root_module.addOptions("build_options", gitlab_test_debug_option); - const codeberg_tests = b.addTest(.{ - .name = "codeberg-tests", + const forgejo_tests = b.addTest(.{ + .name = "forgejo-tests", .root_source_file = b.path("src/integration_tests.zig"), .target = target, .optimize = optimize, - .filters = &[_][]const u8{"Codeberg provider"}, + .filters = &[_][]const u8{"Forgejo provider"}, }); - codeberg_tests.root_module.addImport("zeit", zeit_dep.module("zeit")); - const codeberg_test_debug_option = b.addOptions(); - codeberg_test_debug_option.addOption(bool, "test_debug", test_debug); - codeberg_tests.root_module.addOptions("build_options", codeberg_test_debug_option); + forgejo_tests.root_module.addImport("zeit", zeit_dep.module("zeit")); + const forgejo_test_debug_option = b.addOptions(); + forgejo_test_debug_option.addOption(bool, "test_debug", test_debug); + forgejo_tests.root_module.addOptions("build_options", forgejo_test_debug_option); const sourcehut_tests = b.addTest(.{ .name = "sourcehut-tests", @@ -132,6 +132,6 @@ pub fn build(b: *std.Build) void { github_step.dependOn(&b.addRunArtifact(github_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); } diff --git a/config.example.json b/config.example.json index 4a76676..c913d33 100644 --- a/config.example.json +++ b/config.example.json @@ -1,7 +1,13 @@ { "github_token": "ghp_your_github_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": { "token": "your_sourcehut_token_here", "repositories": [ diff --git a/src/config.zig b/src/config.zig index 314dec1..cab937d 100644 --- a/src/config.zig +++ b/src/config.zig @@ -2,6 +2,31 @@ const std = @import("std"); const json = std.json; 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 { token: ?[]const u8 = null, repositories: [][]const u8, @@ -19,7 +44,8 @@ pub const SourceHutConfig = struct { pub const Config = struct { github_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, allocator: Allocator, @@ -27,6 +53,7 @@ pub const Config = struct { if (self.github_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.forgejo) |*forgejo_config| forgejo_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{ .github_token = if (root.get("github_token")) |v| switch (v) { .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, else => null, } else null, + .forgejo = forgejo_config, .sourcehut = sourcehut_config, .allocator = allocator, }; diff --git a/src/integration_tests.zig b/src/integration_tests.zig index 6e83558..2f9ced2 100644 --- a/src/integration_tests.zig +++ b/src/integration_tests.zig @@ -6,7 +6,7 @@ const atom = @import("atom.zig"); const Release = @import("main.zig").Release; const GitHub = @import("providers/GitHub.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 config = @import("config.zig"); @@ -436,24 +436,24 @@ test "GitLab provider integration" { } } -test "Codeberg provider integration" { +test "Forgejo provider integration" { const allocator = testing.allocator; // Load config to get token 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; }; defer app_config.deinit(); if (app_config.codeberg_token == null) { - testPrint("Skipping Codeberg test - no token configured\n", .{}); + testPrint("Skipping Forgejo test - no token configured\n", .{}); 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| { - testPrint("Codeberg provider error: {}\n", .{err}); + testPrint("Forgejo provider error: {}\n", .{err}); return; // Skip test if provider fails }; defer { @@ -463,7 +463,7 @@ test "Codeberg provider integration" { releases.deinit(); } - testPrint("Codeberg: Found {} releases\n", .{releases.items.len}); + testPrint("Forgejo: Found {} releases\n", .{releases.items.len}); // Verify releases have required fields for (releases.items) |release| { diff --git a/src/main.zig b/src/main.zig index 1b57cad..09ed2c3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -6,8 +6,8 @@ const Thread = std.Thread; const GitHub = @import("providers/GitHub.zig"); const GitLab = @import("providers/GitLab.zig"); -const Codeberg = @import("providers/Codeberg.zig"); const SourceHut = @import("providers/SourceHut.zig"); +const ForgejoRegistry = @import("ForgejoRegistry.zig"); const atom = @import("atom.zig"); const config = @import("config.zig"); 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) var github_provider: ?GitHub = null; var gitlab_provider: ?GitLab = null; - var codeberg_provider: ?Codeberg = null; + var forgejo_registry: ?ForgejoRegistry = null; var sourcehut_provider: ?SourceHut = null; if (app_config.github_token) |token| { @@ -147,15 +147,32 @@ pub fn main() !u8 { gitlab_provider = GitLab.init(token); try providers.append(gitlab_provider.?.provider()); } - if (app_config.codeberg_token) |token| { - codeberg_provider = Codeberg.init(token); - try providers.append(codeberg_provider.?.provider()); + + // Handle Forgejo instances (including legacy codeberg_token) + 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) { sourcehut_provider = SourceHut.init(sh_config.token.?, sh_config.repositories); 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 const provider_results = try fetchReleasesFromAllProviders(allocator, providers.items); defer { @@ -567,5 +584,6 @@ test { std.testing.refAllDecls(@import("providers/GitHub.zig")); std.testing.refAllDecls(@import("providers/GitLab.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")); } diff --git a/src/providers/Codeberg.zig b/src/providers/Forgejo.zig similarity index 81% rename from src/providers/Codeberg.zig rename to src/providers/Forgejo.zig index 446282a..700dbe6 100644 --- a/src/providers/Codeberg.zig +++ b/src/providers/Forgejo.zig @@ -9,12 +9,18 @@ const tag_filter = @import("../tag_filter.zig"); const Release = @import("../main.zig").Release; const Provider = @import("../Provider.zig"); +name: []const u8, +base_url: []const u8, token: []const u8, const Self = @This(); -pub fn init(token: []const u8) Self { - return Self{ .token = token }; +pub fn init(name: []const u8, base_url: []const u8, token: []const u8) Self { + return Self{ + .name = name, + .base_url = base_url, + .token = token, + }; } pub fn provider(self: *Self) Provider { @@ -27,8 +33,8 @@ pub fn fetchReleases(self: *Self, allocator: Allocator) !ArrayList(Release) { var releases = ArrayList(Release).init(allocator); - // Get starred repositories (Codeberg uses Gitea API) - const starred_repos = try getStarredRepos(allocator, &client, self.token); + // Get starred repositories (uses Forgejo/Gitea API) + const starred_repos = try getStarredRepos(allocator, &client, self.base_url, self.token); defer { for (starred_repos.items) |repo| { allocator.free(repo); @@ -39,9 +45,9 @@ pub fn fetchReleases(self: *Self, allocator: Allocator) !ArrayList(Release) { // Get releases for each repo for (starred_repos.items) |repo| { // 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 Codeberg releases for {s}: {}\n", .{ repo, err }) catch {}; + stderr.print("Error fetching {s} releases for {s}: {}\n", .{ self.name, repo, err }) catch {}; continue; }; defer repo_releases.deinit(); @@ -56,11 +62,10 @@ pub fn fetchReleases(self: *Self, allocator: Allocator) !ArrayList(Release) { } pub fn getName(self: *Self) []const u8 { - _ = self; - return "codeberg"; + return self.name; } -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); errdefer { // Clean up any allocated repo names if we fail @@ -78,7 +83,7 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, token: []const u8 const per_page: u32 = 100; 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}", .{ base_url, per_page, page }); defer allocator.free(url); const uri = try std.Uri.parse(url); @@ -99,15 +104,15 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, token: []const u8 if (req.response.status != .ok) { if (req.response.status == .unauthorized) { const stderr = std.io.getStdErr().writer(); - stderr.print("Codeberg API: Unauthorized - check your token and scopes\n", .{}) catch {}; + stderr.print("Forgejo API: Unauthorized - check your token and scopes\n", .{}) catch {}; return error.Unauthorized; } else if (req.response.status == .forbidden) { const stderr = std.io.getStdErr().writer(); - stderr.print("Codeberg API: Forbidden - token may lack required scopes (read:repository)\n", .{}) catch {}; + stderr.print("Forgejo API: Forbidden - token may lack required scopes (read:repository)\n", .{}) catch {}; return error.Forbidden; } const stderr = std.io.getStdErr().writer(); - stderr.print("Codeberg API request failed with status: {}\n", .{req.response.status}) catch {}; + stderr.print("Forgejo API request failed with status: {}\n", .{req.response.status}) catch {}; return error.HttpRequestFailed; } @@ -116,7 +121,7 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, token: []const u8 const parsed = json.parseFromSlice(json.Value, allocator, body, .{}) catch |err| { const stderr = std.io.getStdErr().writer(); - stderr.print("Error parsing Codeberg starred repos JSON (page {d}): {}\n", .{ page, err }) catch {}; + stderr.print("Error parsing Forgejo starred repos JSON (page {d}): {}\n", .{ page, err }) catch {}; return error.JsonParseError; }; defer parsed.deinit(); @@ -152,7 +157,7 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, token: []const u8 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); errdefer { // Clean up any allocated releases if we fail @@ -162,7 +167,7 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8 releases.deinit(); } - const url = try std.fmt.allocPrint(allocator, "https://codeberg.org/api/v1/repos/{s}/releases", .{repo}); + const url = try std.fmt.allocPrint(allocator, "{s}/api/v1/repos/{s}/releases", .{ base_url, repo }); defer allocator.free(url); const uri = try std.Uri.parse(url); @@ -186,19 +191,19 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8 if (req.response.status != .ok) { 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 {}; + stderr.print("Forgejo 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 {}; + stderr.print("Forgejo 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 {}; + stderr.print("Forgejo 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 {}; + stderr.print("Forgejo API request failed for repo {s} with status: {}\n", .{ repo, req.response.status }) catch {}; return error.HttpRequestFailed; } @@ -207,7 +212,7 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8 const parsed = json.parseFromSlice(json.Value, allocator, body, .{}) catch |err| { const stderr = std.io.getStdErr().writer(); - stderr.print("Error parsing Codeberg releases JSON for {s}: {}\n", .{ repo, err }) catch {}; + stderr.print("Error parsing Forgejo releases JSON for {s}: {}\n", .{ repo, err }) catch {}; return error.JsonParseError; }; defer parsed.deinit(); @@ -247,7 +252,7 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8 .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"), + .provider = try allocator.dupe(u8, provider_name), .is_tag = false, }; @@ -264,15 +269,15 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8 return releases; } -test "codeberg provider name" { +test "forgejo provider name" { const allocator = std.testing.allocator; _ = allocator; - var codeberg_provider = init("dummy_token"); - try std.testing.expectEqualStrings("codeberg", codeberg_provider.getName()); + var forgejo_provider = init("codeberg", "https://codeberg.org", "dummy_token"); + 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; // Sample Codeberg API response for releases (captured from real API) @@ -326,7 +331,7 @@ test "codeberg release parsing with live data snapshot" { .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"), + .provider = try allocator.dupe(u8, "test-forgejo"), .is_tag = false, }; @@ -348,13 +353,13 @@ test "codeberg release parsing with live data snapshot" { ))), 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; - // 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{ "nightly", "prerelease", "latest", "edge", "canary", "dev-branch", };