Compare commits
5 commits
e2199c2636
...
102522f373
Author | SHA1 | Date | |
---|---|---|---|
102522f373 | |||
22f24c1ddb | |||
981a46da54 | |||
02584db325 | |||
00eacdf8fc |
8 changed files with 279 additions and 165 deletions
12
README.md
12
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",
|
||||
|
@ -51,8 +57,8 @@ Create a `config.json` file with your API tokens:
|
|||
### API Token Setup
|
||||
|
||||
- **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
|
||||
- **GitLab**: Create a Personal Access Token with `read_user` and `read_api` scopes
|
||||
- **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
|
||||
|
|
20
build.zig
20
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);
|
||||
}
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,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;
|
||||
|
||||
// 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 +485,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| {
|
||||
|
|
30
src/main.zig
30
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"));
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
@ -26,9 +32,10 @@ pub fn fetchReleases(self: *Self, allocator: Allocator) !ArrayList(Release) {
|
|||
defer client.deinit();
|
||||
|
||||
var releases = ArrayList(Release).init(allocator);
|
||||
const stderr = std.io.getStdErr().writer();
|
||||
|
||||
// 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 +46,8 @@ 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 stderr = std.io.getStdErr().writer();
|
||||
stderr.print("Error fetching Codeberg releases for {s}: {}\n", .{ repo, err }) catch {};
|
||||
const repo_releases = getRepoReleases(allocator, &client, self.base_url, self.token, self.name, repo) catch |err| {
|
||||
stderr.print("Error fetching {s} releases for {s}: {}\n", .{ self.name, repo, err }) catch {};
|
||||
continue;
|
||||
};
|
||||
defer repo_releases.deinit();
|
||||
|
@ -56,12 +62,12 @@ 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);
|
||||
const stderr = std.io.getStdErr().writer();
|
||||
errdefer {
|
||||
// Clean up any allocated repo names if we fail
|
||||
for (repos.items) |repo| {
|
||||
|
@ -77,8 +83,14 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, token: []const u8
|
|||
var page: u32 = 1;
|
||||
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) {
|
||||
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);
|
||||
|
||||
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 == .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;
|
||||
}
|
||||
|
||||
|
@ -115,8 +124,7 @@ fn getStarredRepos(allocator: Allocator, client: *http.Client, token: []const u8
|
|||
defer allocator.free(body);
|
||||
|
||||
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,8 +160,9 @@ 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);
|
||||
const stderr = std.io.getStdErr().writer();
|
||||
errdefer {
|
||||
// Clean up any allocated releases if we fail
|
||||
for (releases.items) |release| {
|
||||
|
@ -162,100 +171,120 @@ 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});
|
||||
defer allocator.free(url);
|
||||
|
||||
const uri = try std.Uri.parse(url);
|
||||
// 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;
|
||||
|
||||
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 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();
|
||||
// Paginate through all releases
|
||||
var page: u32 = 1;
|
||||
const per_page: u32 = 100;
|
||||
|
||||
try req.send();
|
||||
try req.wait();
|
||||
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);
|
||||
|
||||
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 {};
|
||||
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 uri = try std.Uri.parse(url);
|
||||
|
||||
const body = try req.reader().readAllAlloc(allocator, 10 * 1024 * 1024);
|
||||
defer allocator.free(body);
|
||||
var server_header_buffer: [16 * 1024]u8 = undefined;
|
||||
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| {
|
||||
const stderr = std.io.getStdErr().writer();
|
||||
stderr.print("Error parsing Codeberg releases JSON for {s}: {}\n", .{ repo, err }) catch {};
|
||||
return error.JsonParseError;
|
||||
};
|
||||
defer parsed.deinit();
|
||||
try req.send();
|
||||
try req.wait();
|
||||
|
||||
if (parsed.value != .array) {
|
||||
return error.UnexpectedJsonFormat;
|
||||
}
|
||||
|
||||
const array = parsed.value.array;
|
||||
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;
|
||||
if (req.response.status != .ok) {
|
||||
if (req.response.status == .unauthorized) {
|
||||
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) {
|
||||
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) {
|
||||
stderr.print("Forgejo API: Repository {s} not found or no releases\n", .{repo}) catch {};
|
||||
return error.NotFound;
|
||||
}
|
||||
stderr.print("Forgejo API request failed for repo {s} with status: {}\n", .{ repo, req.response.status }) catch {};
|
||||
return error.HttpRequestFailed;
|
||||
}
|
||||
|
||||
const published_at_value = obj.get("published_at") orelse continue;
|
||||
if (published_at_value != .string) continue;
|
||||
const body = try req.reader().readAllAlloc(allocator, 10 * 1024 * 1024);
|
||||
defer allocator.free(body);
|
||||
|
||||
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, "codeberg"),
|
||||
.is_tag = false,
|
||||
const parsed = json.parseFromSlice(json.Value, allocator, body, .{}) catch |err| {
|
||||
stderr.print("Error parsing Forgejo releases JSON for {s}: {}\n", .{ repo, err }) catch {};
|
||||
return error.JsonParseError;
|
||||
};
|
||||
defer parsed.deinit();
|
||||
|
||||
releases.append(release) catch |err| {
|
||||
// If append fails, clean up the release we just created
|
||||
release.deinit(allocator);
|
||||
return err;
|
||||
};
|
||||
if (parsed.value != .array) {
|
||||
return error.UnexpectedJsonFormat;
|
||||
}
|
||||
|
||||
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)
|
||||
|
@ -264,15 +293,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 +355,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 +377,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",
|
||||
};
|
|
@ -73,9 +73,6 @@ fn getStarredProjects(allocator: Allocator, client: *http.Client, token: []const
|
|||
const username = try getCurrentUsername(allocator, client, token);
|
||||
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
|
||||
var page: u32 = 1;
|
||||
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, .{
|
||||
.server_header_buffer = &server_header_buffer,
|
||||
.extra_headers = &.{
|
||||
.{ .name = "Authorization", .value = auth_header },
|
||||
.{ .name = "Private-Token", .value = token },
|
||||
.{ .name = "User-Agent", .value = "release-tracker/1.0" },
|
||||
},
|
||||
});
|
||||
|
@ -107,7 +104,8 @@ fn getStarredProjects(allocator: Allocator, client: *http.Client, token: []const
|
|||
return error.Unauthorized;
|
||||
},
|
||||
.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;
|
||||
},
|
||||
else => {
|
||||
|
@ -156,14 +154,11 @@ fn getCurrentUsername(allocator: Allocator, client: *http.Client, token: []const
|
|||
// Try to get user info first
|
||||
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 req = try client.open(.GET, uri, .{
|
||||
.server_header_buffer = &server_header_buffer,
|
||||
.extra_headers = &.{
|
||||
.{ .name = "Authorization", .value = auth_header },
|
||||
.{ .name = "Private-Token", .value = token },
|
||||
.{ .name = "User-Agent", .value = "release-tracker/1.0" },
|
||||
},
|
||||
});
|
||||
|
@ -180,7 +175,8 @@ fn getCurrentUsername(allocator: Allocator, client: *http.Client, token: []const
|
|||
return error.Unauthorized;
|
||||
},
|
||||
.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;
|
||||
},
|
||||
else => {
|
||||
|
@ -214,14 +210,11 @@ fn getProjectReleases(allocator: Allocator, client: *http.Client, token: []const
|
|||
|
||||
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 req = try client.open(.GET, uri, .{
|
||||
.server_header_buffer = &server_header_buffer,
|
||||
.extra_headers = &.{
|
||||
.{ .name = "Authorization", .value = auth_header },
|
||||
.{ .name = "Private-Token", .value = token },
|
||||
.{ .name = "User-Agent", .value = "release-tracker/1.0" },
|
||||
},
|
||||
});
|
||||
|
@ -292,23 +285,13 @@ fn getProjectReleases(allocator: Allocator, client: *http.Client, token: []const
|
|||
}
|
||||
|
||||
test "gitlab provider" {
|
||||
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();
|
||||
}
|
||||
var gitlab_provider = init("test-token");
|
||||
|
||||
// Test provider name
|
||||
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" {
|
||||
|
|
Loading…
Add table
Reference in a new issue