Compare commits

...

5 Commits

Author SHA1 Message Date
ARUNAVO RAY cc635485f0 feat: surface auto-mirror toggle in automation settings (refs #278) (#282)
The fix in v3.15.8 made scheduleConfig.autoMirror an independent trigger
in the scheduler, but it remained reachable only via the AUTO_MIRROR_REPOS
env var. This adds a UI checkbox under the Automatic Syncing section so
the option can be toggled per-config without touching the environment.

The toggle is conditional on scheduling being enabled (since auto-mirror
without a scheduler is meaningless) and is independent of the existing
"Auto-mirror new starred repositories" toggle in GitHub settings. Together
they cover the full owned/starred matrix that the scheduler already
supports.

Plumbing: config-mapper.ts now round-trips autoMirror through the UI/DB
boundary, and ScheduleConfig in types/config.ts gets the matching field.
No schema or migration change — autoMirror was already in the zod schema.
2026-05-04 10:14:09 +05:30
github-actions[bot] 6f343de5fd chore: sync version to 3.15.8 2026-05-04 03:49:44 +00:00
ARUNAVO RAY a18f262ca7 fix: make autoMirrorStarred actually trigger auto-mirror (fixes #278) (#281)
The "Auto-mirror new starred repositories" checkbox in the GitHub settings
was a filter layered on top of scheduleConfig.autoMirror, which itself is
only settable via the AUTO_MIRROR_REPOS env var (no UI). So users who
checked the box saw their starred repos auto-imported but never mirrored.

Treat autoMirror and autoMirrorStarred as independent triggers in the
scheduler: autoMirror covers owned (and self-starred) repos, autoMirrorStarred
covers repos starred from other owners. Either flag on its own is enough
to enter the auto-mirror phase, and the filter scopes the work accordingly.

Also normalize the owner comparison to lowercase since GitHub usernames are
case-insensitive — previously a self-starred repo whose stored owner casing
differed from the configured owner would be misclassified as a third-party
star.

Behavior change worth flagging in release notes: anyone who currently has
the starred checkbox on (broken state) will start getting starred repos
mirrored on upgrade. AUTO_MIRROR_REPOS=true users see no change.
2026-05-04 09:12:57 +05:30
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
8 changed files with 249 additions and 37 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.15.6",
"version": "3.15.8",
"engines": {
"bun": ">=1.2.9"
},
@@ -269,6 +269,31 @@ export function AutomationSettings({
</div>
</div>
</div>
<div className="flex items-start space-x-3 pt-1">
<Checkbox
id="enable-auto-mirror-new"
checked={scheduleConfig.autoMirror ?? false}
className="mt-1.25"
onCheckedChange={(checked) =>
onScheduleChange({
...scheduleConfig,
autoMirror: !!checked,
})
}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="enable-auto-mirror-new"
className="text-sm font-normal cursor-pointer"
>
Auto-mirror new repositories
</Label>
<p className="text-xs text-muted-foreground">
Automatically mirror newly imported repositories on each scheduled sync. When off, new repos are imported for browsing but require a manual mirror click. (Starred repos have their own toggle in GitHub settings.)
</p>
</div>
</div>
</div>
)}
@@ -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,
+45
View File
@@ -58,6 +58,51 @@ describe("Scheduler Service - Ignored Repository Handling", () => {
expect(shouldMirrorRepository(oldSyncedRepo)).toBe(true);
});
test("auto-mirror filter respects autoMirror and autoMirrorStarred independently", () => {
// Mirrors the inline filter at scheduler-service.ts L228-233 / L609-614:
// a repo is "starred from another owner" iff isStarred && owner !== githubOwner.
// Such repos are gated by autoMirrorStarred; everything else is gated by autoMirror.
const githubOwner = "Alice".toLowerCase();
const filterRepos = (
repos: Array<{ name: string; isStarred: boolean; owner: string }>,
autoMirror: boolean,
autoMirrorStarred: boolean,
) =>
repos.filter(repo => {
const isStarredFromOther = repo.isStarred && repo.owner.toLowerCase() !== githubOwner;
return isStarredFromOther ? autoMirrorStarred : autoMirror;
});
// "ALICE" tests case-insensitive owner match — GitHub usernames are case-insensitive,
// so a self-starred repo stored with different casing must still count as owned.
const repos = [
{ name: "owned-repo", isStarred: false, owner: "alice" },
{ name: "self-starred", isStarred: true, owner: "ALICE" },
{ name: "starred-from-bob", isStarred: true, owner: "bob" },
];
// Both off: nothing mirrors
expect(filterRepos(repos, false, false).map(r => r.name)).toEqual([]);
// Only autoMirror: owned + self-starred, not third-party stars
expect(filterRepos(repos, true, false).map(r => r.name)).toEqual([
"owned-repo",
"self-starred",
]);
// Only autoMirrorStarred: just third-party stars (the bug fix — used to be empty)
expect(filterRepos(repos, false, true).map(r => r.name)).toEqual([
"starred-from-bob",
]);
// Both on: everything
expect(filterRepos(repos, true, true).map(r => r.name)).toEqual([
"owned-repo",
"self-starred",
"starred-from-bob",
]);
});
test("should validate all repository status enum values", () => {
const validStatuses = [
"imported",
+34 -30
View File
@@ -203,10 +203,14 @@ async function runScheduledSync(config: any): Promise<void> {
}
}
// Auto-mirror: Mirror imported/pending/failed repositories if enabled
if (scheduleConfig.autoMirror) {
// Auto-mirror: Mirror imported/pending/failed repositories if enabled.
// autoMirror covers owned repos; autoMirrorStarred covers starred repos from other owners.
// Either flag on its own is enough to enter this phase.
const autoMirrorOwned = !!scheduleConfig.autoMirror;
const autoMirrorStarred = !!config.githubConfig?.autoMirrorStarred;
if (autoMirrorOwned || autoMirrorStarred) {
try {
console.log(`[Scheduler] Auto-mirror enabled - checking for repositories to mirror for user ${userId}...`);
console.log(`[Scheduler] Auto-mirror enabled (owned=${autoMirrorOwned}, starred=${autoMirrorStarred}) - checking for repositories to mirror for user ${userId}...`);
let reposNeedingMirror = await db
.select()
.from(repositories)
@@ -221,17 +225,16 @@ async function runScheduledSync(config: any): Promise<void> {
)
);
// Filter out starred repos from auto-mirror when autoMirrorStarred is disabled
if (!config.githubConfig?.autoMirrorStarred) {
const githubOwner = config.githubConfig?.owner || '';
const beforeCount = reposNeedingMirror.length;
reposNeedingMirror = reposNeedingMirror.filter(
repo => !repo.isStarred || repo.owner === githubOwner
);
const skippedCount = beforeCount - reposNeedingMirror.length;
if (skippedCount > 0) {
console.log(`[Scheduler] Skipped ${skippedCount} starred repositories from auto-mirror (autoMirrorStarred is disabled)`);
}
const githubOwner = (config.githubConfig?.owner || '').toLowerCase();
const beforeCount = reposNeedingMirror.length;
reposNeedingMirror = reposNeedingMirror.filter(repo => {
// GitHub usernames are case-insensitive; lowercase both sides to avoid misclassifying self-starred repos.
const isStarredFromOther = repo.isStarred && repo.owner.toLowerCase() !== githubOwner;
return isStarredFromOther ? autoMirrorStarred : autoMirrorOwned;
});
const skippedCount = beforeCount - reposNeedingMirror.length;
if (skippedCount > 0) {
console.log(`[Scheduler] Skipped ${skippedCount} repositories from auto-mirror (autoMirror=${autoMirrorOwned}, autoMirrorStarred=${autoMirrorStarred})`);
}
if (reposNeedingMirror.length > 0) {
@@ -574,10 +577,12 @@ async function performInitialAutoStart(): Promise<void> {
continue;
}
// Step 2: Trigger mirror for all repositories that need mirroring
// Only auto-mirror if autoMirror is enabled in schedule config
if (!config.scheduleConfig?.autoMirror) {
console.log(`[Scheduler] Step 2: Skipping initial mirror - autoMirror is disabled for user ${config.userId}`);
// Step 2: Trigger mirror for all repositories that need mirroring.
// autoMirror covers owned repos; autoMirrorStarred covers starred repos from other owners.
const autoMirrorOwned = !!config.scheduleConfig?.autoMirror;
const autoMirrorStarred = !!config.githubConfig?.autoMirrorStarred;
if (!autoMirrorOwned && !autoMirrorStarred) {
console.log(`[Scheduler] Step 2: Skipping initial mirror - autoMirror and autoMirrorStarred are both disabled for user ${config.userId}`);
// Still update schedule config timestamps
const currentTime2 = new Date();
@@ -587,7 +592,7 @@ async function performInitialAutoStart(): Promise<void> {
continue;
}
console.log(`[Scheduler] Step 2: Triggering mirror for repositories that need mirroring...`);
console.log(`[Scheduler] Step 2: Triggering mirror for repositories that need mirroring (owned=${autoMirrorOwned}, starred=${autoMirrorStarred})...`);
let reposNeedingMirror = await db
.select()
.from(repositories)
@@ -602,17 +607,16 @@ async function performInitialAutoStart(): Promise<void> {
)
);
// Filter out starred repos from auto-mirror when autoMirrorStarred is disabled
if (!config.githubConfig?.autoMirrorStarred) {
const githubOwner = config.githubConfig?.owner || '';
const beforeCount = reposNeedingMirror.length;
reposNeedingMirror = reposNeedingMirror.filter(
repo => !repo.isStarred || repo.owner === githubOwner
);
const skippedCount = beforeCount - reposNeedingMirror.length;
if (skippedCount > 0) {
console.log(`[Scheduler] Skipped ${skippedCount} starred repositories from initial auto-mirror (autoMirrorStarred is disabled)`);
}
const githubOwner = (config.githubConfig?.owner || '').toLowerCase();
const beforeCount = reposNeedingMirror.length;
reposNeedingMirror = reposNeedingMirror.filter(repo => {
// GitHub usernames are case-insensitive; lowercase both sides to avoid misclassifying self-starred repos.
const isStarredFromOther = repo.isStarred && repo.owner.toLowerCase() !== githubOwner;
return isStarredFromOther ? autoMirrorStarred : autoMirrorOwned;
});
const skippedCount = beforeCount - reposNeedingMirror.length;
if (skippedCount > 0) {
console.log(`[Scheduler] Skipped ${skippedCount} repositories from initial auto-mirror (autoMirror=${autoMirrorOwned}, autoMirrorStarred=${autoMirrorStarred})`);
}
if (reposNeedingMirror.length > 0) {
+3
View File
@@ -246,6 +246,7 @@ export function mapUiScheduleToDb(uiSchedule: any, existing?: DbScheduleConfig):
enabled: !!uiSchedule.enabled,
interval: intervalExpression,
timezone,
autoMirror: typeof uiSchedule.autoMirror === "boolean" ? uiSchedule.autoMirror : base.autoMirror,
nextRun: scheduleChanged ? undefined : base.nextRun,
} as DbScheduleConfig;
}
@@ -264,6 +265,7 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
clockFrequencyHours: 24,
startTime: "22:00",
timezone: "UTC",
autoMirror: false,
lastRun: null,
nextRun: null,
};
@@ -296,6 +298,7 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
clockFrequencyHours: parsedClockSchedule?.frequencyHours ?? 24,
startTime: parsedClockSchedule?.startTime ?? "22:00",
timezone: normalizeTimezone(dbSchedule.timezone || "UTC"),
autoMirror: dbSchedule.autoMirror ?? false,
lastRun: dbSchedule.lastRun || null,
nextRun: dbSchedule.nextRun || null,
};
+1
View File
@@ -36,6 +36,7 @@ export interface ScheduleConfig {
clockFrequencyHours?: number;
startTime?: string;
timezone?: string;
autoMirror?: boolean;
lastRun?: Date;
nextRun?: Date;
}