mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-08-21 23:27:43 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc635485f0 | |||
| 6f343de5fd | |||
| a18f262ca7 | |||
| 3798456f5d | |||
| 73f1609117 |
+1
-1
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface ScheduleConfig {
|
||||
clockFrequencyHours?: number;
|
||||
startTime?: string;
|
||||
timezone?: string;
|
||||
autoMirror?: boolean;
|
||||
lastRun?: Date;
|
||||
nextRun?: Date;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user