forgejo refactor

This commit is contained in:
Emil Lerch 2025-07-20 15:18:10 -07:00
parent e2199c2636
commit 00eacdf8fc
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 142 additions and 57 deletions

View file

@ -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

View file

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

View file

@ -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": [

View file

@ -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,
};

View file

@ -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| {

View file

@ -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"));
}

View file

@ -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",
};