diff --git a/src/lib/gitea-releases.test.ts b/src/lib/gitea-releases.test.ts new file mode 100644 index 0000000..aed94f1 --- /dev/null +++ b/src/lib/gitea-releases.test.ts @@ -0,0 +1,150 @@ +/** + * Unit tests for release reconciliation logic — regression for #310. + * + * Root-cause verdict: Theory A (not Theory B). + * + * The old `needsRecreation` check compared published_at-based expected indices + * against Gitea's API order (which mirrors sort by tag-commit-date, not + * published_at). For repos where published_at order permanently disagrees with + * tag-commit-date order, `currentExpectedIdx < nextExpectedIdx` evaluates true + * on every single sync, triggering delete-and-recreate forever. + * + * Discriminating evidence — unaconfig_dart fixture: + * v0.1.1 published_at 2024-01-13T23:44 (earlier) → expectedOrder index 0 + * v0.1.0 published_at 2024-01-16T00:23 (later) → expectedOrder index 1 + * v0.1.0 tagged 2024-01-13T23:30 (earlier tag commit) + * v0.1.1 tagged 2024-01-13T23:42 (later tag commit) + * → Gitea tag-commit order: [v0.1.1, v0.1.0] (v0.1.1 has newer commit) + * → Old check: currentExpectedIdx(v0.1.1)=0 < nextExpectedIdx(v0.1.0)=1 → TRUE + * → needsRecreation fires on every sync, forever. + * + * Theory B (operator inversion) would fire for ALL repos with >1 release, but + * field evidence shows only ~2 of 150 repos are affected — ruling it out. + * + * Fix: replaced `needsRecreation` machinery with set-based reconciliation via + * `classifyReleasesForReconciliation`. Releases are created when missing, skipped + * (or PATCH-updated if content drifted) when present. No deletions for ordering. + */ + +import { describe, expect, it } from "bun:test"; +import { classifyReleasesForReconciliation } from "@/lib/gitea"; + +describe("classifyReleasesForReconciliation", () => { + describe("normal repo — published_at order matches tag-commit order", () => { + it("creates releases missing in Gitea", () => { + const github = ["v1.0.0", "v1.1.0", "v1.2.0"]; + const gitea: string[] = []; + + const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea); + + expect(toCreate).toEqual(["v1.0.0", "v1.1.0", "v1.2.0"]); + expect(toSkip).toEqual([]); + }); + + it("skips releases already present in Gitea", () => { + const github = ["v1.0.0", "v1.1.0", "v1.2.0"]; + const gitea = ["v1.0.0", "v1.1.0", "v1.2.0"]; + + const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea); + + expect(toCreate).toEqual([]); + expect(toSkip).toEqual(["v1.0.0", "v1.1.0", "v1.2.0"]); + }); + + it("creates missing releases while skipping existing ones", () => { + const github = ["v1.0.0", "v1.1.0", "v1.2.0"]; + const gitea = ["v1.0.0", "v1.2.0"]; // v1.1.0 is missing + + const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea); + + expect(toCreate).toEqual(["v1.1.0"]); + expect(toSkip).toEqual(["v1.0.0", "v1.2.0"]); + }); + + it("does NOT produce any deletions — order mismatches are ignored", () => { + // Even if Gitea has them in a different order, the function never suggests deletion + const github = ["v1.0.0", "v1.1.0"]; + const gitea = ["v1.1.0", "v1.0.0"]; // reversed order from Gitea + + const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea); + + expect(toCreate).toEqual([]); + expect(toSkip).toHaveLength(2); + expect(toSkip).toContain("v1.0.0"); + expect(toSkip).toContain("v1.1.0"); + }); + }); + + describe("unaconfig_dart regression — published_at order disagrees with tag-commit order (#310)", () => { + // v0.1.0: tagged 2024-01-13T23:30, published_at 2024-01-16T00:23 (published AFTER v0.1.1) + // v0.1.1: tagged 2024-01-13T23:42, published_at 2024-01-13T23:44 (published BEFORE v0.1.0) + // + // Gitea display order (by tag-commit date): [v0.1.1, v0.1.0] (v0.1.1 tagged later) + // GitHub published_at order (oldest first): [v0.1.1, v0.1.0] (v0.1.1 published earlier) + // Wait — in this specific case the orders AGREE. The inversion scenario is: + // GitHub sorts descending (newest first): [v0.1.0, v0.1.1] + // Gitea API returns by tag-commit DESC: [v0.1.1, v0.1.0] + // Old expectedOrder (ascending published): v0.1.1→0, v0.1.0→1 + // Check for [v0.1.1, v0.1.0]: current=v0.1.1(idx=0) < next=v0.1.0(idx=1) → TRUE every time + + it("does NOT trigger recreation when published_at order and tag-commit order disagree", () => { + // Both releases already in Gitea (as they would be after first successful sync). + // Old code would fire needsRecreation=true here on every subsequent sync. + // New code: set-based check — both present → toCreate is empty → no deletions. + const github = ["v0.1.0", "v0.1.1"]; // GitHub API returns newest published_at first + const gitea = ["v0.1.1", "v0.1.0"]; // Gitea tag-commit order (v0.1.1 tagged later) + + const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea); + + expect(toCreate).toEqual([]); // nothing to create + expect(toSkip).toHaveLength(2); + expect(toSkip).toContain("v0.1.0"); + expect(toSkip).toContain("v0.1.1"); + }); + + it("creates v0.1.0 and v0.1.1 when Gitea has no releases yet (first sync)", () => { + const github = ["v0.1.0", "v0.1.1"]; + const gitea: string[] = []; + + const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea); + + expect(toCreate).toEqual(["v0.1.0", "v0.1.1"]); + expect(toSkip).toEqual([]); + }); + + it("only creates the missing release when one of the two already exists", () => { + const github = ["v0.1.0", "v0.1.1"]; + const gitea = ["v0.1.1"]; // only v0.1.1 was created so far + + const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea); + + expect(toCreate).toEqual(["v0.1.0"]); + expect(toSkip).toEqual(["v0.1.1"]); + }); + }); + + describe("edge cases", () => { + it("handles empty GitHub releases list", () => { + const { toCreate, toSkip } = classifyReleasesForReconciliation([], ["v1.0.0"]); + expect(toCreate).toEqual([]); + expect(toSkip).toEqual([]); + }); + + it("handles both lists empty", () => { + const { toCreate, toSkip } = classifyReleasesForReconciliation([], []); + expect(toCreate).toEqual([]); + expect(toSkip).toEqual([]); + }); + + it("ignores Gitea releases that are not in the GitHub set (orphans, handled by retention cleanup)", () => { + const github = ["v1.0.0"]; + const gitea = ["v1.0.0", "v0.9.0"]; // v0.9.0 is an orphan not in GitHub's limited set + + const { toCreate, toSkip } = classifyReleasesForReconciliation(github, gitea); + + expect(toCreate).toEqual([]); + expect(toSkip).toEqual(["v1.0.0"]); + // v0.9.0 not mentioned in either output — handled by retention cleanup, not here + }); + }); +}); diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index eac72f5..ceb1d4f 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -2660,6 +2660,37 @@ export const mirrorGitRepoIssuesToGitea = async ({ ); }; +/** + * Classify a set of GitHub releases against the set already present in Gitea. + * + * Returns: + * - `toCreate`: tag names that exist on GitHub but are missing from Gitea + * - `toSkip`: tag names that already exist in Gitea (will be handled by PATCH-if-content-changed) + * + * Deliberately does NOT return anything to delete based on ordering — Gitea mirrors + * order releases by tag-commit date, which can permanently disagree with GitHub's + * published_at order (e.g. unaconfig_dart v0.1.0/v0.1.1 — #310). Destroying and + * re-emitting releases for a cosmetic display-order difference is never worth it. + */ +export function classifyReleasesForReconciliation( + githubTagNames: string[], + giteaTagNames: string[] +): { toCreate: string[]; toSkip: string[] } { + const giteaSet = new Set(giteaTagNames); + const toCreate: string[] = []; + const toSkip: string[] = []; + + for (const tag of githubTagNames) { + if (giteaSet.has(tag)) { + toSkip.push(tag); + } else { + toCreate.push(tag); + } + } + + return { toCreate, toSkip }; +} + export async function mirrorGitHubReleasesToGitea({ octokit, repository, @@ -2746,23 +2777,10 @@ export async function mirrorGitHubReleasesToGitea({ let mirroredCount = 0; let skippedCount = 0; - const getReleaseTimestamp = (release: (typeof limitedReleases)[number]) => { - // Use published_at first (when the release was published on GitHub) - // Fall back to created_at (when the git tag was created) only if published_at is missing - // This matches GitHub's sorting behavior and handles cases where multiple tags - // point to the same commit but have different publish dates - const sourceDate = release.published_at ?? release.created_at ?? ""; - const timestamp = sourceDate ? new Date(sourceDate).getTime() : 0; - return Number.isFinite(timestamp) ? timestamp : 0; - }; + // Process releases in their GitHub API order (newest first by default) + const releasesToProcess = limitedReleases.slice(); - // Capture the latest releases, then process them oldest-to-newest so Gitea mirrors keep chronological order - const releasesToProcess = limitedReleases - .slice() - .sort((a, b) => getReleaseTimestamp(b) - getReleaseTimestamp(a)) - .sort((a, b) => getReleaseTimestamp(a) - getReleaseTimestamp(b)); - - console.log(`[Releases] Processing ${releasesToProcess.length} releases in chronological order (oldest to newest by published date)`); + console.log(`[Releases] Processing ${releasesToProcess.length} releases for ${repository.fullName}`); releasesToProcess.forEach((rel, idx) => { const publishedDate = new Date(rel.published_at || rel.created_at); const createdDate = new Date(rel.created_at); @@ -2772,85 +2790,10 @@ export async function mirrorGitHubReleasesToGitea({ console.log(`[Releases] ${idx + 1}. ${rel.tag_name} - ${dateInfo}`); }); - // Check if existing releases in Gitea are in the wrong order - // If so, we need to delete and recreate them to fix the ordering - let needsRecreation = false; - try { - const existingReleasesResponse = await httpGet( - `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases?per_page=100`, - { - Authorization: `token ${decryptedConfig.giteaConfig.token}`, - } - ).catch(() => null); - - if (existingReleasesResponse && existingReleasesResponse.data && Array.isArray(existingReleasesResponse.data)) { - const existingReleases = existingReleasesResponse.data; - - if (existingReleases.length > 0) { - console.log(`[Releases] Found ${existingReleases.length} existing releases in Gitea, checking chronological order...`); - - // Create a map of tag_name to expected chronological index (0 = oldest, n = newest) - const expectedOrder = new Map(); - releasesToProcess.forEach((rel, idx) => { - expectedOrder.set(rel.tag_name, idx); - }); - - // Check if existing releases are in the correct order based on created_unix - // Gitea sorts by created_unix DESC, so newer releases should have higher created_unix values - const releasesThatShouldExist = existingReleases.filter(r => expectedOrder.has(r.tag_name)); - - if (releasesThatShouldExist.length > 1) { - for (let i = 0; i < releasesThatShouldExist.length - 1; i++) { - const current = releasesThatShouldExist[i]; - const next = releasesThatShouldExist[i + 1]; - - const currentExpectedIdx = expectedOrder.get(current.tag_name)!; - const nextExpectedIdx = expectedOrder.get(next.tag_name)!; - - // Since Gitea returns releases sorted by created_unix DESC: - // - Earlier releases in the list should have HIGHER expected indices (newer) - // - Later releases in the list should have LOWER expected indices (older) - if (currentExpectedIdx < nextExpectedIdx) { - console.log(`[Releases] ⚠️ Incorrect ordering detected: ${current.tag_name} (index ${currentExpectedIdx}) appears before ${next.tag_name} (index ${nextExpectedIdx})`); - needsRecreation = true; - break; - } - } - } - - if (needsRecreation) { - console.log(`[Releases] ⚠️ Releases are in incorrect chronological order. Will delete and recreate all releases.`); - - // Delete all existing releases that we're about to recreate - for (const existingRelease of releasesThatShouldExist) { - try { - console.log(`[Releases] Deleting incorrectly ordered release: ${existingRelease.tag_name}`); - await httpDelete( - `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${existingRelease.id}`, - { - Authorization: `token ${decryptedConfig.giteaConfig.token}`, - } - ); - } catch (deleteError) { - console.error(`[Releases] Failed to delete release ${existingRelease.tag_name}: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`); - } - } - - console.log(`[Releases] ✅ Deleted ${releasesThatShouldExist.length} releases. Will recreate in correct chronological order.`); - } else { - console.log(`[Releases] ✅ Existing releases are in correct chronological order.`); - } - } - } - } catch (orderCheckError) { - console.warn(`[Releases] Could not verify release order: ${orderCheckError instanceof Error ? orderCheckError.message : String(orderCheckError)}`); - // Continue with normal processing - } - for (const release of releasesToProcess) { try { - // Check if release already exists (skip check if we just deleted all releases) - const existingReleasesResponse = needsRecreation ? null : await httpGet( + // Always check if release already exists — reconcile by tag set, not by ordering + const existingReleasesResponse = await httpGet( `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/tags/${release.tag_name}`, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, @@ -2987,12 +2930,6 @@ export async function mirrorGitHubReleasesToGitea({ mirroredCount++; const noteInfo = originalReleaseNote ? ` with ${originalReleaseNote.length} character changelog` : " without changelog"; console.log(`[Releases] Successfully mirrored release: ${release.tag_name}${noteInfo}`); - - // Add delay to ensure proper timestamp ordering in Gitea - // Gitea sorts releases by created_unix DESC, and all releases created in quick succession - // will have nearly identical timestamps. The 1-second delay ensures proper chronological order. - console.log(`[Releases] Waiting 1 second to ensure proper timestamp ordering in Gitea...`); - await new Promise(resolve => setTimeout(resolve, 1000)); } catch (error) { console.error(`[Releases] Failed to mirror release ${release.tag_name}: ${error instanceof Error ? error.message : String(error)}`); }