fix: unstick repos in 'mirroring' on transient errors (fixes #268) (#280)

* fix: hoist migrateSucceeded above try so catch can update DB on failure (fixes #268)

`let migrateSucceeded` was declared inside the try block of
mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg, but the catch
block referenced it. Block-scoping made it invisible to catch, so any
error inside the try (network timeout, transient 5xx, etc.) crashed the
catch with `ReferenceError: migrateSucceeded is not defined` before
reaching the DB update that marks the repo "failed". Result: repos
stuck in "mirroring" forever with no entry in the activity log.

Hoisting the declaration above the try restores the intended behavior:
catch updates the repo to failed, clears mirroredLocation when migrate
hadn't succeeded, writes a failed activity-log entry, and re-throws
with the original error message preserved.

TypeScript was flagging this as "Cannot find name 'migrateSucceeded'"
but esbuild stripped the types during build, so the bug shipped.

* test: replace integration test with structural source check (#268)

The behavioral version of this regression test passed locally but
failed in CI because of mock.module pollution between files: bun's
mock.module is process-wide, so my mock for @/lib/gitea-enhanced
leaked into gitea-enhanced.test.ts (its real-module assertions saw
my null-returning mocks), and gitea-enhanced.test.ts's own
@/lib/http-client mock could supersede mine depending on file
discovery order, causing my mirrorGithubRepoToGitea call to not
throw at all in CI.

Replace with a structural assertion that reads gitea.ts and verifies
`let migrateSucceeded` is declared before the outermost try in both
mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg. Verified the
new test fails on the pre-fix source with a clear error message
pointing to issue #268, and passes on the fixed source.
This commit is contained in:
ARUNAVO RAY
2026-05-04 08:08:22 +05:30
committed by GitHub
parent 588567931a
commit 73f1609117
2 changed files with 140 additions and 6 deletions
@@ -0,0 +1,132 @@
/**
* Regression test for issue #268.
*
* `let migrateSucceeded = false;` was declared *inside* the try block
* of mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg, but the
* catch block referenced it. `let` is block-scoped to the try, so any
* error inside try made the catch crash with `ReferenceError:
* migrateSucceeded is not defined` before reaching the DB update that
* marks the repo "failed". Result: repos stuck in "mirroring" forever
* with no entry in the activity log (see issue logs).
*
* This test asserts the declaration is hoisted above the try block in
* both functions. It deliberately reads the source rather than calling
* the functions, because behavioral tests for these functions require
* heavy module mocks that pollute other test files (bun's mock.module
* is process-wide and persists across files).
*/
import { describe, test, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const SOURCE = readFileSync(
join(import.meta.dir, "gitea.ts"),
"utf8"
);
/**
* Locate the body of a function declaration by name. Walks from the
* declaration, balances parens to skip the parameter list (which can
* contain destructured object literals with their own braces), then
* finds the body's opening brace and its matching close.
*/
function extractFunctionBody(source: string, declarationStart: RegExp): string {
const match = source.match(declarationStart);
if (!match) {
throw new Error(`Could not locate declaration ${declarationStart}`);
}
let i = match.index! + match[0].length;
// Skip whitespace until the opening paren of the parameter list.
while (i < source.length && source[i] !== "(") i++;
if (source[i] !== "(") {
throw new Error(`No '(' after ${declarationStart}`);
}
// Balance parens to find the end of the parameter list. Braces inside
// the parameter list (e.g. destructured `{ foo, bar }`) are allowed
// and ignored.
let parenDepth = 0;
for (; i < source.length; i++) {
if (source[i] === "(") parenDepth++;
else if (source[i] === ")") {
parenDepth--;
if (parenDepth === 0) {
i++;
break;
}
}
}
// Skip return-type annotation, => arrow, whitespace, until the body's `{`.
while (i < source.length && source[i] !== "{") i++;
if (source[i] !== "{") {
throw new Error(`No body '{' for ${declarationStart}`);
}
// Balance braces for the body.
let braceDepth = 0;
const startIdx = i;
for (; i < source.length; i++) {
if (source[i] === "{") braceDepth++;
else if (source[i] === "}") {
braceDepth--;
if (braceDepth === 0) {
return source.slice(startIdx, i + 1);
}
}
}
throw new Error(`Unterminated body for ${declarationStart}`);
}
/**
* Confirm that within a function body, the first `let migrateSucceeded`
* declaration occurs BEFORE the function's outermost `try {`.
*
* If the declaration is inside the try block, the catch block can't see
* it (ReferenceError in production = repo stuck mirroring).
*/
function assertMigrateSucceededDeclaredBeforeTry(body: string, label: string) {
const declIdx = body.indexOf("let migrateSucceeded");
expect(declIdx, `${label}: 'let migrateSucceeded' should exist`).toBeGreaterThanOrEqual(0);
// The function's outermost try is the first standalone `try {` in
// the body — assignments and inner try/catches don't share its name.
const tryIdx = body.search(/\btry\s*\{/);
expect(tryIdx, `${label}: outermost 'try {' should exist`).toBeGreaterThanOrEqual(0);
expect(
declIdx,
`${label}: 'let migrateSucceeded' must be declared BEFORE the try block ` +
`so the catch block can read it. If declared inside try, it's block-scoped ` +
`and the catch will throw ReferenceError, leaving repos stuck in 'mirroring'. ` +
`See issue #268.`
).toBeLessThan(tryIdx);
// And it should still be assigned to true after the migrate call —
// otherwise the catch can't tell whether to clear mirroredLocation.
expect(
body.includes("migrateSucceeded = true"),
`${label}: 'migrateSucceeded = true' assignment should exist after the migrate call`
).toBe(true);
// And the catch must read it.
expect(
body.includes("if (!migrateSucceeded)"),
`${label}: catch block should read 'migrateSucceeded' to decide whether to clear mirroredLocation`
).toBe(true);
}
describe("issue #268 — migrateSucceeded scoping regression", () => {
test("mirrorGithubRepoToGitea declares migrateSucceeded outside try", () => {
const body = extractFunctionBody(
SOURCE,
/export const mirrorGithubRepoToGitea = async\b/
);
assertMigrateSucceededDeclaredBeforeTry(body, "mirrorGithubRepoToGitea");
});
test("mirrorGitHubRepoToGiteaOrg declares migrateSucceeded outside try", () => {
const body = extractFunctionBody(
SOURCE,
/export async function mirrorGitHubRepoToGiteaOrg\b/
);
assertMigrateSucceededDeclaredBeforeTry(body, "mirrorGitHubRepoToGiteaOrg");
});
});
+8 -6
View File
@@ -539,6 +539,11 @@ export const mirrorGithubRepoToGitea = async ({
repository: Repository;
config: Partial<Config>;
}): Promise<any> => {
// Declared here (not inside try) so the catch block can read it.
// `let` is block-scoped — declaring inside try makes it inaccessible
// from catch, which previously caused a ReferenceError that swallowed
// the real error and left repos stuck in "mirroring" state.
let migrateSucceeded = false;
try {
if (!config.userId || !config.githubConfig || !config.giteaConfig) {
throw new Error("github config and gitea config are required.");
@@ -837,10 +842,6 @@ export const mirrorGithubRepoToGitea = async ({
);
}
// Track whether the Gitea migrate call succeeded so the catch block
// knows whether to clear mirroredLocation (only safe before migrate succeeds)
let migrateSucceeded = false;
const response = await httpPost(
apiUrl,
migratePayload,
@@ -1321,6 +1322,9 @@ export async function mirrorGitHubRepoToGiteaOrg({
giteaOrgId: number;
orgName: string;
}) {
// Declared here (not inside try) so the catch block can read it.
// See note in mirrorGithubRepoToGitea for the scoping bug this prevents.
let migrateSucceeded = false;
try {
if (
!config.giteaConfig?.url ||
@@ -1528,8 +1532,6 @@ export async function mirrorGitHubRepoToGiteaOrg({
);
}
let migrateSucceeded = false;
const migrateRes = await httpPost(
apiUrl,
migratePayload,