Compare commits

..
Author SHA1 Message Date
Arunavo Ray 7e95dc640b fix(releases): never duplicate release assets on the destination
Gitea and Forgejo accept any number of attachments with the same name:
CreateReleaseAttachment has no name-collision check, so every upload POST
appends another attachment. Asset reconciliation listed what a release
already had and then kept uploading for tens of seconds or minutes with no
mutual exclusion, so two passes over one repository in the same run both
read "missing" and both uploaded. That wide check-to-write window is what
left two, three or five copies of every asset (#417).

The classifier now groups existing attachments by name, keeps one copy and
reports the surplus and stale ones for deletion. Reconciliation fails
closed: nothing is uploaded when the attachment list cannot be read,
deletes run before any upload, and an asset whose stale copy could not be
deleted is skipped until the next sync. Release reconciliation holds a lock
keyed on the destination repository, so two passes cannot interleave even
when they come from two repository rows. The sync path and both mirror
paths claim the row with one conditional UPDATE instead of reading the
status and writing it in a second statement.
2026-09-10 23:12:17 +05:30
19 changed files with 101 additions and 529 deletions

No files matched your search

-9
View File
@@ -42,15 +42,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Comprehensive fix report documentation
### Fixed
- Crash recovery no longer resumes a job that is still running
- A job older than two hours was treated as interrupted even while it checkpointed every two minutes, so recovery started a second pass over the same repositories alongside the original; the age rule is gone and only a missing or stale checkpoint marks a job interrupted
- Every completed item is now recorded in the job's checkpoint instead of one in every N, so a real resume skips exactly what was done; progress events are still throttled
- Concurrent items completing at the same time could overwrite each other's checkpoint; job progress writes are serialized per job
- A resumed job records a fresh start time
- The same upstream repository is no longer imported twice under two sources that point at the same host
- Discovery decided what was new by (source, full name), so an organization pinned to a public-only github.com source and a personal github.com token source each inserted their own row for one repository, and both rows mirrored to the same destination in the same scheduler batch
- Auto import, organization re-discovery and the two import endpoints now identify a repository by its host and full name whichever source lists it; the same name on a different host is still a separate repository
- A scheduler pass mirrors or syncs one row per upstream repository and logs the duplicate rows it skips, so existing duplicates never run side by side
- A per-organization Mirror Destination equal to the organization's own name is kept instead of being dropped (#416)
- The editor treated a typed value matching the organization name as "reset to default" and saved no override, which only holds under the preserve strategy; under single-org the default is the destination organization, so the repositories went there
- The organization card now shows the default for the configured strategy in the preview, the placeholder, the helper text and the reset button, and marks any stored destination as custom
+3 -6
View File
@@ -6,7 +6,7 @@
"name": "gitea-mirror",
"dependencies": {
"@astrojs/mdx": "^7.0.3",
"@astrojs/node": "^11.1.5",
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.1",
"@better-auth/api-key": "1.7.2",
"@better-auth/oauth-provider": "1.7.2",
@@ -94,7 +94,6 @@
"lodash": "^4.18.1",
"picomatch": "^4.0.4",
"sharp": "^0.35.4",
"smol-toml": "^1.7.1",
"svgo": "^4.1.0",
},
"packages": {
@@ -144,7 +143,7 @@
"@astrojs/mdx": ["@astrojs/mdx@7.0.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/markdown-remark": "7.2.1", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.16.0", "es-module-lexer": "^2.0.0", "estree-util-visit": "^2.0.0", "hast-util-to-html": "^9.0.5", "piccolore": "^0.1.3", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", "source-map": "^0.7.6", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@astrojs/markdown-satteri": "^0.3.1", "astro": "^7.0.0" }, "optionalPeers": ["@astrojs/markdown-satteri"] }, "sha512-RxyIwU0uFam5ftwqKOjpIdhnFxZ/kEikeimLyQy3eGXbHT8WgRGzzesOIHVU8+m9TY8ag5WVOyvV24/GyqPdPQ=="],
"@astrojs/node": ["@astrojs/node@11.1.5", "", { "dependencies": { "@astrojs/internal-helpers": "0.11.0", "send": "^1.2.1", "server-destroy": "^1.0.1" }, "peerDependencies": { "astro": "^7.2.1" } }, "sha512-CgA+LmG4UWqHxg/Vdc5MdxjjJNZSjKYQQx4uEUwnbj+1ZD2CYumJI3OWg2twsVdj2FlYsB5yvmCC54Re+OK+UQ=="],
"@astrojs/node": ["@astrojs/node@11.0.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "send": "^1.2.1", "server-destroy": "^1.0.1" }, "peerDependencies": { "astro": "^7.0.0" } }, "sha512-/ijULxT+A5Cm8wSwWZ2vgqfim1b05D6B8n/a9l6MMA4FCotIH73g7fL7y76XojKXpTe75FVvQH92OxsMqea9kQ=="],
"@astrojs/prism": ["@astrojs/prism@4.0.2", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA=="],
@@ -1474,7 +1473,7 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="],
"smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
@@ -1696,8 +1695,6 @@
"@astrojs/compiler-binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
"@astrojs/node/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.11.0", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA=="],
"@astrojs/react/@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"@astrojs/react/vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
+6 -6
View File
@@ -113,9 +113,9 @@
url = "https://registry.npmjs.org/@astrojs/mdx/-/mdx-7.0.3.tgz";
hash = "sha512-RxyIwU0uFam5ftwqKOjpIdhnFxZ/kEikeimLyQy3eGXbHT8WgRGzzesOIHVU8+m9TY8ag5WVOyvV24/GyqPdPQ==";
};
"@astrojs/node@11.1.5" = fetchurl {
url = "https://registry.npmjs.org/@astrojs/node/-/node-11.1.5.tgz";
hash = "sha512-CgA+LmG4UWqHxg/Vdc5MdxjjJNZSjKYQQx4uEUwnbj+1ZD2CYumJI3OWg2twsVdj2FlYsB5yvmCC54Re+OK+UQ==";
"@astrojs/node@11.0.2" = fetchurl {
url = "https://registry.npmjs.org/@astrojs/node/-/node-11.0.2.tgz";
hash = "sha512-/ijULxT+A5Cm8wSwWZ2vgqfim1b05D6B8n/a9l6MMA4FCotIH73g7fL7y76XojKXpTe75FVvQH92OxsMqea9kQ==";
};
"@astrojs/prism@4.0.2" = fetchurl {
url = "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz";
@@ -3205,9 +3205,9 @@
url = "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz";
hash = "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==";
};
"smol-toml@1.8.0" = fetchurl {
url = "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz";
hash = "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==";
"smol-toml@1.6.0" = fetchurl {
url = "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz";
hash = "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==";
};
"sonner@2.0.7" = fetchurl {
url = "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz";
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: gitea-mirror
description: Kubernetes helm chart for gitea-mirror
type: application
version: 3.36.1
appVersion: "3.36.1"
version: 3.35.1
appVersion: "3.35.1"
icon: https://github.com/RayLabsHQ/gitea-mirror/blob/main/.github/assets/logo.png
keywords:
- git
+2 -3
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.36.1",
"version": "3.35.1",
"engines": {
"bun": ">=1.2.9"
},
@@ -51,7 +51,6 @@
"fast-uri": "^3.1.5",
"fast-xml-builder": "^1.1.7",
"js-yaml": "^4.3.2",
"smol-toml": "^1.7.1",
"kysely": "^0.28.17",
"lodash": "^4.18.1",
"picomatch": "^4.0.4",
@@ -60,7 +59,7 @@
},
"dependencies": {
"@astrojs/mdx": "^7.0.3",
"@astrojs/node": "^11.1.5",
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.1",
"@better-auth/api-key": "1.7.2",
"@better-auth/oauth-provider": "1.7.2",
+12 -43
View File
@@ -4,7 +4,6 @@ import { eq, and } from "drizzle-orm";
import { buildInterruptedJobsCondition } from "./interrupted-job-detection";
import { v4 as uuidv4 } from "uuid";
import { publishEvent } from "./events";
import { withKeyedLock } from "./utils/keyed-mutex";
import { triggerJobNotification } from "./notification-service";
export async function createMirrorJob({
@@ -114,34 +113,9 @@ export async function createMirrorJob({
}
/**
* Updates the progress of a mirror job.
*
* Progress is a read-modify-write of the completedItemIds list, and items
* finish concurrently, so the update runs under a per-job lock: two items
* completing in the same tick used to read the same list and one of them
* silently overwrote the other. A resume then re-processed the lost item.
*
* `silent` records the progress without publishing an event. Every completed
* item is checkpointed so a resume knows exactly what is done; the events the
* dashboard listens to are throttled by the caller instead.
* Updates the progress of a mirror job
*/
export async function updateMirrorJobProgress(params: {
jobId: string;
completedItemId?: string;
status?: RepoStatus;
message?: string;
details?: string;
inProgress?: boolean;
isCompleted?: boolean;
/** Record a new start time, as when recovery resumes the job. */
startedAt?: Date;
/** Write the progress without publishing a mirror-status event. */
silent?: boolean;
}) {
return withKeyedLock(`mirror-job:${params.jobId}`, () => updateMirrorJobProgressLocked(params));
}
async function updateMirrorJobProgressLocked({
export async function updateMirrorJobProgress({
jobId,
completedItemId,
status,
@@ -149,9 +123,15 @@ async function updateMirrorJobProgressLocked({
details,
inProgress,
isCompleted,
startedAt,
silent = false,
}: Parameters<typeof updateMirrorJobProgress>[0]) {
}: {
jobId: string;
completedItemId?: string;
status?: RepoStatus;
message?: string;
details?: string;
inProgress?: boolean;
isCompleted?: boolean;
}) {
try {
// Get the current job
const [job] = await db
@@ -177,10 +157,6 @@ async function updateMirrorJobProgressLocked({
}
}
if (startedAt) {
updates.startedAt = startedAt;
}
// Update status if provided
if (status) {
updates.status = status;
@@ -219,10 +195,6 @@ async function updateMirrorJobProgressLocked({
...updates,
};
if (silent) {
return updatedJob;
}
// Create deduplication key for progress updates
let deduplicationKey: string | undefined;
if (completedItemId) {
@@ -352,15 +324,12 @@ export async function resumeInterruptedJob(job: any) {
return null;
}
// Update the job to show it's being resumed. The start time moves to
// now so the row records this pass; the fresh checkpoint written here is
// what keeps a second recovery sweep from resuming the same job again.
// Update the job to show it's being resumed
await updateMirrorJobProgress({
jobId: job.id,
message: `Resuming job with ${remainingItemIds.length} remaining items`,
details: `Job was interrupted and is being resumed. ${job.completedItemIds.length} of ${job.itemIds.length} items were already processed.`,
inProgress: true,
startedAt: new Date(),
});
return {
+5 -30
View File
@@ -18,6 +18,7 @@ import { drizzle } from "drizzle-orm/bun-sqlite";
import { mirrorJobs } from "@/lib/db/schema";
import {
INTERRUPTED_CHECKPOINT_AGE_MS,
STALE_JOB_AGE_MS,
JOB_HEARTBEAT_INTERVAL_MS,
computeInterruptedJobCutoffs,
isJobInterrupted,
@@ -31,15 +32,14 @@ const NOW = new Date("2026-09-02T12:00:00Z");
const ago = (ms: number) => new Date(NOW.getTime() - ms);
describe("computeInterruptedJobCutoffs", () => {
test("keeps the 10 minute checkpoint window and has no stale age cutoff", () => {
test("keeps the 10 minute checkpoint window and 2 hour stale window", () => {
expect(INTERRUPTED_CHECKPOINT_AGE_MS).toBe(10 * MINUTE);
expect(STALE_JOB_AGE_MS).toBe(2 * HOUR);
const cutoffs = computeInterruptedJobCutoffs(NOW);
expect(cutoffs.checkpointCutoff.getTime()).toBe(NOW.getTime() - 10 * MINUTE);
// The two hour "stale regardless of checkpoint" rule resumed live jobs
// and is gone; a checkpointing job is alive however old it is.
expect(Object.keys(cutoffs)).toEqual(["checkpointCutoff"]);
expect(cutoffs.staleCutoff.getTime()).toBe(NOW.getTime() - 2 * HOUR);
});
test("heartbeat interval leaves room for a missed beat inside the checkpoint window", () => {
@@ -78,18 +78,9 @@ describe("isJobInterrupted", () => {
).toBe(true);
});
test("a job that keeps checkpointing stays live however long ago it started", () => {
test("a job running past the stale window is interrupted even with a fresh checkpoint", () => {
expect(
isJobInterrupted({ inProgress: true, startedAt: ago(3 * HOUR), lastCheckpoint: ago(MINUTE) }, NOW)
).toBe(false);
expect(
isJobInterrupted({ inProgress: true, startedAt: ago(30 * HOUR), lastCheckpoint: ago(2 * MINUTE) }, NOW)
).toBe(false);
});
test("an old job whose checkpoint went stale is still interrupted", () => {
expect(
isJobInterrupted({ inProgress: true, startedAt: ago(3 * HOUR), lastCheckpoint: ago(11 * MINUTE) }, NOW)
).toBe(true);
});
@@ -244,22 +235,6 @@ describe("wiring", () => {
).toBe(true);
});
test("resumeInterruptedJob records a fresh start time for the resumed pass", () => {
const resumeBody = HELPERS_SRC.slice(HELPERS_SRC.indexOf("export async function resumeInterruptedJob("));
expect(/inProgress:\s*true,\s*startedAt:\s*new Date\(\)/.test(resumeBody)).toBe(true);
});
test("updateMirrorJobProgress serializes writes per job so concurrent items cannot drop each other", () => {
expect(
/withKeyedLock\(`mirror-job:\$\{params\.jobId\}`,\s*\(\)\s*=>\s*updateMirrorJobProgressLocked\(params\)\)/.test(HELPERS_SRC)
).toBe(true);
});
test("processWithRetry checkpoints every completed item instead of the one that trips the interval", () => {
expect(/await onCheckpoint\(jobId,\s*itemId,\s*publish\)/.test(CONCURRENCY_SRC)).toBe(true);
expect(CONCURRENCY_SRC.includes("onCheckpoint(jobId, 'final')")).toBe(false);
});
test("middleware keeps the recovery latch until the recovery promise settles", () => {
expect(
/recoveryPromise\s*\.catch\(\(\)\s*=>\s*false\)\s*\.finally\(\(\)\s*=>\s*\{\s*recoveryInFlight\s*=\s*false;\s*\}\)/.test(
+15 -15
View File
@@ -2,20 +2,11 @@
* Decides which in-progress mirror jobs count as interrupted and should be
* resumed by recovery (issue #372).
*
* A job is interrupted when either of these holds:
* A job is interrupted when any of these hold:
* - it has a checkpoint older than the checkpoint window, or
* - it has no checkpoint and is older than the checkpoint window (or has
* no recorded start at all, as legacy rows may).
*
* A job that keeps checkpointing is alive, however long ago it started.
* Running jobs refresh their checkpoint on a heartbeat between items, so a
* missing or old checkpoint is the only evidence that the process behind a
* job is gone. There used to be a third rule, "started more than two hours
* ago, whatever the checkpoint says", which resumed large jobs while they
* were still running: the resumed pass then re-processed the same
* repositories alongside the original, which is one way release assets got
* duplicated (#417). A job that hangs without ever finishing an item is a
* different problem and is not what recovery is for.
* no recorded start at all, as legacy rows may), or
* - it started longer ago than the stale window, whatever its checkpoint.
*
* The grace period for jobs without a checkpoint is the point of this
* module. Checkpoints are written when an item completes, so a job that has
@@ -33,6 +24,9 @@ import type { mirrorJobs } from "@/lib/db/schema";
/** A job with no checkpoint newer than this is considered inactive. */
export const INTERRUPTED_CHECKPOINT_AGE_MS = 10 * 60 * 1000;
/** A job that started longer ago than this is considered stale regardless. */
export const STALE_JOB_AGE_MS = 2 * 60 * 60 * 1000;
/**
* How often a running job refreshes its checkpoint between item
* completions. Kept well inside the checkpoint window so one missed beat
@@ -43,11 +37,14 @@ export const JOB_HEARTBEAT_INTERVAL_MS = 2 * 60 * 1000;
export interface InterruptedJobCutoffs {
/** Checkpoints (or starts, for jobs without one) before this are too old. */
checkpointCutoff: Date;
/** Starts before this mark the job stale no matter what. */
staleCutoff: Date;
}
export function computeInterruptedJobCutoffs(now: Date = new Date()): InterruptedJobCutoffs {
return {
checkpointCutoff: new Date(now.getTime() - INTERRUPTED_CHECKPOINT_AGE_MS),
staleCutoff: new Date(now.getTime() - STALE_JOB_AGE_MS),
};
}
@@ -64,9 +61,10 @@ export interface JobLivenessFields {
export function isJobInterrupted(job: JobLivenessFields, now: Date = new Date()): boolean {
if (!job.inProgress) return false;
const { checkpointCutoff } = computeInterruptedJobCutoffs(now);
const { checkpointCutoff, staleCutoff } = computeInterruptedJobCutoffs(now);
if (job.startedAt && job.startedAt < staleCutoff) return true;
// A checkpoint is the job's own proof of life; its age decides on its own.
if (job.lastCheckpoint) return job.lastCheckpoint < checkpointCutoff;
// No checkpoint yet: give the job the same window it would get after a
@@ -84,7 +82,7 @@ export function buildInterruptedJobsCondition(
table: typeof mirrorJobs,
now: Date = new Date(),
): SQL {
const { checkpointCutoff } = computeInterruptedJobCutoffs(now);
const { checkpointCutoff, staleCutoff } = computeInterruptedJobCutoffs(now);
return and(
eq(table.inProgress, true),
@@ -97,6 +95,8 @@ export function buildInterruptedJobsCondition(
),
// Checkpoint present but too old.
lt(table.lastCheckpoint, checkpointCutoff),
// Running far too long, whatever the checkpoint says.
lt(table.startedAt, staleCutoff),
),
)!;
}
-101
View File
@@ -1,101 +0,0 @@
import { describe, test, expect } from 'bun:test';
import {
repositoryIdentityKey,
repositoryIdentityKeys,
selectNewRepositoriesByIdentity,
dedupeRepositoriesByIdentity,
} from './repo-utils';
describe('repositoryIdentityKey', () => {
test('is the host plus the full name, independent of the source row', () => {
const a = repositoryIdentityKey({ sourceProvider: 'github', sourceUrl: 'https://github.com', normalizedFullName: 'acme/tools' });
const b = repositoryIdentityKey({ sourceProvider: 'github', sourceUrl: 'https://github.com/', normalizedFullName: 'ACME/Tools' });
expect(a).toBe(b);
});
test('treats missing provider and url as github.com, like legacy rows', () => {
expect(repositoryIdentityKey({ normalizedFullName: 'acme/tools' })).toBe(
repositoryIdentityKey({ sourceProvider: 'github', sourceUrl: 'https://github.com', normalizedFullName: 'acme/tools' })
);
});
test('keeps the same name on two hosts apart', () => {
const ghe = repositoryIdentityKey({ sourceProvider: 'github', sourceUrl: 'https://ghe.example.com', normalizedFullName: 'acme/tools' });
const com = repositoryIdentityKey({ sourceProvider: 'github', sourceUrl: 'https://github.com', normalizedFullName: 'acme/tools' });
expect(ghe).not.toBe(com);
});
});
describe('selectNewRepositoriesByIdentity', () => {
test('skips a repository already tracked on the same host under another source', () => {
const existing = repositoryIdentityKeys([
{ sourceProvider: 'github', sourceUrl: 'https://github.com', normalizedFullName: 'acme/tools' },
]);
const { fresh, alreadyTracked } = selectNewRepositoriesByIdentity(
[
{ sourceProvider: 'github', sourceUrl: 'https://github.com', normalizedFullName: 'acme/tools', sourceId: 'public-only' },
{ sourceProvider: 'github', sourceUrl: 'https://github.com', normalizedFullName: 'acme/new', sourceId: 'public-only' },
],
existing
);
expect(fresh.map((r) => r.normalizedFullName)).toEqual(['acme/new']);
expect(alreadyTracked.map((r) => r.normalizedFullName)).toEqual(['acme/tools']);
});
test('extends the existing set so a later source does not re-add the same repository', () => {
const existing = new Set<string>();
const first = selectNewRepositoriesByIdentity(
[{ sourceProvider: 'github', sourceUrl: 'https://github.com', normalizedFullName: 'acme/tools' }],
existing
);
const second = selectNewRepositoriesByIdentity(
[{ sourceProvider: 'github', sourceUrl: 'https://github.com', normalizedFullName: 'acme/tools' }],
existing
);
expect(first.fresh).toHaveLength(1);
expect(second.fresh).toHaveLength(0);
expect(second.alreadyTracked).toHaveLength(1);
});
test('still allows the same name from a different host', () => {
const existing = repositoryIdentityKeys([
{ sourceProvider: 'github', sourceUrl: 'https://github.com', normalizedFullName: 'acme/tools' },
]);
const { fresh } = selectNewRepositoriesByIdentity(
[{ sourceProvider: 'gitlab', sourceUrl: 'https://gitlab.com', normalizedFullName: 'acme/tools' }],
existing
);
expect(fresh).toHaveLength(1);
});
});
describe('dedupeRepositoriesByIdentity', () => {
test('keeps one row per upstream repository in a batch', () => {
const { kept, dropped } = dedupeRepositoriesByIdentity([
{ id: 'a', sourceId: 's1', sourceProvider: 'github', sourceUrl: 'https://github.com', normalizedFullName: 'acme/tools' },
{ id: 'b', sourceId: 's2', sourceProvider: 'github', sourceUrl: 'https://github.com', normalizedFullName: 'acme/tools' },
{ id: 'c', sourceId: 's1', sourceProvider: 'github', sourceUrl: 'https://github.com', normalizedFullName: 'acme/other' },
]);
expect(kept.map((r) => r.id)).toEqual(['a', 'c']);
expect(dropped.map((r) => r.id)).toEqual(['b']);
});
test('prefers a row linked to a source over a legacy row without one', () => {
const { kept, dropped } = dedupeRepositoriesByIdentity([
{ id: 'legacy', sourceId: null, normalizedFullName: 'acme/tools' },
{ id: 'linked', sourceId: 's1', sourceProvider: 'github', sourceUrl: 'https://github.com', normalizedFullName: 'acme/tools' },
]);
expect(kept.map((r) => r.id)).toEqual(['linked']);
expect(dropped.map((r) => r.id)).toEqual(['legacy']);
});
test('leaves a batch without duplicates alone', () => {
const rows = [
{ id: 'a', sourceId: 's1', normalizedFullName: 'acme/a' },
{ id: 'b', sourceId: 's1', normalizedFullName: 'acme/b' },
];
const { kept, dropped } = dedupeRepositoriesByIdentity(rows);
expect(kept).toEqual(rows);
expect(dropped).toEqual([]);
});
});
-79
View File
@@ -93,82 +93,3 @@ export function repositoryDestinationColumns(
): Pick<RepoInsert, 'destinationProvider' | 'destinationUrl'> {
return { destinationProvider: destination.provider, destinationUrl: destination.url };
}
/**
* Upstream identity of a repository row: which host it lives on and its full
* name there. The repositories table is unique on (userId, sourceId,
* normalizedFullName) so that the same owner/name can be tracked on two
* hosts, but two source rows can point at the same host (a personal token
* source and a public-only source, both github.com), and a legacy row with a
* NULL sourceId is distinct from a re-imported one. Discovery used the unique
* index alone to decide what was new, so one upstream repository could end up
* as two rows that both mirror to the same destination repository and get
* processed together in one batch (one of the overlaps behind #417).
*/
export interface RepositoryIdentityFields {
sourceProvider?: string | null;
sourceUrl?: string | null;
normalizedFullName: string;
}
export function repositoryIdentityKey(row: RepositoryIdentityFields): string {
const provider = (row.sourceProvider || 'github').trim().toLowerCase();
const url = (row.sourceUrl || 'https://github.com').trim().toLowerCase().replace(/\/+$/, '');
return `${provider}|${url}|${row.normalizedFullName.toLowerCase()}`;
}
/**
* Split discovered rows into the ones not yet tracked for this user on the
* same host under any source, and the ones that are. Also drops repeats
* within `candidates` themselves. `existing` is extended with every fresh
* row, so a caller looping over several sources can pass the same set.
*/
export function selectNewRepositoriesByIdentity<T extends RepositoryIdentityFields>(
candidates: T[],
existing: Set<string>
): { fresh: T[]; alreadyTracked: T[] } {
const fresh: T[] = [];
const alreadyTracked: T[] = [];
for (const candidate of candidates) {
const key = repositoryIdentityKey(candidate);
if (existing.has(key)) {
alreadyTracked.push(candidate);
} else {
existing.add(key);
fresh.push(candidate);
}
}
return { fresh, alreadyTracked };
}
export function repositoryIdentityKeys(rows: Iterable<RepositoryIdentityFields>): Set<string> {
const keys = new Set<string>();
for (const row of rows) keys.add(repositoryIdentityKey(row));
return keys;
}
/**
* Keep one row per upstream identity in a processing batch, so two rows that
* resolve to the same destination repository are never mirrored or synced in
* the same pass. A row linked to a source wins over a legacy row without one;
* otherwise the first row in the batch is kept.
*/
export function dedupeRepositoriesByIdentity<
T extends RepositoryIdentityFields & { sourceId?: string | null },
>(rows: T[]): { kept: T[]; dropped: T[] } {
const byKey = new Map<string, T>();
const dropped: T[] = [];
for (const row of rows) {
const key = repositoryIdentityKey(row);
const current = byKey.get(key);
if (!current) {
byKey.set(key, row);
} else if (!current.sourceId && row.sourceId) {
dropped.push(current);
byKey.set(key, row);
} else {
dropped.push(row);
}
}
return { kept: Array.from(byKey.values()), dropped };
}
+1 -29
View File
@@ -465,34 +465,6 @@ describe.skipIf(!isChild)("Scheduler public-only sources (WP4)", () => {
.filter((row) => row.normalizedFullName?.startsWith("pinned-org/"))
.map((row) => row.name);
expect(orgRepoNamesAfterSecondRun).toEqual(["existing", "newly-published"]);
// Nothing new on the second run, so discovery does not even attempt an
// insert: already-tracked repositories are filtered out by identity
// before the write, not dropped by the unique index afterwards.
expect(repoInsertAttempts).toBe(attemptsAfterFirstRun);
});
test("re-discovery does not add a second row for a repository the user already tracks under another source on the same host", async () => {
// Imported earlier through a personal token source on github.com, so it
// has a different sourceId than the pinned public-only source.
repoRows.push({
userId: "user-1",
sourceId: "source-personal",
name: "shared",
fullName: "pinned-org/shared",
normalizedFullName: "pinned-org/shared",
organization: "pinned-org",
sourceProvider: "github",
sourceUrl: "https://github.com",
status: "mirrored",
});
orgRepoListings.set("pinned-org", [makeGitRepo("shared"), makeGitRepo("fresh")]);
await schedulerLoop();
const sharedRows = repoRows.filter((row) => row.normalizedFullName === "pinned-org/shared");
expect(sharedRows).toHaveLength(1);
expect(sharedRows[0].sourceId).toBe("source-personal");
expect(repoRows.some((row) => row.normalizedFullName === "pinned-org/fresh")).toBe(true);
expect(consoleLines.some((line) => line.includes("already tracked") && line.includes("pinned-org"))).toBe(true);
expect(repoInsertAttempts).toBeGreaterThan(attemptsAfterFirstRun);
});
});
+17 -78
View File
@@ -13,14 +13,7 @@ import { formatDuration } from '@/lib/utils/duration-parser';
import type { Repository } from '@/lib/db/schema';
import type { Octokit } from '@octokit/rest';
import { repoStatusEnum, repositoryVisibilityEnum } from '@/types/Repository';
import {
mergeGitReposPreferStarred,
normalizeGitRepoToInsert,
calcBatchSizeForInsert,
repositoryIdentityKeys,
selectNewRepositoriesByIdentity,
dedupeRepositoriesByIdentity,
} from '@/lib/repo-utils';
import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils';
import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
import { loadOrganizationForkPolicies, orgForkSkipDecision, resolveOrganizationSkipForks } from '@/lib/utils/mirror-overrides';
import { createMirrorJob } from '@/lib/helpers';
@@ -109,19 +102,14 @@ async function importRepositoriesFromSources(
const orgForkOverrides = await loadOrganizationForkPolicies({ userId });
const existingRepos = await db
.select({
normalizedFullName: repositories.normalizedFullName,
sourceProvider: repositories.sourceProvider,
sourceUrl: repositories.sourceUrl,
})
.select({ normalizedFullName: repositories.normalizedFullName, sourceId: repositories.sourceId })
.from(repositories)
.where(eq(repositories.userId, userId));
// A repository is identified by its host and full name, not by the source
// row that found it. Two sources can point at the same host (a personal
// token and a public-only source, both github.com), and a repository the
// user already tracks must not be inserted a second time under the other
// source: both rows would mirror to the same destination repository.
const existingRepoKeys = repositoryIdentityKeys(existingRepos);
// Repositories are unique per source: the same full name under two sources
// counts as two repositories.
const existingRepoKeys = new Set(
existingRepos.map(r => `${r.sourceId ?? ''}|${r.normalizedFullName}`)
);
for (const source of sources) {
try {
@@ -144,21 +132,17 @@ async function importRepositoriesFromSources(
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
const mirrorableGithubRepos = allGithubRepos.filter(isMirrorableGitHubRepo);
const candidates = mirrorableGithubRepos.map(repo =>
normalizeGitRepoToInsert(repo, { userId, configId: config.id, sourceId: source.id })
const newRepos = mirrorableGithubRepos.filter(
r => !existingRepoKeys.has(`${source.id}|${r.fullName.toLowerCase()}`)
);
const { fresh: reposToInsert, alreadyTracked } = selectNewRepositoriesByIdentity(
candidates,
existingRepoKeys
);
const newRepos = reposToInsert;
if (alreadyTracked.length > 0) {
console.log(`[Scheduler] ${alreadyTracked.length} repositories listed by source ${source.name} are already tracked for user ${userId} on the same host; not adding them again`);
}
if (newRepos.length > 0) {
console.log(`[Scheduler] Found ${newRepos.length} new repositories for user ${userId} on source ${source.name}`);
const reposToInsert = newRepos.map(repo =>
normalizeGitRepoToInsert(repo, { userId, configId: config.id, sourceId: source.id })
);
// Batch insert to avoid SQLite parameter limit
const sample = reposToInsert[0];
const columnCount = Object.keys(sample ?? {}).length || 1;
@@ -229,19 +213,6 @@ async function rediscoverOrganizationRepositories(
)
);
// Same identity rule as personal discovery: an organization pinned to a
// public-only source must not re-insert repositories the personal source
// already tracks on the same host (or the other way round).
const existingRepos = await db
.select({
normalizedFullName: repositories.normalizedFullName,
sourceProvider: repositories.sourceProvider,
sourceUrl: repositories.sourceUrl,
})
.from(repositories)
.where(eq(repositories.userId, userId));
const existingRepoKeys = repositoryIdentityKeys(existingRepos);
for (const org of includedOrgs) {
try {
const source = findSourceForOrganization(org, sources);
@@ -257,17 +228,14 @@ async function rediscoverOrganizationRepositories(
repo => repo.isDisabled !== true && !(skipOrgForks && repo.isForked)
);
const { fresh: repoRecords, alreadyTracked } = selectNewRepositoriesByIdentity(
mirrorableRepos.map(repo =>
if (mirrorableRepos.length > 0) {
const repoRecords = mirrorableRepos.map(repo =>
normalizeGitRepoToInsert(
{ ...repo, organization: repo.organization ?? org.name },
{ userId, configId: config.id, sourceId: source.id }
)
),
existingRepoKeys
);
);
if (repoRecords.length > 0) {
// Batch insert to avoid SQLite parameter limit
const sample = repoRecords[0];
const columnCount = Object.keys(sample ?? {}).length || 1;
@@ -279,10 +247,7 @@ async function rediscoverOrganizationRepositories(
.values(batch)
.onConflictDoNothing({ target: [repositories.userId, repositories.sourceId, repositories.normalizedFullName] });
}
console.log(`[Scheduler] Re-discovered ${repoRecords.length} new repositories for organization ${org.name} on source ${source.name} for user ${userId} during ${phase}`);
}
if (alreadyTracked.length > 0) {
console.log(`[Scheduler] Organization ${org.name}: ${alreadyTracked.length} repositories are already tracked for user ${userId} on the same host, not adding them again under source ${source.name}`);
console.log(`[Scheduler] Re-discovered ${mirrorableRepos.length} repositories for organization ${org.name} on source ${source.name} for user ${userId} during ${phase}`);
}
await db
@@ -366,24 +331,6 @@ function sourceUsernamesBySourceId(sources: SourceRecord[]): Map<string, string>
/**
* Run scheduled mirror sync for a single user configuration
*/
/**
* Keep one row per upstream repository in a scheduler batch. Duplicate rows
* (the same repository imported under two sources on the same host) resolve
* to one destination repository, and processing both in one Promise.all ran
* every step twice against it. The dropped rows are logged so the duplicate
* can be cleaned up; discovery no longer creates new ones.
*/
function dropDuplicateRowsForPass<T extends { fullName: string; sourceId?: string | null; sourceProvider?: string | null; sourceUrl?: string | null; normalizedFullName: string }>(
rows: T[],
pass: string
): T[] {
const { kept, dropped } = dedupeRepositoriesByIdentity(rows);
for (const row of dropped) {
console.warn(`[Scheduler] ${row.fullName} is tracked more than once (another row on the same host covers it); skipping the duplicate row in this ${pass} pass`);
}
return kept;
}
async function runScheduledSync(config: any): Promise<void> {
const userId = config.userId;
console.log(`[Scheduler] Running scheduled sync for user ${userId}`);
@@ -510,11 +457,6 @@ async function runScheduledSync(config: any): Promise<void> {
console.log(`[Scheduler] Skipped ${forkSkippedCount - reposNeedingMirror.length} forked repositories from auto-mirror (organization skip forks)`);
}
// Two rows for one upstream repository (imported under two sources
// that point at the same host) resolve to one destination
// repository; mirror it once per pass, not twice at the same time.
reposNeedingMirror = dropDuplicateRowsForPass(reposNeedingMirror, 'auto-mirror');
if (reposNeedingMirror.length > 0) {
console.log(`[Scheduler] Found ${reposNeedingMirror.length} repositories that need initial mirroring`);
@@ -642,8 +584,6 @@ async function runScheduledSync(config: any): Promise<void> {
// Non-critical logging, ignore errors
}
reposToSync = dropDuplicateRowsForPass(reposToSync, 'sync');
if (reposToSync.length === 0) {
console.log(`[Scheduler] No repositories to sync for user ${userId}`);
return;
@@ -868,7 +808,6 @@ async function performInitialAutoStart(): Promise<void> {
if (skippedCount > 0) {
console.log(`[Scheduler] Skipped ${skippedCount} repositories from initial auto-mirror (autoMirror=${autoMirrorOwned}, autoMirrorStarred=${autoMirrorStarred})`);
}
reposNeedingMirror = dropDuplicateRowsForPass(reposNeedingMirror, 'initial auto-mirror');
if (reposNeedingMirror.length > 0) {
console.log(`[Scheduler] Found ${reposNeedingMirror.length} repositories that need mirroring`);
-43
View File
@@ -165,46 +165,3 @@ describe("processWithRetry", () => {
}
});
});
describe("processWithRetry checkpointing", () => {
test("records every completed item and publishes on the interval", async () => {
const items = ["a", "b", "c", "d", "e"];
const calls: Array<[string, string, boolean]> = [];
await processWithRetry(items, async (item) => item.toUpperCase(), {
concurrencyLimit: 1,
jobId: "job-1",
getItemId: (item) => item,
checkpointInterval: 2,
onCheckpoint: async (jobId, itemId, publish) => {
calls.push([jobId, itemId, publish]);
},
});
// One checkpoint per item, in completion order, and no "final" placeholder.
expect(calls.map(([, itemId]) => itemId)).toEqual(items);
expect(calls.every(([jobId]) => jobId === "job-1")).toBe(true);
// Progress events only every second item.
expect(calls.map(([, , publish]) => publish)).toEqual([false, true, false, true, false]);
});
test("does not checkpoint an item that failed for good", async () => {
const calls: string[] = [];
// processInParallel logs a failed item and carries on, so the call resolves.
await processWithRetry(["ok", "bad"], async (item) => {
if (item === "bad") throw new Error("nope");
return item;
}, {
concurrencyLimit: 1,
maxRetries: 0,
jobId: "job-2",
getItemId: (item) => item,
onCheckpoint: async (_jobId, itemId) => {
calls.push(itemId);
},
});
expect(calls).toEqual(["ok"]);
});
});
+14 -18
View File
@@ -93,15 +93,8 @@ export async function processWithRetry<T, R>(
onRetry?: (item: T, error: Error, attempt: number) => void;
jobId?: string; // Optional job ID for checkpointing
getItemId?: (item: T) => string; // Function to get a unique ID for each item
/**
* Called once for EVERY completed item, so a resume knows exactly what is
* done. `publish` is true every `checkpointInterval` items: the callback
* uses it to throttle progress events, not the checkpoint itself. It used
* to be called only for the item that tripped the interval, and a resume
* re-processed everything finished since the previous checkpoint.
*/
onCheckpoint?: (jobId: string, completedItemId: string, publish: boolean) => Promise<void>;
checkpointInterval?: number; // How many completed items between published progress events
onCheckpoint?: (jobId: string, completedItemId: string) => Promise<void>; // Callback for checkpointing
checkpointInterval?: number; // How many items to process before checkpointing
} = {}
): Promise<R[]> {
const {
@@ -137,16 +130,16 @@ export async function processWithRetry<T, R>(
const result = await processItem(item);
// Record every completed item; publish progress on the interval.
// Handle checkpointing if enabled
if (jobId && getItemId && onCheckpoint) {
const itemId = getItemId(item);
itemsProcessedSinceLastCheckpoint++;
const publish = itemsProcessedSinceLastCheckpoint >= checkpointInterval;
if (publish) {
// Checkpoint based on the interval
if (itemsProcessedSinceLastCheckpoint >= checkpointInterval) {
await onCheckpoint(jobId, itemId);
itemsProcessedSinceLastCheckpoint = 0;
}
await onCheckpoint(jobId, itemId, publish);
}
return result;
@@ -193,6 +186,12 @@ export async function processWithRetry<T, R>(
onProgress
);
// Final checkpoint if there are remaining items since the last checkpoint
if (jobId && getItemId && onCheckpoint && itemsProcessedSinceLastCheckpoint > 0) {
// We don't have a specific item ID for the final checkpoint, so we'll use a placeholder
await onCheckpoint(jobId, 'final');
}
return results;
}
@@ -310,10 +309,8 @@ export async function processWithResilience<T, R>(
});
}, JOB_HEARTBEAT_INTERVAL_MS);
// Define the checkpoint function. Every completed item is written to the
// job row so a resume skips exactly what is done; only every
// checkpointInterval-th write also publishes a progress event.
const onCheckpoint = async (jobId: string, completedItemId: string, publish: boolean = true) => {
// Define the checkpoint function
const onCheckpoint = async (jobId: string, completedItemId: string) => {
const itemName = items.find(item => getItemId(item) === completedItemId)
? getItemName(items.find(item => getItemId(item) === completedItemId)!)
: 'unknown';
@@ -322,7 +319,6 @@ export async function processWithResilience<T, R>(
jobId,
completedItemId,
message: `Processed item: ${itemName}`,
silent: !publish,
});
};
+10 -16
View File
@@ -9,8 +9,6 @@ import {
mergeGitReposPreferStarred,
normalizeGitRepoToInsert,
calcBatchSizeForInsert,
repositoryIdentityKeys,
selectNewRepositoriesByIdentity,
} from "@/lib/repo-utils";
import { loadOrganizationForkPolicies } from "@/lib/utils/mirror-overrides";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
@@ -167,8 +165,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
tx
.select({
normalizedFullName: repositories.normalizedFullName,
sourceProvider: repositories.sourceProvider,
sourceUrl: repositories.sourceUrl,
sourceId: repositories.sourceId,
})
.from(repositories)
.where(eq(repositories.userId, userId)),
@@ -178,20 +175,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
.where(eq(organizations.userId, userId)),
]);
// A repository is identified by its host and full name, whichever
// source row lists it. The same name on two hosts is two
// repositories; the same repository seen by two sources on one host
// (a personal token and a public-only source) is one, and must not
// become two rows that mirror to the same destination.
const existingRepoKeys = repositoryIdentityKeys(existingRepos);
// The same full name under two sources is two different repositories.
const existingRepoKeys = new Set(
existingRepos.map((r) => `${r.sourceId ?? ""}|${r.normalizedFullName}`)
);
const existingOrgMap = new Map(existingOrgs.map((o) => [o.normalizedName, o.status]));
insertedRepos = selectNewRepositoriesByIdentity(
newRepos.filter(
(r) => !r.organization || !ignoredOrgNames.has(r.organization.toLowerCase())
),
existingRepoKeys
).fresh;
insertedRepos = newRepos.filter(
(r) =>
!existingRepoKeys.has(`${source.id}|${r.normalizedFullName}`) &&
(!r.organization || !ignoredOrgNames.has(r.organization.toLowerCase()))
);
insertedOrgs = newOrgs.filter((o) => !existingOrgMap.has(o.normalizedName));
// Update previously failed orgs that now succeeded. The pin must
-11
View File
@@ -148,17 +148,6 @@ if (isChild) {
select: () => ({
from: (table: unknown) => ({
where: (cond: unknown) => ({
// Awaited directly by the existing-repository lookup that keeps
// one row per upstream repository; the inserted rows stand in
// for the table.
then: (
onfulfilled: (rows: OrgRow[]) => unknown,
onrejected?: (reason: unknown) => unknown
) =>
Promise.resolve(table === repositoriesTable ? [...insertedRepoRows] : []).then(
onfulfilled,
onrejected
),
limit: async (): Promise<OrgRow[]> => {
const leaves = conditionLeafValues(cond);
if (table === organizationsTable) {
+6 -30
View File
@@ -15,12 +15,7 @@ import {
normalizeSourceUrl,
} from "@/lib/source-providers/kinds";
import type { SourceRecord } from "@/lib/sources";
import {
normalizeGitRepoToInsert,
calcBatchSizeForInsert,
repositoryIdentityKeys,
selectNewRepositoriesByIdentity,
} from "@/lib/repo-utils";
import { normalizeGitRepoToInsert, calcBatchSizeForInsert } from "@/lib/repo-utils";
import { resolveOrganizationSkipForks } from "@/lib/utils/mirror-overrides";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
@@ -264,31 +259,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
// Insert repositories. The normalizer stamps the source provider and URL.
// Repositories the user already tracks on this host under another source
// (the personal source, say) are not inserted a second time: two rows for
// one upstream repository would both mirror to the same destination.
const existingRepos = await db
.select({
normalizedFullName: repositories.normalizedFullName,
sourceProvider: repositories.sourceProvider,
sourceUrl: repositories.sourceUrl,
})
.from(repositories)
.where(eq(repositories.userId, userId));
const { fresh: repoRecords, alreadyTracked } = selectNewRepositoriesByIdentity(
mirrorableRepos.map((repo) =>
normalizeGitRepoToInsert(
{ ...repo, organization: repo.organization ?? orgData.name },
{ userId, configId, sourceId: source.id }
)
),
repositoryIdentityKeys(existingRepos)
const repoRecords = mirrorableRepos.map((repo) =>
normalizeGitRepoToInsert(
{ ...repo, organization: repo.organization ?? orgData.name },
{ userId, configId, sourceId: source.id }
)
);
if (alreadyTracked.length > 0) {
console.log(
`[Organization import] ${alreadyTracked.length} repositories of ${orgData.name} are already tracked for user ${userId} on this host; not adding them again under source ${source.name}`
);
}
// Batch insert repositories to avoid SQLite parameter limit
// Compute batch size based on column count
+1 -2
View File
@@ -38,8 +38,7 @@
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1",
"sharp": "^0.35.4",
"svgo": "^4.1.0",
"js-yaml": "^4.3.2",
"smol-toml": "^1.7.1"
"js-yaml": "^4.3.2"
}
}
}
+7 -8
View File
@@ -9,7 +9,6 @@ overrides:
sharp: ^0.35.4
svgo: ^4.1.0
js-yaml: ^4.3.2
smol-toml: ^1.7.1
importers:
@@ -2023,8 +2022,8 @@ packages:
engines: {node: '>=20.19.5', npm: '>=10.8.2'}
hasBin: true
smol-toml@1.8.0:
resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==}
smol-toml@1.7.0:
resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
engines: {node: '>= 18'}
source-map-js@1.2.1:
@@ -2365,7 +2364,7 @@ snapshots:
picomatch: 4.0.5
retext-smartypants: 6.2.0
shiki: 4.3.1
smol-toml: 1.8.0
smol-toml: 1.7.0
unified: 11.0.5
'@astrojs/internal-helpers@0.11.0':
@@ -2376,7 +2375,7 @@ snapshots:
picomatch: 4.0.5
retext-smartypants: 6.2.0
shiki: 4.3.1
smol-toml: 1.8.0
smol-toml: 1.7.0
unified: 11.0.5
'@astrojs/markdown-remark@7.2.1':
@@ -2641,7 +2640,7 @@ snapshots:
dependencies:
'@emnapi/core': 1.11.1
'@emnapi/runtime': 1.11.1
'@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
optional: true
'@bruits/satteri-win32-arm64-msvc@0.10.5':
@@ -3296,7 +3295,7 @@ snapshots:
picomatch: 4.0.5
semver: 7.8.5
shiki: 4.3.1
smol-toml: 1.8.0
smol-toml: 1.7.0
svgo: 4.1.0
tinyclip: 0.1.15
tinyexec: 1.2.4
@@ -4681,7 +4680,7 @@ snapshots:
arg: 5.0.2
sax: 1.6.0
smol-toml@1.8.0: {}
smol-toml@1.7.0: {}
source-map-js@1.2.1: {}