diff --git a/src/main.zig b/src/main.zig index a2da073..1b57cad 100644 --- a/src/main.zig +++ b/src/main.zig @@ -67,6 +67,7 @@ pub const Release = struct { description: []const u8, provider: []const u8, is_tag: bool = false, + is_prerelease: bool = false, // Track if this is a prerelease/draft pub fn deinit(self: Release, allocator: Allocator) void { allocator.free(self.repo_name); @@ -205,8 +206,25 @@ pub fn main() !u8 { // Sort all releases by published date (most recent first) std.mem.sort(Release, all_releases.items, {}, utils.compareReleasesByDate); + // Filter out prereleases after duplicate detection is complete + // Exception: Keep git-style version prereleases (like kraftkit) + var filtered_releases = std.ArrayList(Release).init(allocator); + defer filtered_releases.deinit(); + + for (all_releases.items) |release| { + if (!release.is_prerelease) { + try filtered_releases.append(release); + } else { + // Check if this is a git-style version (e.g., v1.2.3-123-g1234567) + // These should be included even if marked as prerelease + if (isGitStyleVersion(release.tag_name)) { + try filtered_releases.append(release); + } + } + } + // Generate Atom feed from filtered releases - const atom_content = try atom.generateFeed(allocator, all_releases.items); + const atom_content = try atom.generateFeed(allocator, filtered_releases.items); defer allocator.free(atom_content); // Write to output file @@ -218,12 +236,61 @@ pub fn main() !u8 { _ = checkFileSizeAndWarn(atom_content.len); // Log to stderr for user feedback - printInfo("Total releases in feed: {} of {} total in last {} days\n", .{ all_releases.items.len, original_count, @divTrunc(RELEASE_AGE_LIMIT_SECONDS, std.time.s_per_day) }); + printInfo("Total releases in feed: {} of {} total in last {} days\n", .{ filtered_releases.items.len, original_count, @divTrunc(RELEASE_AGE_LIMIT_SECONDS, std.time.s_per_day) }); printInfo("Updated feed written to: {s}\n", .{output_file}); return 0; } +/// Check if a tag contains git-style version pattern like v1.2.3-123-g1234567 +fn isGitStyleVersion(tag_name: []const u8) bool { + // Convert to lowercase for comparison + var tag_lower_buf: [256]u8 = undefined; + if (tag_name.len >= tag_lower_buf.len) return false; + + const tag_lower = std.ascii.lowerString(tag_lower_buf[0..tag_name.len], tag_name); + + // Look for pattern: -number-g followed by hex characters + var i: usize = 0; + while (i < tag_lower.len) { + if (tag_lower[i] == '-') { + // Found a dash, check if followed by digits + var j = i + 1; + var has_digits = false; + + // Skip digits + while (j < tag_lower.len and tag_lower[j] >= '0' and tag_lower[j] <= '9') { + has_digits = true; + j += 1; + } + + // Check if followed by -g and hex characters + if (has_digits and j + 2 < tag_lower.len and + tag_lower[j] == '-' and tag_lower[j + 1] == 'g') + { + // Check if followed by hex characters + var k = j + 2; + var hex_count: usize = 0; + while (k < tag_lower.len and + ((tag_lower[k] >= '0' and tag_lower[k] <= '9') or + (tag_lower[k] >= 'a' and tag_lower[k] <= 'f'))) + { + hex_count += 1; + k += 1; + } + + // If we found hex characters, this looks like git describe format + if (hex_count >= 4) { // At least 4 hex chars for a reasonable commit hash + return true; + } + } + } + i += 1; + } + + return false; +} + fn formatTimestampForDisplay(allocator: Allocator, timestamp: i64) ![]const u8 { if (timestamp == 0) { return try allocator.dupe(u8, "beginning of time"); @@ -496,6 +563,7 @@ test { std.testing.refAllDecls(@import("timestamp_tests.zig")); std.testing.refAllDecls(@import("atom.zig")); std.testing.refAllDecls(@import("utils.zig")); + std.testing.refAllDecls(@import("tag_filter.zig")); std.testing.refAllDecls(@import("providers/GitHub.zig")); std.testing.refAllDecls(@import("providers/GitLab.zig")); std.testing.refAllDecls(@import("providers/SourceHut.zig")); diff --git a/src/providers/Codeberg.zig b/src/providers/Codeberg.zig index ac4a4e6..446282a 100644 --- a/src/providers/Codeberg.zig +++ b/src/providers/Codeberg.zig @@ -4,6 +4,7 @@ const json = std.json; const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; const utils = @import("../utils.zig"); +const tag_filter = @import("../tag_filter.zig"); const Release = @import("../main.zig").Release; const Provider = @import("../Provider.zig"); @@ -224,6 +225,13 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8 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; @@ -235,7 +243,7 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8 const release = Release{ .repo_name = try allocator.dupe(u8, repo), - .tag_name = try allocator.dupe(u8, tag_name_value.string), + .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), @@ -342,3 +350,26 @@ test "codeberg release parsing with live data snapshot" { ); try std.testing.expectEqualStrings("codeberg", releases.items[0].provider); } + +test "Codeberg tag filtering" { + const allocator = std.testing.allocator; + + // Test that Codeberg now uses the same filtering as other providers + const problematic_tags = [_][]const u8{ + "nightly", "prerelease", "latest", "edge", "canary", "dev-branch", + }; + + for (problematic_tags) |tag| { + try std.testing.expect(tag_filter.shouldSkipTag(allocator, tag)); + } + + // Test that valid tags are not filtered + const valid_tags = [_][]const u8{ + "v1.0.0", "v2.1.3-stable", + // Note: v1.0.0-alpha.1 is now filtered to avoid duplicates + }; + + for (valid_tags) |tag| { + try std.testing.expect(!tag_filter.shouldSkipTag(allocator, tag)); + } +} diff --git a/src/providers/GitHub.zig b/src/providers/GitHub.zig index 87438bc..71b67b6 100644 --- a/src/providers/GitHub.zig +++ b/src/providers/GitHub.zig @@ -5,6 +5,7 @@ const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; const Thread = std.Thread; const utils = @import("../utils.zig"); +const tag_filter = @import("../tag_filter.zig"); const Release = @import("../main.zig").Release; const Provider = @import("../Provider.zig"); @@ -488,6 +489,10 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8 for (array.items) |item| { const obj = item.object; + // Track prerelease and draft status but don't filter yet + const is_prerelease = if (obj.get("prerelease")) |prerelease| prerelease.bool else false; + const is_draft = if (obj.get("draft")) |draft| draft.bool else false; + const body_value = obj.get("body") orelse json.Value{ .string = "" }; const body_str = if (body_value == .string) body_value.string else ""; @@ -499,6 +504,7 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8 .description = try allocator.dupe(u8, body_str), .provider = try allocator.dupe(u8, "github"), .is_tag = false, + .is_prerelease = is_prerelease or is_draft, // Mark as prerelease if either flag is true }; try releases.append(release); @@ -507,57 +513,9 @@ fn getRepoReleases(allocator: Allocator, client: *http.Client, token: []const u8 return releases; } -fn shouldSkipTag(allocator: std.mem.Allocator, tag_name: []const u8) bool { - // List of common moving tags that should be filtered out - const moving_tags = [_][]const u8{ - // common "latest commit tags" - "latest", - "tip", - "continuous", - "head", - - // common branch tags - "main", - "master", - "trunk", - "develop", - "development", - "dev", - - // common fast moving channel names - "nightly", - "edge", - "canary", - "alpha", - - // common slower channels, but without version information - // they probably are not something we're interested in - "beta", - "rc", - "release", - "snapshot", - "unstable", - "experimental", - "prerelease", - "preview", - }; - - // Check if tag name contains common moving patterns - const tag_lower = std.ascii.allocLowerString(allocator, tag_name) catch return false; - defer allocator.free(tag_lower); - - for (moving_tags) |moving_tag| - if (std.mem.eql(u8, tag_lower, moving_tag)) - return true; - - // Skip pre-release and development tags - if (std.mem.startsWith(u8, tag_lower, "pre-") or - std.mem.startsWith(u8, tag_lower, "dev-") or - std.mem.startsWith(u8, tag_lower, "test-") or - std.mem.startsWith(u8, tag_lower, "debug-")) - return true; - - return false; +/// Wrapper function for backward compatibility and testing +pub fn shouldSkipTag(allocator: std.mem.Allocator, tag_name: []const u8) bool { + return tag_filter.shouldSkipTag(allocator, tag_name); } fn getRepoTags(allocator: Allocator, client: *http.Client, token: []const u8, repo: []const u8) !ArrayList(Release) { @@ -660,7 +618,7 @@ fn parseGraphQL(allocator: std.mem.Allocator, repo: []const u8, body: []const u8 const tag_name = node_obj.get("name").?.string; // Skip common moving tags - if (shouldSkipTag(allocator, tag_name)) continue; + if (tag_filter.shouldSkipTag(allocator, tag_name)) continue; const target = node_obj.get("target").?.object; @@ -1012,6 +970,114 @@ test "addNonReleaseTags should not add duplicate tags" { try std.testing.expect(found_unique); } +test "GitHub prerelease and draft filtering" { + const allocator = std.testing.allocator; + + // Test shouldSkipRelease function + try std.testing.expect(tag_filter.shouldSkipRelease(true, false)); // prerelease + try std.testing.expect(tag_filter.shouldSkipRelease(false, true)); // draft + try std.testing.expect(tag_filter.shouldSkipRelease(true, true)); // both + try std.testing.expect(!tag_filter.shouldSkipRelease(false, false)); // normal release + + // Mock GitHub API response with prerelease and draft fields + const github_api_response = + \\[ + \\ { + \\ "tag_name": "v1.0.0", + \\ "published_at": "2024-01-01T00:00:00Z", + \\ "html_url": "https://github.com/test/repo/releases/tag/v1.0.0", + \\ "body": "Stable release", + \\ "prerelease": false, + \\ "draft": false + \\ }, + \\ { + \\ "tag_name": "v1.1.0-alpha", + \\ "published_at": "2024-01-02T00:00:00Z", + \\ "html_url": "https://github.com/test/repo/releases/tag/v1.1.0-alpha", + \\ "body": "Alpha release", + \\ "prerelease": true, + \\ "draft": false + \\ }, + \\ { + \\ "tag_name": "v1.2.0-draft", + \\ "published_at": "2024-01-03T00:00:00Z", + \\ "html_url": "https://github.com/test/repo/releases/tag/v1.2.0-draft", + \\ "body": "Draft release", + \\ "prerelease": false, + \\ "draft": true + \\ } + \\] + ; + + // Parse the JSON to verify structure + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, github_api_response, .{}); + defer parsed.deinit(); + + const array = parsed.value.array; + try std.testing.expectEqual(@as(usize, 3), array.items.len); + + // Verify the prerelease and draft fields are present and correct + const stable_release = array.items[0].object; + try std.testing.expectEqual(false, stable_release.get("prerelease").?.bool); + try std.testing.expectEqual(false, stable_release.get("draft").?.bool); + + const prerelease_item = array.items[1].object; + try std.testing.expectEqual(true, prerelease_item.get("prerelease").?.bool); + try std.testing.expectEqual(false, prerelease_item.get("draft").?.bool); + + const draft_item = array.items[2].object; + try std.testing.expectEqual(false, draft_item.get("prerelease").?.bool); + try std.testing.expectEqual(true, draft_item.get("draft").?.bool); +} + +test "shouldSkipTag comprehensive filtering tests" { + const allocator = std.testing.allocator; + + // Test exact matches for moving tags + const moving_tags = [_][]const u8{ + "latest", "tip", "continuous", "head", "main", "master", "trunk", + "develop", "development", "dev", "nightly", "edge", "canary", "alpha", + "beta", "rc", "release", "snapshot", "unstable", "experimental", "prerelease", + "preview", + }; + + for (moving_tags) |tag| { + try std.testing.expect(shouldSkipTag(allocator, tag)); + + // Test case insensitive matching + const upper_tag = try std.ascii.allocUpperString(allocator, tag); + defer allocator.free(upper_tag); + try std.testing.expect(shouldSkipTag(allocator, upper_tag)); + } + + // Test prefix patterns + const prefix_patterns = [_][]const u8{ + "pre-release", "pre-1.0.0", "dev-branch", "dev-feature", + "test-build", "test-123", "debug-version", "debug-info", + }; + + for (prefix_patterns) |tag| { + try std.testing.expect(shouldSkipTag(allocator, tag)); + } + + // Test valid version tags that should NOT be filtered + const valid_tags = [_][]const u8{ + "v1.0.0", "v2.1.3", "1.0.0", "2.1.3-stable", "v1.0.0-final", + "release-1.0.0", "stable-v1.0.0", "v1.0.0-lts", + "2023.1.0", + // Note: Semantic versioning prerelease tags are now filtered to avoid duplicates + }; + + for (valid_tags) |tag| { + try std.testing.expect(!shouldSkipTag(allocator, tag)); + } + + // Test edge cases + try std.testing.expect(!shouldSkipTag(allocator, "")); // Empty string + try std.testing.expect(!shouldSkipTag(allocator, "v1.0.0+build.1")); // Build metadata + try std.testing.expect(!shouldSkipTag(allocator, "nightly-build-v1.0.0")); // Contains "nightly" but has version +} + test "parse tag graphQL output" { const result = \\{"data":{"repository":{"refs":{"pageInfo":{"hasNextPage":false,"endCursor":"MzY"},"nodes":[{"name":"v0.7.9","target":{"committedDate":"2025-07-16T06:14:23Z","message":"chore: bump version to v0.7.9"}},{"name":"v0.7.8","target":{"committedDate":"2025-07-15T23:01:11Z","message":"chore: bump version to v0.7.8"}},{"name":"v0.7.7","target":{"committedDate":"2025-04-16T02:32:43Z","message":"chore: bump version to v0.7.0"}},{"name":"v0.7.6","target":{"committedDate":"2025-04-13T18:00:14Z","message":"chore: bump version to v0.7.6"}},{"name":"v0.7.5","target":{"committedDate":"2025-04-12T20:31:13Z","message":"chore: bump version to v0.7.5"}},{"name":"v0.7.4","target":{"committedDate":"2025-04-06T02:08:45Z","message":"chore: bump version to v0.7.4"}},{"name":"v0.3.6","target":{"committedDate":"2024-12-20T07:25:36Z","message":"chore: bump version to v3.4.6"}},{"name":"v0.1.0","target":{"committedDate":"2024-11-16T23:19:14Z","message":"chore: bump version to v0.1.0"}}]}}}} diff --git a/src/providers/GitLab.zig b/src/providers/GitLab.zig index fd687a5..1cfcda7 100644 --- a/src/providers/GitLab.zig +++ b/src/providers/GitLab.zig @@ -4,6 +4,7 @@ const json = std.json; const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; const utils = @import("../utils.zig"); +const tag_filter = @import("../tag_filter.zig"); const Release = @import("../main.zig").Release; const Provider = @import("../Provider.zig"); @@ -220,9 +221,16 @@ fn getProjectReleases(allocator: Allocator, client: *http.Client, token: []const const desc_value = obj.get("description") orelse json.Value{ .string = "" }; const desc_str = if (desc_value == .string) desc_value.string else ""; + const tag_name = obj.get("tag_name").?.string; + + // Skip problematic tags + if (tag_filter.shouldSkipTag(allocator, tag_name)) { + continue; + } + const release = Release{ .repo_name = try allocator.dupe(u8, obj.get("name").?.string), - .tag_name = try allocator.dupe(u8, obj.get("tag_name").?.string), + .tag_name = try allocator.dupe(u8, tag_name), .published_at = try utils.parseReleaseTimestamp(obj.get("created_at").?.string), .html_url = try allocator.dupe(u8, obj.get("_links").?.object.get("self").?.string), .description = try allocator.dupe(u8, desc_str), @@ -347,3 +355,26 @@ test "gitlab release parsing with live data snapshot" { ); try std.testing.expectEqualStrings("gitlab", releases.items[0].provider); } + +test "GitLab tag filtering" { + const allocator = std.testing.allocator; + + // Test that GitLab now uses the same filtering as other providers + const problematic_tags = [_][]const u8{ + "nightly", "prerelease", "latest", "edge", "canary", "dev-branch", + }; + + for (problematic_tags) |tag| { + try std.testing.expect(tag_filter.shouldSkipTag(allocator, tag)); + } + + // Test that valid tags are not filtered + const valid_tags = [_][]const u8{ + "v1.0.0", "v2.1.3-stable", + // Note: v1.0.0-alpha.1 is now filtered to avoid duplicates + }; + + for (valid_tags) |tag| { + try std.testing.expect(!tag_filter.shouldSkipTag(allocator, tag)); + } +} diff --git a/src/providers/SourceHut.zig b/src/providers/SourceHut.zig index d719b74..50f4ae7 100644 --- a/src/providers/SourceHut.zig +++ b/src/providers/SourceHut.zig @@ -4,6 +4,7 @@ const json = std.json; const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; const utils = @import("../utils.zig"); +const tag_filter = @import("../tag_filter.zig"); const Release = @import("../main.zig").Release; const Provider = @import("../Provider.zig"); @@ -128,6 +129,11 @@ fn fetchReleasesMultiRepo(allocator: Allocator, client: *http.Client, token: []c else "1970-01-01T00:00:00Z"; + // Skip problematic tags + if (tag_filter.shouldSkipTag(allocator, tag_data.tag_name)) { + continue; + } + // TODO: Investigate annotated tags as the description here const release = Release{ .repo_name = try std.fmt.allocPrint(allocator, "~{s}/{s}", .{ tag_data.username, tag_data.reponame }), @@ -600,3 +606,26 @@ test "sourcehut provider" { try std.testing.expectEqualStrings("sourcehut", sourcehut_provider.getName()); } + +test "SourceHut tag filtering" { + const allocator = std.testing.allocator; + + // Test that SourceHut now uses the same filtering as other providers + const problematic_tags = [_][]const u8{ + "nightly", "prerelease", "latest", "edge", "canary", "dev-branch", + }; + + for (problematic_tags) |tag| { + try std.testing.expect(tag_filter.shouldSkipTag(allocator, tag)); + } + + // Test that valid tags are not filtered + const valid_tags = [_][]const u8{ + "v1.0.0", "v2.1.3-stable", + // Note: v1.0.0-alpha.1 is now filtered to avoid duplicates + }; + + for (valid_tags) |tag| { + try std.testing.expect(!tag_filter.shouldSkipTag(allocator, tag)); + } +} diff --git a/src/tag_filter.zig b/src/tag_filter.zig new file mode 100644 index 0000000..26770be --- /dev/null +++ b/src/tag_filter.zig @@ -0,0 +1,302 @@ +const std = @import("std"); + +/// Common tag filtering logic that can be used across all providers +pub fn shouldSkipTag(allocator: std.mem.Allocator, tag_name: []const u8) bool { + // Check if tag name contains common moving patterns + const tag_lower = std.ascii.allocLowerString(allocator, tag_name) catch return false; + defer allocator.free(tag_lower); + + // First check if this looks like semantic versioning (v1.2.3-something or 1.2.3-something) + // If it does, we should be more careful about filtering + const is_semantic_versioning = isSemVer(tag_lower); + + // List of common moving tags that should be filtered out + const moving_tags = [_][]const u8{ + // common "latest commit tags" + "latest", + "tip", + "continuous", + "head", + + // common branch tags + "main", + "master", + "trunk", + "develop", + "development", + "dev", + + // common fast moving channel names + "nightly", + "edge", + "canary", + + // common slower channels, but without version information + // they probably are not something we're interested in + "release", + "snapshot", + "unstable", + "experimental", + "prerelease", + "preview", + }; + + // Check for exact matches with moving tags + for (moving_tags) |moving_tag| + if (std.mem.eql(u8, tag_lower, moving_tag)) + return true; + + // Only filter standalone alpha/beta/rc if they're NOT part of semantic versioning + if (!is_semantic_versioning) { + const standalone_prerelease_tags = [_][]const u8{ + "alpha", + "beta", + "rc", + }; + + for (standalone_prerelease_tags) |tag| + if (std.mem.eql(u8, tag_lower, tag)) + return true; + } else { + // For semantic versioning, be more conservative and filter out prerelease versions + // since these are likely to be duplicates of releases that are already filtered + // by the releases API prerelease flag + if (containsPrereleaseIdentifier(tag_lower)) { + return true; + } + } + + // Skip pre-release and development tags + if (std.mem.startsWith(u8, tag_lower, "pre-") or + std.mem.startsWith(u8, tag_lower, "dev-") or + std.mem.startsWith(u8, tag_lower, "test-") or + std.mem.startsWith(u8, tag_lower, "debug-")) + return true; + + return false; +} + +/// Check if a tag looks like semantic versioning +fn isSemVer(tag_lower: []const u8) bool { + // Look for patterns like: + // v1.2.3, v1.2.3-alpha.1, 1.2.3, 1.2.3-beta.2, etc. + + var start_idx: usize = 0; + + // Skip optional 'v' prefix + if (tag_lower.len > 0 and tag_lower[0] == 'v') { + start_idx = 1; + } + + if (start_idx >= tag_lower.len) return false; + + // Look for pattern: number.number.number + var dot_count: u8 = 0; + var has_digit = false; + + for (tag_lower[start_idx..]) |c| { + if (c >= '0' and c <= '9') { + has_digit = true; + } else if (c == '.') { + if (!has_digit) return false; // dot without preceding digit + dot_count += 1; + has_digit = false; + if (dot_count > 2) break; // we only care about major.minor.patch + } else if (c == '-' or c == '+') { + // This could be prerelease or build metadata + break; + } else { + // Invalid character for semver + return false; + } + } + + // Must have at least 2 dots (major.minor.patch) and end with a digit + return dot_count >= 2 and has_digit; +} + +/// Check if a semantic version contains prerelease identifiers +fn containsPrereleaseIdentifier(tag_lower: []const u8) bool { + // Look for common prerelease identifiers in semantic versioning + const prerelease_identifiers = [_][]const u8{ + "-alpha", + "-beta", + "-rc", + "-pre", + }; + + for (prerelease_identifiers) |identifier| { + if (std.mem.indexOf(u8, tag_lower, identifier) != null) { + return true; + } + } + + // Note: We don't filter git-style version tags like v1.2.3-123-g1234567 + // These are development versions but may be useful to track + // (e.g., kraftkit releases that should be included per user request) + + return false; +} + +/// Check if a release should be filtered based on prerelease/draft status +pub fn shouldSkipRelease(is_prerelease: bool, is_draft: bool) bool { + return is_prerelease or is_draft; +} + +test "shouldSkipTag filters common moving tags" { + const allocator = std.testing.allocator; + + // Test exact matches for moving tags + const moving_tags = [_][]const u8{ + "latest", + "tip", + "continuous", + "head", + "main", + "master", + "trunk", + "develop", + "development", + "dev", + "nightly", + "edge", + "canary", + "release", + "snapshot", + "unstable", + "experimental", + "prerelease", + "preview", + }; + + for (moving_tags) |tag| { + const should_skip = shouldSkipTag(allocator, tag); + try std.testing.expect(should_skip); + + // Test case insensitive matching + const upper_tag = try std.ascii.allocUpperString(allocator, tag); + defer allocator.free(upper_tag); + const should_skip_upper = shouldSkipTag(allocator, upper_tag); + try std.testing.expect(should_skip_upper); + } + + // Test standalone alpha/beta/rc (should be filtered) + const standalone_prerelease = [_][]const u8{ "alpha", "beta", "rc" }; + for (standalone_prerelease) |tag| { + try std.testing.expect(shouldSkipTag(allocator, tag)); + } +} + +test "shouldSkipTag filters prefix patterns" { + const allocator = std.testing.allocator; + + const prefix_patterns = [_][]const u8{ + "pre-release", + "pre-1.0.0", + "dev-branch", + "dev-feature", + "test-build", + "test-123", + "debug-version", + "debug-info", + }; + + for (prefix_patterns) |tag| { + const should_skip = shouldSkipTag(allocator, tag); + try std.testing.expect(should_skip); + } +} + +test "shouldSkipTag allows valid version tags" { + const allocator = std.testing.allocator; + + const valid_tags = [_][]const u8{ + "v1.0.0", + "v2.1.3", + "1.0.0", + "2.1.3-stable", + "v1.0.0-final", + "release-1.0.0", + "stable-v1.0.0", + "v1.0.0-lts", + "2023.1.0", + // Note: Semantic versioning prerelease tags are now filtered to avoid duplicates + // with the releases API, so they're not in this "valid" list anymore + }; + + for (valid_tags) |tag| { + const should_skip = shouldSkipTag(allocator, tag); + try std.testing.expect(!should_skip); + } +} + +test "shouldSkipRelease filters prerelease and draft" { + // Test prerelease filtering + try std.testing.expect(shouldSkipRelease(true, false)); + + // Test draft filtering + try std.testing.expect(shouldSkipRelease(false, true)); + + // Test both prerelease and draft + try std.testing.expect(shouldSkipRelease(true, true)); + + // Test normal release + try std.testing.expect(!shouldSkipRelease(false, false)); +} + +test "semantic versioning detection" { + const allocator = std.testing.allocator; + + // Test that semantic versioning tags with alpha/beta/rc are now filtered + // (to avoid duplicates with releases API) + const semver_prerelease_tags = [_][]const u8{ + "v1.0.0-alpha.1", + "v1.0.0-beta.2", + "v1.0.0-rc.1", + "1.0.0-alpha.1", + "2.0.0-beta.1", + "3.0.0-rc.1", + "v5.5.0-rc1", + "v0.5.0-alpha01", + "v1.12.0-beta3", + "v1.24.0-rc0", + }; + + for (semver_prerelease_tags) |tag| { + const should_skip = shouldSkipTag(allocator, tag); + try std.testing.expect(should_skip); // Now these should be filtered + } + + // Test that git-style version tags are preserved (per user request for kraftkit) + const git_style_tags = [_][]const u8{ + "v0.11.6-212-g74599361", + "v1.2.3-45-g1234567", + "v2.0.0-123-gabcdef0", + }; + + for (git_style_tags) |tag| { + const should_skip = shouldSkipTag(allocator, tag); + try std.testing.expect(!should_skip); // These should NOT be filtered + } + + // Test that regular semantic versioning tags are preserved + const regular_semver_tags = [_][]const u8{ + "v1.0.0", + "v2.1.3", + "1.0.0", + "2.1.3", + "v10.20.30", + }; + + for (regular_semver_tags) |tag| { + const should_skip = shouldSkipTag(allocator, tag); + try std.testing.expect(!should_skip); + } + + // Test that standalone alpha/beta/rc are still filtered + const standalone_tags = [_][]const u8{ "alpha", "beta", "rc", "ALPHA", "Beta", "RC" }; + for (standalone_tags) |tag| { + const should_skip = shouldSkipTag(allocator, tag); + try std.testing.expect(should_skip); + } +}