Compare commits

...

6 Commits

Author SHA1 Message Date
Arunavo Ray 3798456f5d chore: bump version to 3.15.7 2026-05-04 08:20:26 +05:30
ARUNAVO RAY 73f1609117 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.
2026-05-04 08:08:22 +05:30
Arunavo Ray 588567931a chore: bump version to 3.15.6 2026-04-26 13:43:45 +05:30
Arunavo Ray 5c1317c759 feat: warn when Forgejo destination has known mirror-credential bug (refs #263)
Forgejo < 15.0.0 silently discards auth_username/auth_password sent to
/api/v1/repos/migrate, causing subsequent pull-mirror sync of private repos
to fail with `terminal prompts disabled`. Fix landed upstream in Forgejo
v15.0.0 via codeberg.org/forgejo/forgejo/pulls/11909 and was not backported
to v12/v13/v14.

Test-connection endpoint now also probes /api/v1/version, detects Forgejo
via the `+gitea-` suffix, and surfaces a warning Alert in the Gitea config
form when the connected server reports a major version below 15.
2026-04-26 13:43:40 +05:30
Arunavo Ray 5f1c37b320 fix: don't gate dashboard on optional username fields (refs #271, v3.15.5)
The useConfigStatus hook treated `githubConfig.username` and
`giteaConfig.username` as required for the dashboard to render. In
practice neither is required at runtime — the GitHub token is
self-authenticating via listForAuthenticatedUser, and a Gitea username
isn't needed under single-org or flat mirror strategies.

Users who configured via env vars without GITHUB_USERNAME / GITEA_USERNAME
set (or who left those blank in the form, which is only client-side
`required`) ended up with empty strings in their config row. Mirroring
ran fine — tokens alone are sufficient — but the dashboard refused to
fetch and rendered all zeros because useConfigStatus failed the gate.

Drop the username checks from the gate. The `githubOwner` field is still
exported for consumers that want to display an owner; only the gate is
relaxed. Cache-hit and fresh-fetch branches both updated.
2026-04-22 19:21:13 +05:30
Arunavo Ray 083b342f38 ci: bump bun 1.3.10/1.3.12 → 1.3.13 across CI and runtime
CI was on 1.3.10 while the Dockerfile runtime moved to 1.3.12 in v3.15.2,
so we were testing against an older runtime than we shipped. Align both
on 1.3.13 (latest stable). May also resolve the intermittent --coverage
instrumentation flake observed on 1.3.10 against http-client.ts.
2026-04-22 08:39:37 +05:30
10 changed files with 237 additions and 27 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: '1.3.10'
bun-version: '1.3.13'
- name: Check lockfile and install dependencies
run: |
+1 -1
View File
@@ -40,7 +40,7 @@ env:
FAKE_GITHUB_PORT: 4580
GIT_SERVER_PORT: 4590
APP_PORT: 4321
BUN_VERSION: "1.3.10"
BUN_VERSION: "1.3.13"
jobs:
e2e-tests:
+2 -2
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.4
FROM oven/bun:1.3.12-debian AS base
FROM oven/bun:1.3.13-debian AS base
WORKDIR /app
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
python3 make g++ gcc wget sqlite3 openssl ca-certificates \
@@ -49,7 +49,7 @@ RUN git clone --branch "v${GIT_LFS_VERSION}" --depth 1 https://github.com/git-lf
&& install -m 755 /tmp/git-lfs/bin/git-lfs /usr/local/bin/git-lfs
# ----------------------------
FROM oven/bun:1.3.12-debian AS runner
FROM oven/bun:1.3.13-debian AS runner
WORKDIR /app
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
git wget sqlite3 openssl ca-certificates \
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.15.4",
"version": "3.15.7",
"engines": {
"bun": ">=1.2.9"
},
+32 -1
View File
@@ -6,7 +6,9 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { giteaApi } from "@/lib/api";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertTriangle } from "lucide-react";
import { giteaApi, type GiteaServerInfo } from "@/lib/api";
import type { GiteaConfig, MirrorStrategy } from "@/types/config";
import { toast } from "sonner";
import { OrganizationStrategy } from "./OrganizationStrategy";
@@ -23,6 +25,7 @@ interface GiteaConfigFormProps {
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername }: GiteaConfigFormProps) {
const [isLoading, setIsLoading] = useState(false);
const [serverInfo, setServerInfo] = useState<GiteaServerInfo | null>(null);
// Derive the mirror strategy from existing config for backward compatibility
const getMirrorStrategy = (): MirrorStrategy => {
@@ -128,13 +131,16 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
try {
const result = await giteaApi.testConnection(config.url, config.token);
if (result.success) {
setServerInfo(result.serverInfo ?? null);
toast.success("Successfully connected to Gitea!");
} else {
setServerInfo(null);
toast.error(
"Failed to connect to Gitea. Please check your URL and token."
);
}
} catch (error) {
setServerInfo(null);
toast.error(
error instanceof Error ? error.message : "An unknown error occurred"
);
@@ -162,6 +168,31 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
</CardHeader>
<CardContent className="flex flex-col gap-y-6 flex-1">
{serverInfo?.type === "forgejo" && serverInfo.hasMirrorCredBug && (
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
Forgejo {serverInfo.version} has a known mirror-credential bug
</AlertTitle>
<AlertDescription>
<p>
Pull-mirror credentials sent via Forgejo's migrate API aren't persisted on this version, so subsequent syncs of private repos fail with <code className="text-xs font-mono bg-amber-100 dark:bg-amber-900/40 px-1 py-0.5 rounded">terminal prompts disabled</code>. Fixed in Forgejo 15.0.0 (
<a
href="https://codeberg.org/forgejo/forgejo/pulls/11909"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
PR #11909
</a>
).
</p>
<p>
Upgrade Forgejo to 15.0.0 or later, then delete and re-mirror affected repos or open each repo's Settings Mirror Settings in Forgejo and re-enter the GitHub token once.
</p>
</AlertDescription>
</Alert>
)}
<div>
<label
htmlFor="gitea-username"
+14 -10
View File
@@ -65,14 +65,16 @@ export function useConfigStatus(): ConfigStatus {
if (isCacheValid && hasCheckedRef.current) {
const configResponse = configCache.data!;
const isGitHubConfigured = !!(
configResponse?.githubConfig?.username &&
configResponse?.githubConfig?.token
);
// Only token/url are actually required at runtime: the GitHub token is
// self-authenticating for listForAuthenticatedUser, and a Gitea username
// isn't needed under single-org / flat mirror strategies. Users who
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
// (or who otherwise left those blank) were being locked out of the
// dashboard even though mirroring worked fine (see issue #271).
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
const isGiteaConfigured = !!(
configResponse?.giteaConfig?.url &&
configResponse?.giteaConfig?.username &&
configResponse?.giteaConfig?.token
);
@@ -108,14 +110,16 @@ export function useConfigStatus(): ConfigStatus {
userId: user.id
};
const isGitHubConfigured = !!(
configResponse?.githubConfig?.username &&
configResponse?.githubConfig?.token
);
// Only token/url are actually required at runtime: the GitHub token is
// self-authenticating for listForAuthenticatedUser, and a Gitea username
// isn't needed under single-org / flat mirror strategies. Users who
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
// (or who otherwise left those blank) were being locked out of the
// dashboard even though mirroring worked fine (see issue #271).
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
const isGiteaConfigured = !!(
configResponse?.giteaConfig?.url &&
configResponse?.giteaConfig?.username &&
configResponse?.giteaConfig?.token
);
+14 -4
View File
@@ -87,12 +87,22 @@ export const githubApi = {
};
// Gitea API
export interface GiteaServerInfo {
type: "forgejo" | "gitea";
version: string;
raw: string;
hasMirrorCredBug: boolean;
}
export const giteaApi = {
testConnection: (url: string, token: string) =>
apiRequest<{ success: boolean }>("/gitea/test-connection", {
method: "POST",
body: JSON.stringify({ url, token }),
}),
apiRequest<{ success: boolean; serverInfo?: GiteaServerInfo; message?: string }>(
"/gitea/test-connection",
{
method: "POST",
body: JSON.stringify({ url, token }),
}
),
};
// Health API
@@ -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,
+32 -1
View File
@@ -2,6 +2,25 @@ import type { APIRoute } from 'astro';
import { httpGet, HttpError } from '@/lib/http-client';
import { createSecureErrorResponse } from '@/lib/utils';
// Forgejo reports `15.0.0+gitea-1.22.0`; pure Gitea reports just `1.22.0`.
// Forgejo < 15.0.0 has a known bug where pull-mirror credentials sent via
// /api/v1/repos/migrate are not persisted, so subsequent sync of private
// repos fails with `terminal prompts disabled`. Fixed upstream in v15.0.0
// via PR #11909 (codeberg.org/forgejo/forgejo/pulls/11909).
function parseServerInfo(versionString: string) {
const forgejoMatch = versionString.match(/^(\d+)\.(\d+)\.(\d+)\+gitea-/);
if (forgejoMatch) {
const major = Number(forgejoMatch[1]);
return {
type: 'forgejo' as const,
version: `${forgejoMatch[1]}.${forgejoMatch[2]}.${forgejoMatch[3]}`,
raw: versionString,
hasMirrorCredBug: major < 15,
};
}
return { type: 'gitea' as const, version: versionString, raw: versionString, hasMirrorCredBug: false };
}
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
@@ -49,7 +68,18 @@ export const POST: APIRoute = async ({ request }) => {
);
}
// Return success response with user data
let serverInfo: ReturnType<typeof parseServerInfo> | undefined;
try {
const versionResp = await httpGet(`${baseUrl}/api/v1/version`, {
'Accept': 'application/json',
});
if (typeof versionResp.data?.version === 'string') {
serverInfo = parseServerInfo(versionResp.data.version);
}
} catch {
// Version probe is best-effort; older or non-standard servers may not expose it.
}
return new Response(
JSON.stringify({
success: true,
@@ -59,6 +89,7 @@ export const POST: APIRoute = async ({ request }) => {
name: data.full_name,
avatar_url: data.avatar_url,
},
serverInfo,
}),
{
status: 200,