Compare commits

...

2 Commits

Author SHA1 Message Date
Arunavo Ray 06bfb49e0e chore: bump version to 3.20.4 2026-07-02 15:46:20 +05:30
ARUNAVO RAY b5e0c58708 fix: stop false-positive orphan archiving and heal sync 405s on archived-* renamed mirrors (#331) (#336)
Three related fixes for the "repos keep getting archived and then fail to
sync with HTTP 405" report:

1. Orphan cleanup no longer archives on bulk-list absence alone.
   Repos added via the "+" Add Repository dialog (foreign owner, not
   starred) can never appear in the authenticated bulk fetches, so every
   cleanup cycle deterministically flagged them as orphaned and archived
   them. identifyOrphanedRepositories() now runs a targeted per-repo
   confirmation (starred check or repos.get) and only treats a clean 404
   as gone; any other outcome fails safe.

2. The archived-* rename is persisted. archiveGiteaRepo() now returns
   the actual post-rename name and the cleanup service records it in
   mirroredLocation, so the DB no longer points at a name that only
   301-redirects.

3. Sync self-heals repos renamed in Gitea/Forgejo. Requests to a
   renamed repo get a 301; fetch follows it, downgrading POST to GET,
   which lands on the POST-only mirror-sync endpoint as a 405. The sync
   candidate loop now adopts the canonical owner/name from the GET
   response body before POSTing, tries an archived-{name} fallback for
   archived repos (guarded by an original_url source match), and keeps
   archived repos archived: no mirror-interval PATCH, status stays
   'archived' per the documented Manual Sync contract.

Also hardens mirrorGitHubReleasesToGitea to derive GitHub coordinates
from fullName so Gitea-side names can never leak into GitHub API calls.

Verified end-to-end on Forgejo 15.0.3 (rootless): pre-fix reproduces the
exact 405; post-fix the stale-name sync succeeds (GET stale -> 301, GET
canonical -> 200, POST canonical mirror-sync -> 200), archived repos keep
interval "0s" with no PATCH issued, and non-archived renamed repos heal
and get the configured interval applied.
2026-07-02 15:46:00 +05:30
7 changed files with 964 additions and 39 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.20.3",
"version": "3.20.4",
"engines": {
"bun": ">=1.2.9"
},
+245
View File
@@ -0,0 +1,245 @@
/**
* Unit tests for archiveGiteaRepo's return value and sanitizeRepoNameAlphaDashDot —
* regression coverage for #331's follow-up (repos falsely flagged as orphaned
* and archived, then unreachable by "Manual Sync" because the DB's
* mirroredLocation/name were never updated to the post-rename name).
*
* archiveGiteaRepo now reports the Gitea-side name it ended up with after a
* rename (mirror path) so callers (repository-cleanup-service.ts) can persist
* it, instead of leaving the DB pointing at a name that no longer exists.
*/
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
const mockHttpGet = mock(async (_url: string, _headers?: any) => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const mockHttpPatch = mock(async (_url: string, _body?: any, _headers?: any) => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const mockHttpPost = mock(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const mockHttpDelete = mock(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const mockHttpPut = mock(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
class MockHttpError extends Error {
constructor(
message: string,
public status: number,
public statusText: string,
public response?: string
) {
super(message);
this.name = "HttpError";
}
}
mock.module("@/lib/http-client", () => ({
httpGet: mockHttpGet,
httpPatch: mockHttpPatch,
httpPost: mockHttpPost,
httpDelete: mockHttpDelete,
httpPut: mockHttpPut,
HttpError: MockHttpError,
}));
import { archiveGiteaRepo, sanitizeRepoNameAlphaDashDot } from "./gitea";
describe("sanitizeRepoNameAlphaDashDot", () => {
test("replaces disallowed characters with a dash", () => {
expect(sanitizeRepoNameAlphaDashDot("my repo!")).toBe("my-repo");
});
test("collapses consecutive disallowed characters into a single dash", () => {
expect(sanitizeRepoNameAlphaDashDot("a___b")).toBe("a-b");
});
test("trims leading and trailing separators/dots", () => {
expect(sanitizeRepoNameAlphaDashDot("--.foo.--")).toBe("foo");
});
test("leaves an already-valid AlphaDashDot name unchanged", () => {
expect(sanitizeRepoNameAlphaDashDot("valid-repo.name")).toBe("valid-repo.name");
});
});
describe("archiveGiteaRepo", () => {
const client = { url: "https://gitea.example.com", token: "test-token" };
let originalConsoleLog: typeof console.log;
let originalConsoleWarn: typeof console.warn;
let originalConsoleError: typeof console.error;
let originalConsoleDebug: typeof console.debug;
beforeEach(() => {
mockHttpGet.mockClear();
mockHttpPatch.mockClear();
mockHttpPost.mockClear();
mockHttpDelete.mockClear();
// Reset to benign defaults; individual tests override with mockImplementationOnce/mockImplementation.
mockHttpGet.mockImplementation(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
mockHttpPatch.mockImplementation(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
originalConsoleLog = console.log;
originalConsoleWarn = console.warn;
originalConsoleError = console.error;
originalConsoleDebug = console.debug;
console.log = mock(() => {});
console.warn = mock(() => {});
console.error = mock(() => {});
console.debug = mock(() => {});
});
afterEach(() => {
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
console.error = originalConsoleError;
console.debug = originalConsoleDebug;
});
test("mirror repo rename returns the new archived name", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "my-repo", mirror: true, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const result = await archiveGiteaRepo(client, "owner", "my-repo");
expect(result).toEqual({ archivedName: "archived-my-repo" });
// Rename PATCH + mirror-interval-disable PATCH
expect(mockHttpPatch).toHaveBeenCalledTimes(2);
const renameCall = mockHttpPatch.mock.calls[0];
expect(String(renameCall[0])).toContain("/api/v1/repos/owner/my-repo");
expect(renameCall[1]).toMatchObject({ name: "archived-my-repo" });
});
test("already-archived mirror repo returns the existing name without re-renaming", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "archived-my-repo", mirror: true, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const result = await archiveGiteaRepo(client, "owner", "archived-my-repo");
expect(result).toEqual({ archivedName: "archived-my-repo" });
expect(mockHttpPatch).not.toHaveBeenCalled();
});
test("non-mirror repo archives natively and returns archivedName: null", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "regular-repo", mirror: false, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
mockHttpPatch.mockImplementationOnce(async () => ({
data: { archived: true },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const result = await archiveGiteaRepo(client, "owner", "regular-repo");
expect(result).toEqual({ archivedName: null });
expect(mockHttpPatch).toHaveBeenCalledTimes(1);
expect(mockHttpPatch.mock.calls[0][1]).toMatchObject({ archived: true });
});
test("rename PATCH failure (primary and timestamped fallback both fail) returns archivedName: null", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "my-repo", mirror: true, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
mockHttpPatch.mockImplementation(async () => {
throw new MockHttpError("Unprocessable Entity", 422, "Unprocessable Entity");
});
const result = await archiveGiteaRepo(client, "owner", "my-repo");
expect(result).toEqual({ archivedName: null });
// Primary rename attempt + timestamped fallback attempt, no interval-disable call
expect(mockHttpPatch).toHaveBeenCalledTimes(2);
});
test("mirror repo rename recovers via timestamped fallback after a primary conflict", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "my-repo", mirror: true, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
let callCount = 0;
mockHttpPatch.mockImplementation(async (url: string, body?: any) => {
callCount++;
if (callCount === 1) {
// Primary rename attempt fails (e.g. AlphaDashDot conflict)
throw new MockHttpError("conflict", 422, "Unprocessable Entity");
}
// Fallback rename attempt and the interval-disable PATCH both succeed
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
});
const result = await archiveGiteaRepo(client, "owner", "my-repo");
expect(result.archivedName).toMatch(/^archived-\d{14}-my-repo$/);
expect(mockHttpPatch).toHaveBeenCalledTimes(3);
});
test("repository not found in Gitea returns archivedName: null", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: null,
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const result = await archiveGiteaRepo(client, "owner", "missing-repo");
expect(result).toEqual({ archivedName: null });
expect(mockHttpPatch).not.toHaveBeenCalled();
});
});
+380 -5
View File
@@ -19,15 +19,19 @@ const mockCreatePreSyncBundleBackup = mock(() =>
let mockShouldCreatePreSyncBackup = false;
let mockShouldBlockSyncOnBackupFailure = true;
// Mock the database module
// Mock the database module. Every db.update(...).set(payload) is captured in
// dbUpdateSetCalls so tests can assert on what got written (e.g. archived
// repos keeping status "archived" after a Manual Sync).
const dbUpdateSetCalls: any[] = [];
const mockDb = {
insert: mock((table: any) => ({
values: mock((data: any) => Promise.resolve({ insertedId: "mock-id" }))
})),
update: mock(() => ({
set: mock(() => ({
where: mock(() => Promise.resolve())
}))
set: mock((data: any) => {
dbUpdateSetCalls.push(data);
return { where: mock(() => Promise.resolve()) };
})
}))
};
@@ -173,10 +177,74 @@ const mockHttpGet = mock(async (url: string, headers?: any) => {
headers: new Headers(),
};
}
// Only reachable at the "archived-{name}" candidate — the base name
// ("starred/broken-repo") deliberately falls through to the generic 404
// below, simulating a repo that archiveGiteaRepo already renamed in Gitea.
// original_url matches the test repository's GitHub source, so the
// fallback candidate's source-identity guard accepts it.
if (url.includes("/api/v1/repos/starred/archived-broken-repo")) {
return {
data: {
id: 792,
name: "archived-broken-repo",
mirror: true,
owner: { login: "starred" },
mirror_interval: "0h",
original_url: "https://github.com/user/broken-repo.git",
private: false,
},
status: 200,
statusText: "OK",
headers: new Headers(),
};
}
// Collision scenario: this archived mirror belongs to a DIFFERENT GitHub
// source (otheruser/collide-repo) that happens to share the base name with
// the test repository (user/collide-repo). The base name
// ("starred/collide-repo") falls through to the generic 404 below, so the
// archived-{name} fallback candidate is the only match — and its
// original_url must cause the source-identity guard to reject it.
if (url.includes("/api/v1/repos/starred/archived-collide-repo")) {
return {
data: {
id: 793,
name: "archived-collide-repo",
mirror: true,
owner: { login: "starred" },
mirror_interval: "0h",
original_url: "https://github.com/otheruser/collide-repo.git",
private: false,
},
status: 200,
statusText: "OK",
headers: new Headers(),
};
}
// Simulates Forgejo silently following a 301 redirect for a renamed repo:
// a GET for the STALE (pre-rename) path returns 200 with the repo's
// CURRENT identity in the response body (name differs from what was
// requested), exactly as Bun's fetch behaves after following Forgejo's
// redirect for a repo renamed from "renamed-repo" to
// "archived-renamed-repo". See #331 follow-up / canonical-identity
// adoption in syncGiteaRepoEnhanced.
if (url.includes("/api/v1/repos/starred/renamed-repo")) {
return {
data: {
id: 891,
name: "archived-renamed-repo",
mirror: true,
owner: { login: "starred" },
private: false,
},
status: 200,
statusText: "OK",
headers: new Headers(),
};
}
if (url.includes("/api/v1/repos/")) {
throw new MockHttpError("Not Found", 404, "Not Found");
}
// Handle org GET requests based on test context
if (url.includes("/api/v1/orgs/starred")) {
orgCheckCount++;
@@ -239,10 +307,18 @@ const mockHttpDelete = mock(async (url: string, headers?: any) => {
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
});
// Observable so tests can assert that the mirror-interval PATCH is (not)
// issued — e.g. archived repos must never have Gitea's periodic pulling
// re-enabled by a Manual Sync.
const mockHttpPatch = mock(async (url: string, body?: any, headers?: any) => {
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
});
mock.module("@/lib/http-client", () => ({
httpGet: mockHttpGet,
httpPost: mockHttpPost,
httpDelete: mockHttpDelete,
httpPatch: mockHttpPatch,
HttpError: MockHttpError
}));
@@ -284,6 +360,8 @@ describe("Enhanced Gitea Operations", () => {
mockHttpGet.mockClear();
mockHttpPost.mockClear();
mockHttpDelete.mockClear();
mockHttpPatch.mockClear();
dbUpdateSetCalls.length = 0;
mockCreatePreSyncBundleBackup.mockClear();
mockCreatePreSyncBundleBackup.mockImplementation(() =>
Promise.resolve({ bundlePath: "/tmp/mock.bundle" })
@@ -612,6 +690,303 @@ describe("Enhanced Gitea Operations", () => {
expect(String(mirrorSyncCalls[0][0])).not.toContain("/api/v1/repos/ceph/test-repo/mirror-sync");
});
test("falls back to the archived-{name} candidate when repository.status is 'archived'", async () => {
// Regression for #331 follow-up: repos archived before mirroredLocation
// was backfilled on rename (or any lingering false-positive orphan hit)
// are unreachable by name/expected-owner alone once archiveGiteaRepo has
// renamed them in Gitea to `archived-{sanitized name}`. syncGiteaRepoEnhanced
// must still find them via "Manual Sync" without manual intervention.
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
},
};
const repository: Repository = {
id: "repoArchived1",
name: "broken-repo",
fullName: "user/broken-repo",
owner: "user",
cloneUrl: "https://github.com/user/broken-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("archived"),
visibility: "public",
userId: "user123",
// No mirroredLocation recorded — this repo predates the DB backfill
// added alongside archiveGiteaRepo's new return value.
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(result).toEqual({ success: true });
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(1);
expect(String(mirrorSyncCalls[0][0])).toContain(
"/api/v1/repos/starred/archived-broken-repo/mirror-sync"
);
// The base (pre-archive) name must have been probed and rejected
// (404) before falling back to the archived-{name} candidate.
const repoInfoGets = mockHttpGet.mock.calls.filter((call) =>
String(call[0]).includes("/api/v1/repos/starred/")
);
expect(
repoInfoGets.some((call) =>
String(call[0]).endsWith("/api/v1/repos/starred/broken-repo")
)
).toBe(true);
expect(
repoInfoGets.some((call) =>
String(call[0]).endsWith("/api/v1/repos/starred/archived-broken-repo")
)
).toBe(true);
});
test("adopts canonical identity from response body when GET follows a stale-name redirect", async () => {
// Regression for #331 follow-up, verified end-to-end on Forgejo 15.0.3:
// when a repo has been renamed (e.g. by the orphan-archive flow, or
// manually by a user), Forgejo answers a GET for the OLD name with a
// 301 redirect to the new name. Bun's fetch follows it silently and
// returns 200 with the repo's CURRENT data in the body, while the
// code still has the stale name it requested. If syncGiteaRepoEnhanced
// kept using the requested (stale) name for the follow-up POST
// .../mirror-sync, that POST would hit the same 301, get its method
// downgraded to GET per the WHATWG redirect spec, and the POST-only
// endpoint would return 405. This must work for non-archived repos
// too — a user renaming a repo in Forgejo manually is the general case.
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
},
};
const repository: Repository = {
id: "repoRenamed1",
name: "renamed-repo",
fullName: "user/renamed-repo",
owner: "user",
cloneUrl: "https://github.com/user/renamed-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("mirrored"),
visibility: "public",
userId: "user123",
// Stale: recorded before the rename happened in Gitea/Forgejo.
mirroredLocation: "starred/renamed-repo",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(result).toEqual({ success: true });
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(1);
expect(String(mirrorSyncCalls[0][0])).toContain(
"/api/v1/repos/starred/archived-renamed-repo/mirror-sync"
);
expect(String(mirrorSyncCalls[0][0])).not.toContain(
"/api/v1/repos/starred/renamed-repo/mirror-sync"
);
});
test("keeps archived repos archived and skips the mirror-interval PATCH on Manual Sync", async () => {
// Documented contract (AutomationSettings.tsx): "Archive renames mirror
// backups with an archived- prefix and disables automatic syncs—use
// Manual Sync when you want to refresh." A successful Manual Sync of an
// archived repo must therefore refresh once WITHOUT (a) flipping status
// to "synced" (which would re-enroll it into the scheduler's auto-sync
// pool), (b) clearing the archived errorMessage annotation, or
// (c) PATCHing the mirror interval (which would re-enable Forgejo's own
// periodic pulling that archiveGiteaRepo disabled).
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
// Would normally trigger the mirror-interval PATCH on every sync.
mirrorInterval: "8h",
},
};
const repository: Repository = {
id: "repoArchived2",
name: "broken-repo",
fullName: "user/broken-repo",
owner: "user",
url: "https://github.com/user/broken-repo",
cloneUrl: "https://github.com/user/broken-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("archived"),
isArchived: true,
visibility: "public",
userId: "user123",
errorMessage: "Repository archived - no longer in GitHub",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(result).toEqual({ success: true });
// The mirror-sync itself must still happen (that's the point of
// Manual Sync on an archived repo).
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(1);
expect(String(mirrorSyncCalls[0][0])).toContain(
"/api/v1/repos/starred/archived-broken-repo/mirror-sync"
);
// No mirror-interval PATCH despite config.giteaConfig.mirrorInterval.
expect(mockHttpPatch).not.toHaveBeenCalled();
// The success-path DB update (the one recording lastMirrored) keeps
// status "archived" and does not clear errorMessage.
const successUpdate = dbUpdateSetCalls.find((data) => "lastMirrored" in data);
expect(successUpdate).toBeDefined();
expect(successUpdate.status).toBe("archived");
expect("errorMessage" in successUpdate).toBe(false);
expect(successUpdate.mirroredLocation).toBe("starred/archived-broken-repo");
});
test("rejects an archived-{name} fallback candidate whose original_url points at a different source", async () => {
// Two sources sharing a base name: the user mirrors user/collide-repo,
// but starred/archived-collide-repo in Gitea is the archived mirror of
// otheruser/collide-repo. The guessed archived-{name} fallback must be
// rejected via its original_url instead of syncing (and rewriting the
// DB row of) the wrong repository. With every candidate exhausted, the
// sync fails with the not-found error.
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
},
};
const repository: Repository = {
id: "repoCollide1",
name: "collide-repo",
fullName: "user/collide-repo",
owner: "user",
url: "https://github.com/user/collide-repo",
cloneUrl: "https://github.com/user/collide-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("archived"),
isArchived: true,
visibility: "public",
userId: "user123",
// No mirroredLocation — forces reliance on the guessed fallback.
createdAt: new Date(),
updatedAt: new Date(),
};
await expect(
syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
)
).rejects.toThrow("Repository collide-repo not found in Gitea. Tried locations:");
// The wrong repo must never receive a mirror-sync POST.
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(0);
// The fallback candidate WAS probed (and then rejected by the guard).
expect(
mockHttpGet.mock.calls.some((call) =>
String(call[0]).endsWith("/api/v1/repos/starred/archived-collide-repo")
)
).toBe(true);
});
test("blocks sync when pre-sync snapshot fails and blocking is enabled", async () => {
mockShouldCreatePreSyncBackup = true;
mockShouldBlockSyncOnBackupFailure = true;
+99 -6
View File
@@ -26,6 +26,7 @@ import {
strategyNeedsDetection,
} from "./repo-backup";
import { detectForcePush } from "./utils/force-push-detection";
import { sanitizeRepoNameAlphaDashDot } from "./gitea";
import {
parseRepositoryMetadataState,
serializeRepositoryMetadataState,
@@ -61,6 +62,27 @@ export interface GiteaRepoInfo {
interface SyncTargetCandidate {
owner: string;
repoName: string;
/**
* True for the guessed `archived-{name}` fallback candidate (see
* syncGiteaRepoEnhanced). Unlike the recorded mirroredLocation or the
* expected-owner candidate, this one is derived purely from the repo NAME,
* so it can collide with a different source repo that shares the same base
* name — it must pass an original_url source check before being accepted.
*/
isArchivedFallback?: boolean;
}
/**
* Normalize a git remote URL for source-identity comparison: lowercase,
* strip trailing slashes and a trailing `.git`.
*/
function normalizeSourceUrl(url: string): string {
return url
.trim()
.toLowerCase()
.replace(/\/+$/, "")
.replace(/\.git$/, "")
.replace(/\/+$/, "");
}
function parseMirroredLocation(location?: string | null): SyncTargetCandidate | null {
@@ -329,12 +351,32 @@ export async function syncGiteaRepoEnhanced({
// Resolve sync target in a backward-compatible order:
// 1) recorded mirroredLocation (actual historical mirror location)
// 2) owner derived from current strategy/config
// 3) (archived repos only) the `archived-{name}` rename that
// archiveGiteaRepo applies to mirror repos (see #331 follow-up).
// Repos archived before mirroredLocation was backfilled on rename, or
// any lingering false-positive orphan hit, would otherwise be
// unreachable from "Manual Sync" — the UI's documented way to refresh
// an archived mirror — because the recorded/expected name no longer
// exists and Gitea returns HTTP 405 for it.
const dependencies = deps ?? (await import("./gitea"));
const expectedOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
const recordedTarget = parseMirroredLocation(repository.mirroredLocation);
// Archived state can live in either field: the cleanup service sets both,
// but a failed retry can clobber `status` while `isArchived` survives.
const isArchivedRepo =
repository.status === "archived" || !!repository.isArchived;
const candidateTargets = dedupeSyncTargets([
...(recordedTarget ? [recordedTarget] : []),
{ owner: expectedOwner, repoName: repository.name },
...(isArchivedRepo
? [
{
owner: expectedOwner,
repoName: `archived-${sanitizeRepoNameAlphaDashDot(repository.name)}`,
isArchivedFallback: true,
},
]
: []),
]);
let repoOwner = expectedOwner;
@@ -360,8 +402,45 @@ export async function syncGiteaRepoEnhanced({
continue;
}
repoOwner = target.owner;
repoName = target.repoName;
// The archived-{name} fallback candidate is guessed from the repo NAME
// alone, so it can hit a DIFFERENT source's archived mirror when two
// sources share a base name (e.g. foo/tools and bar/tools both mirrored;
// foo's archived as `archived-tools`). Before accepting it, verify the
// candidate's original_url (the authoritative migration source — see
// GiteaRepoInfo) points at THIS repository's GitHub source. An empty/
// unset original_url is accepted as before (some migrations leave it
// unset). This guard deliberately does NOT apply to the recorded
// mirroredLocation or expected-name candidates.
if (
target.isArchivedFallback &&
typeof candidateInfo.original_url === "string" &&
candidateInfo.original_url.trim() !== ""
) {
const candidateSource = normalizeSourceUrl(candidateInfo.original_url);
const ownSources = [repository.cloneUrl, repository.url]
.filter((u): u is string => typeof u === "string" && u.trim() !== "")
.map(normalizeSourceUrl);
if (!ownSources.includes(candidateSource)) {
console.warn(
`[Sync] Skipping archived-name candidate ${target.owner}/${target.repoName} for ${repository.name}: its original_url (${candidateInfo.original_url}) points at a different source`
);
continue;
}
}
// Adopt the canonical identity from the response, not the requested target.
// Gitea/Forgejo answer GETs for a renamed repo's old name with a 301 that
// fetch follows silently, so `target` may be a stale pre-rename name; a
// follow-up POST (mirror-sync) to the stale path gets its method downgraded
// by the redirect and fails with 405. The response body always carries the
// repo's current name/owner (#331 follow-up, verified on Forgejo 15).
const canonicalOwner =
typeof candidateInfo.owner === "string"
? candidateInfo.owner
: candidateInfo.owner?.login;
repoOwner = canonicalOwner || target.owner;
repoName = candidateInfo.name || target.repoName;
repoInfo = candidateInfo;
break;
}
@@ -621,7 +700,13 @@ export async function syncGiteaRepoEnhanced({
// NOTE: Gitea/Forgejo's PATCH /repos/{owner}/{repo} API does not support
// updating mirror credentials (mirror_username/mirror_password). Repos that
// were originally migrated without credentials must be deleted and re-mirrored.
if (config.giteaConfig?.mirrorInterval) {
//
// Skipped for archived repos: archiveGiteaRepo deliberately disabled
// Gitea's own periodic pulling, and the documented contract
// (AutomationSettings.tsx: "Archive ... disables automatic syncs—use
// Manual Sync when you want to refresh") says a Manual Sync refreshes
// once without re-enabling any automatic syncing.
if (config.giteaConfig?.mirrorInterval && !isArchivedRepo) {
try {
console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}`;
@@ -844,14 +929,22 @@ export async function syncGiteaRepoEnhanced({
metadataState.lastSyncedAt = new Date().toISOString();
}
// Mark repo as "synced" in DB
// Mark repo as "synced" in DB — unless it's archived. The documented
// contract (AutomationSettings.tsx: "Archive ... disables automatic
// syncs—use Manual Sync when you want to refresh") means a Manual Sync
// of an archived repo must NOT re-enroll it into the scheduler's
// auto-sync pool (which selects mirrored/synced/failed/pending) nor
// clear the archived errorMessage annotation shown in the UI; it still
// records lastMirrored and the (possibly corrected) mirroredLocation.
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("synced"),
status: isArchivedRepo
? repoStatusEnum.parse("archived")
: repoStatusEnum.parse("synced"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
...(isArchivedRepo ? {} : { errorMessage: null }),
mirroredLocation: `${repoOwner}/${repoName}`,
metadata: metadataUpdated
? serializeRepositoryMetadataState(metadataState)
+44 -18
View File
@@ -2986,6 +2986,14 @@ export async function mirrorGitHubReleasesToGitea({
const repoOwner = giteaOwner || (await getGiteaRepoOwnerAsync({ config, repository }));
const repoName = giteaRepoName || repository.name;
// Derive GITHUB coordinates from fullName, matching the issues/PRs/labels/
// milestones mirror functions (`const [owner, repo] = repository.fullName.split("/")`).
// repository.name/owner can drift from the GitHub source (e.g. Gitea-side
// renames), so fullName is authoritative; fall back only if it's malformed.
const [fullNameOwner, fullNameRepo] = (repository.fullName || "").split("/");
const githubOwner = fullNameOwner && fullNameRepo ? fullNameOwner : repository.owner;
const githubRepo = fullNameOwner && fullNameRepo ? fullNameRepo : repository.name;
// Verify the repository exists in Gitea before attempting to mirror releases
console.log(`[Releases] Verifying repository ${repoName} exists at ${repoOwner}`);
const repoExists = await isRepoPresentInGitea({
@@ -3011,8 +3019,8 @@ export async function mirrorGitHubReleasesToGitea({
while (releases.length < releaseLimit) {
const response = await octokit.rest.repos.listReleases({
owner: repository.owner,
repo: repository.name,
owner: githubOwner,
repo: githubRepo,
per_page: perPage,
page,
});
@@ -4044,29 +4052,43 @@ export async function deleteGiteaRepo(
}
}
/**
* Sanitize a repository name to satisfy Gitea's AlphaDashDot rule for repo
* names (letters, digits, `.`, and `-`; no leading/trailing separators).
*
* Extracted from archiveGiteaRepo (module-level, exported) so gitea-enhanced.ts
* can build the identical `archived-{name}` candidate when probing for a repo
* that was renamed by this exact archive flow — either just now, or by an
* older version of this code before mirroredLocation was backfilled on rename
* (see #331 follow-up).
*/
export function sanitizeRepoNameAlphaDashDot(name: string): string {
// Replace anything that's not [A-Za-z0-9.-] with '-'
const base = name.replace(/[^A-Za-z0-9.-]+/g, "-").replace(/-+/g, "-");
// Trim leading/trailing separators and dots for safety
return base.replace(/^[.-]+/, "").replace(/[.-]+$/, "");
}
/**
* Archive a repository in Gitea
*
*
* IMPORTANT: This function NEVER deletes data. It only marks repositories as archived.
* - For regular repos: Uses Gitea's archive feature (makes read-only)
* - For mirror repos: Renames with [ARCHIVED] prefix (Gitea doesn't allow archiving mirrors)
*
*
* This ensures backups are preserved even when the GitHub source disappears.
*
* Returns the Gitea-side name the repository ended up with after a rename
* (mirror path), or `null` when no rename occurred (regular-repo archive
* path, or any failure). Callers that persist `mirroredLocation` should only
* update it when `archivedName` is non-null.
*/
export async function archiveGiteaRepo(
client: { url: string; token: string },
owner: string,
repo: string
): Promise<void> {
): Promise<{ archivedName: string | null }> {
try {
// Helper: sanitize to Gitea's AlphaDashDot rule
const sanitizeRepoNameAlphaDashDot = (name: string): string => {
// Replace anything that's not [A-Za-z0-9.-] with '-'
const base = name.replace(/[^A-Za-z0-9.-]+/g, "-").replace(/-+/g, "-");
// Trim leading/trailing separators and dots for safety
return base.replace(/^[.-]+/, "").replace(/[.-]+$/, "");
};
// First, check if this is a mirror repository
const repoResponse = await httpGet(
`${client.url}/api/v1/repos/${owner}/${repo}`,
@@ -4077,7 +4099,7 @@ export async function archiveGiteaRepo(
if (!repoResponse.data) {
console.warn(`[Archive] Repository ${owner}/${repo} not found in Gitea. Skipping.`);
return;
return { archivedName: null };
}
if (repoResponse.data?.mirror) {
@@ -4099,7 +4121,7 @@ export async function archiveGiteaRepo(
normalizedName.startsWith('archived-')
) {
console.log(`[Archive] Repository ${owner}/${repo} already marked as archived. Skipping.`);
return;
return { archivedName: currentName };
}
// Use a safe prefix and sanitize the name to satisfy AlphaDashDot rule
@@ -4144,7 +4166,7 @@ export async function archiveGiteaRepo(
// If this also fails, log but don't throw - data remains preserved
console.error(`[Archive] Failed to rename mirror repository ${owner}/${repo}:`, e2);
console.log(`[Archive] Repository ${owner}/${repo} remains accessible but not marked as archived`);
return;
return { archivedName: null };
}
}
@@ -4168,6 +4190,8 @@ export async function archiveGiteaRepo(
// Non-critical - repo is still preserved even if we can't change interval
console.debug(`[Archive] Could not disable mirror interval (non-critical):`, intervalError);
}
return { archivedName };
} else {
// For non-mirror repositories, use Gitea's native archive feature
// This makes the repository read-only but preserves all data
@@ -4188,10 +4212,11 @@ export async function archiveGiteaRepo(
// If archive fails, log but data is still preserved in Gitea
console.error(`[Archive] Failed to archive repository ${owner}/${repo}: ${response.status}`);
console.log(`[Archive] Repository ${owner}/${repo} remains accessible but not marked as archived`);
return;
return { archivedName: null };
}
console.log(`[Archive] Successfully archived repository ${owner}/${repo} (now read-only)`);
return { archivedName: null };
}
} catch (error) {
// Even on error, the repository data is preserved in Gitea
@@ -4199,5 +4224,6 @@ export async function archiveGiteaRepo(
console.error(`[Archive] Could not mark repository ${owner}/${repo} as archived:`, error);
console.log(`[Archive] Repository ${owner}/${repo} data is preserved but not marked as archived`);
// Don't throw - we want cleanup to continue for other repos
return { archivedName: null };
}
}
@@ -0,0 +1,52 @@
/**
* Unit tests for the pure orphan-verdict decision logic regression coverage
* for issue #331's root cause: `identifyOrphanedRepositories()` treated a DB
* repository as "orphaned" the moment it was missing from a single bulk
* GitHub fetch (owned+collaborator+org repos, plus starred repos). That bulk
* fetch can be transiently incomplete (rate-limit timing, GraphQL star-list
* pagination quirks, org-allowlist edge cases, etc.), producing false
* positives that got archived (renamed to `archived-{name}` in Gitea/Forgejo)
* even though the repo was never actually removed/unstarred on GitHub.
*
* The fix adds a second, targeted confirmation call for any repo that merely
* *looks* orphaned from the bulk list before finalizing it as such.
* `resolveOrphanVerdict` is the pure decision function extracted from that
* flow (similar in spirit to classifyAssetsForReconciliation /
* classifyReleasesForReconciliation in gitea-releases.test.ts) so the
* decision logic itself is unit-testable without hitting the DB or octokit.
*/
import { describe, test, expect } from "bun:test";
import { resolveOrphanVerdict } from "./repository-cleanup-service";
describe("resolveOrphanVerdict", () => {
test("repo present in the bulk fetch is never orphaned, regardless of the direct check", () => {
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: true, directCheckConfirmsGone: true })
).toBe(false);
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: true, directCheckConfirmsGone: false })
).toBe(false);
});
test("repo missing from the bulk fetch is orphaned only when the direct check confirms it's gone (404)", () => {
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: false, directCheckConfirmsGone: true })
).toBe(true);
});
test("repo missing from the bulk fetch but still found by the direct check is NOT orphaned (bulk fetch was incomplete)", () => {
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: false, directCheckConfirmsGone: false })
).toBe(false);
});
test("repo missing from the bulk fetch whose direct check itself failed (network error, rate limit, etc.) fails safe as NOT orphaned", () => {
// directCheckConfirmsGone is only true for a clean, explicit 404 — any
// other outcome (including a failed verification call) is represented
// as false by the caller, which must resolve to "not orphaned" here.
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: false, directCheckConfirmsGone: false })
).toBe(false);
});
});
+143 -9
View File
@@ -15,6 +15,37 @@ import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
let cleanupInterval: NodeJS.Timeout | null = null;
let isCleanupRunning = false;
/**
* Decide whether a DB repository that appears to be missing from the bulk
* GitHub fetch should actually be treated as orphaned.
*
* The bulk fetch (owned+collaborator+org repos, plus starred repos) that
* feeds `fullNameFoundInBulkList` can be transiently incomplete rate-limit
* timing, GraphQL star-list pagination quirks, org-allowlist edge cases,
* etc. so a repo missing from it is only a *candidate*, not a confirmed
* orphan. It is only orphaned when a direct, targeted GitHub call ALSO
* confirms the repo is gone (a clean 404). Any other outcome the repo
* still exists, or the direct check itself failed for some other reason
* (network error, rate limit, 5xx, timeout) must NOT be treated as
* orphaned; this fails safe and matches the existing fail-safe philosophy
* already in this module for GitHub API errors.
*
* Pure/exported so the decision logic is unit-testable without hitting the
* DB or octokit (see repository-cleanup-service.test.ts).
*/
export function resolveOrphanVerdict({
fullNameFoundInBulkList,
directCheckConfirmsGone,
}: {
fullNameFoundInBulkList: boolean;
directCheckConfirmsGone: boolean;
}): boolean {
if (fullNameFoundInBulkList) {
return false;
}
return directCheckConfirmsGone;
}
/**
* Identify orphaned repositories for a user
* These are repositories that exist in our database (and likely in Gitea)
@@ -80,8 +111,12 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
.where(eq(repositories.userId, userId));
// Only identify repositories as orphaned if we successfully accessed GitHub
// This prevents false positives when GitHub is down or account is inaccessible
const orphanedRepos = dbRepos.filter(repo => {
// This prevents false positives when GitHub is down or account is inaccessible.
//
// First pass (sync, cheap): filter down to repos that merely *look*
// orphaned based on map membership against the single bulk fetch above.
// This is the false-positive-prone signal — see resolveOrphanVerdict.
const candidateOrphans = dbRepos.filter(repo => {
// Skip repositories we've already archived/preserved
if (repo.status === 'archived' || repo.isArchived) {
console.log(`[Repository Cleanup] Skipping ${repo.fullName} - already archived`);
@@ -97,6 +132,8 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
const githubRepo = githubReposByFullName.get(repo.fullName);
if (!githubRepo) {
// Missing from the bulk list — candidate for direct confirmation below,
// not yet a confirmed orphan.
return true;
}
@@ -107,11 +144,93 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
return false;
});
if (candidateOrphans.length === 0) {
return [];
}
// Second pass (async, targeted): confirm each candidate directly against
// GitHub before finalizing it as orphaned. This only adds extra API calls
// for the (presumably small) set of repos that look orphaned, not for
// every repo, so it shouldn't meaningfully increase rate-limit pressure
// in the common case (few or no orphans per run). Promise.allSettled so
// one repo's verification failure can't block the others.
const verificationOutcomes = await Promise.allSettled(
candidateOrphans.map(async (repo) => {
if (repo.isStarred) {
try {
await octokit.rest.activity.checkRepoIsStarredByAuthenticatedUser({
owner: repo.owner,
repo: repo.name,
});
// Resolves (no throw) => still starred; the bulk star fetch
// missed it. Fail safe: do not treat as orphaned.
return { repo, directCheckConfirmsGone: false };
} catch (starError: any) {
if (starError?.status === 404) {
return { repo, directCheckConfirmsGone: true };
}
console.warn(
`[Repository Cleanup] Direct star-check for ${repo.fullName} failed with a non-404 error; skipping this cycle to be safe: ${
starError instanceof Error ? starError.message : String(starError)
}`
);
return { repo, directCheckConfirmsGone: false };
}
}
try {
await octokit.rest.repos.get({ owner: repo.owner, repo: repo.name });
// Resolves (no throw) => repo still exists; the bulk fetch missed
// it (e.g. an org-allowlist edge case). Fail safe: not orphaned.
return { repo, directCheckConfirmsGone: false };
} catch (repoError: any) {
if (repoError?.status === 404) {
return { repo, directCheckConfirmsGone: true };
}
console.warn(
`[Repository Cleanup] Direct existence check for ${repo.fullName} failed with a non-404 error; skipping this cycle to be safe: ${
repoError instanceof Error ? repoError.message : String(repoError)
}`
);
return { repo, directCheckConfirmsGone: false };
}
})
);
const orphanedRepos = verificationOutcomes
.map((outcome, index) => {
if (outcome.status !== 'fulfilled') {
const repo = candidateOrphans[index];
console.warn(
`[Repository Cleanup] Direct orphan verification threw unexpectedly for ${repo.fullName}; skipping this cycle to be safe: ${
outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)
}`
);
return null;
}
const { repo, directCheckConfirmsGone } = outcome.value;
const isOrphaned = resolveOrphanVerdict({
fullNameFoundInBulkList: false,
directCheckConfirmsGone,
});
if (!isOrphaned) {
return null;
}
console.log(
`[Repository Cleanup] Confirmed orphaned via direct GitHub check: ${repo.fullName}`
);
return repo;
})
.filter((repo): repo is (typeof dbRepos)[number] => repo !== null);
if (orphanedRepos.length > 0) {
console.log(`[Repository Cleanup] Found ${orphanedRepos.length} orphaned repositories for user ${userId}`);
}
return orphanedRepos;
} catch (error) {
console.error(`[Repository Cleanup] Error identifying orphaned repositories for user ${userId}:`, error);
@@ -185,15 +304,30 @@ async function handleOrphanedRepository(
// Non-fatal; continue with best guess
}
await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
// Update database status
await db.update(repositories).set({
const { archivedName } = await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
// Update database status. If the archive call renamed the repo in Gitea
// (mirror path), persist the new location so a subsequent "Manual Sync"
// (the UI's documented path for refreshing an archived mirror) can find
// it by its actual current name instead of the stale pre-rename one —
// otherwise syncGiteaRepoEnhanced looks up a name that no longer exists
// and Gitea returns HTTP 405 ("not a pull mirror").
//
// Only mirroredLocation gets the Gitea-side `archived-{name}` value.
// Do NOT write it into `name`: repositories.name is consumed as the
// GITHUB repo name elsewhere (release listing, force-push detection),
// and mirroredLocation alone is what syncGiteaRepoEnhanced resolves
// first when locating the Gitea mirror.
const dbUpdate: Record<string, any> = {
status: 'archived',
isArchived: true,
errorMessage: 'Repository archived - no longer in GitHub',
updatedAt: new Date(),
}).where(eq(repositories.id, repo.id));
};
if (archivedName && archivedName !== giteaRepoName) {
dbUpdate.mirroredLocation = `${giteaOwner}/${archivedName}`;
}
await db.update(repositories).set(dbUpdate).where(eq(repositories.id, repo.id));
// Create event
await publishEvent({