Compare commits

...

18 Commits

Author SHA1 Message Date
云与原 204922570d feat: add Gotify as a notification provider (#337)
Adds Gotify alongside ntfy and Apprise: new provider module posting to {url}/message with X-Gotify-Key auth, configurable default priority (errors always send at priority 8), token encrypted at rest like the other providers, settings UI section, and provider + service tests. No database migration needed.
2026-07-16 22:20:15 +05:30
Arunavo Ray 97d98b82c6 chore: bump version to 3.21.0 2026-07-16 21:52:01 +05:30
ARUNAVO RAY 8b81ffa975 chore(deps): security fixes and dependency updates across app and www (#348)
better-auth 1.6.23 (fixes GHSA high stored XSS via javascript: redirect_uri), esbuild >=0.28.1 override for www (GHSA low), Astro 7 / @astrojs/node 11 / @astrojs/react 6 / @astrojs/mdx 7 / lucide-react 1.x (inline GitHub icon replaces removed brand icon) and all in-range updates on both the app and the website. The remaining @better-auth/oauth-provider medium advisory is patched upstream only in 1.7-rc and will be picked up when 1.7 stable lands.
2026-07-16 21:51:41 +05:30
Arunavo Ray bb57b52de3 test: isolate bulk-mirror destination tests in a child process
bun's mock.module and the globalThis.fetch swap are process-wide; on CI
(bun 1.3.13) this file's mocks of @/lib/db and @/lib/gitea-enhanced leaked
into gitea-enhanced.test.ts and stuck-status-recovery tests, failing main.
The file now registers nothing in the shared test process and instead
re-runs itself via bun test in a child process where the mocks are contained.
2026-07-16 21:46:08 +05:30
Arunavo Ray b3aad9d80f test: make org ids order-independent in bulk-mirror destination tests
The previous call-order counter diverged between the mocked flow and the
assertions when bun re-instantiates mock factories (green on bun 1.3.6
locally, red on 1.3.13 in CI). Ids are now a pure function of the org name.
2026-07-16 21:35:56 +05:30
Arunavo Ray 3b8625634c docs: add time format toggle screenshot 2026-07-16 21:02:21 +05:30
ARUNAVO RAY bdbfa762af feat(ui): add 12h/24h time format option with locale-aware default (#342) (#346)
Timestamps now follow the browser locale by default (previously hardcoded en-US/12-hour), with a clock toggle in the header for Auto / 12-hour / 24-hour, persisted in localStorage.

Fixes #342
2026-07-16 20:57:48 +05:30
ARUNAVO RAY f922bcc618 fix: recover repositories stuck in syncing/mirroring after crashes (#339) (#347)
Adds stuck-status recovery: repositories (and orgs) stranded in an in-flight status by a crash/restart are reset to failed with an explanation, on container start and every scheduler tick, guarded by the existing 2h liveness window.

Fixes #339
2026-07-16 20:57:38 +05:30
ARUNAVO RAY c5b331c041 fix: stop config saves from resetting env-configured GITEA_MIRROR_INTERVAL (#338) (#345)
Config saves now preserve every field the settings form doesn't expose (mirror interval and other env-only options) instead of resetting them to defaults.

Fixes #338
2026-07-16 20:57:29 +05:30
Arunavo Ray 5c33a5547b test: cover bulk org mirror destination routing (#343) + clarify mixed-strategy log
- Add behavioral tests exercising mirrorGitHubOrgToGitea end-to-end down to
  the migrate HTTP payload: org-level override, per-repo override, mixed
  strategy uid, starred-repo mode, and preserve/single-org/flat-user
  no-override regression paths. All four bug-scenario tests fail on main
  and pass with PR #344 applied.
- Fix the top-level log that claimed 'flat-user strategy' when the mixed
  strategy falls into the same branch.
2026-07-16 20:55:15 +05:30
Yuzu 537aae952d fix: honor destination overrides in bulk org mirroring and crash recovery (#343) (#344)
Routes the bulk Mirror Organization path and crash recovery through the canonical destination resolver (getGiteaRepoOwnerAsync), so org-level and per-repo destination overrides are honored, the mixed strategy no longer sends org repos to the user's personal account (previously uid was dropped from the migrate payload and Gitea defaulted to the authenticated user), and starred repos follow starred-repo mode even when swept up in a bulk org mirror.

Fixes #343
2026-07-16 20:54:58 +05:30
Arunavo Ray 40efb9b83a chore(www): point site URLs to gitea-mirror.raylabs.io
The old giteamirror.com domain is being retired to avoid paying for
per-project domains. Repoint all canonical URLs, og:url, the homepage
siteUrl, robots.txt sitemap ref, and sitemap.xml loc from the old
gitea-mirror.com domain to the new gitea-mirror.raylabs.io site.
2026-07-06 00:40:27 +05:30
Arunavo Ray 06bfb49e0e chore: bump version to 3.20.4 2026-07-02 15:46:20 +05:30
ARUNAVO RAY b5e0c58708 fix: stop false-positive orphan archiving and heal sync 405s on archived-* renamed mirrors (#331) (#336)
Three related fixes for the "repos keep getting archived and then fail to
sync with HTTP 405" report:

1. Orphan cleanup no longer archives on bulk-list absence alone.
   Repos added via the "+" Add Repository dialog (foreign owner, not
   starred) can never appear in the authenticated bulk fetches, so every
   cleanup cycle deterministically flagged them as orphaned and archived
   them. identifyOrphanedRepositories() now runs a targeted per-repo
   confirmation (starred check or repos.get) and only treats a clean 404
   as gone; any other outcome fails safe.

2. The archived-* rename is persisted. archiveGiteaRepo() now returns
   the actual post-rename name and the cleanup service records it in
   mirroredLocation, so the DB no longer points at a name that only
   301-redirects.

3. Sync self-heals repos renamed in Gitea/Forgejo. Requests to a
   renamed repo get a 301; fetch follows it, downgrading POST to GET,
   which lands on the POST-only mirror-sync endpoint as a 405. The sync
   candidate loop now adopts the canonical owner/name from the GET
   response body before POSTing, tries an archived-{name} fallback for
   archived repos (guarded by an original_url source match), and keeps
   archived repos archived: no mirror-interval PATCH, status stays
   'archived' per the documented Manual Sync contract.

Also hardens mirrorGitHubReleasesToGitea to derive GitHub coordinates
from fullName so Gitea-side names can never leak into GitHub API calls.

Verified end-to-end on Forgejo 15.0.3 (rootless): pre-fix reproduces the
exact 405; post-fix the stale-name sync succeeds (GET stale -> 301, GET
canonical -> 200, POST canonical mirror-sync -> 200), archived repos keep
interval "0s" with no PATCH issued, and non-archived renamed repos heal
and get the configured interval applied.
2026-07-02 15:46:00 +05:30
Arunavo Ray 74606f0a5f chore: bump version to 3.20.3 2026-07-01 08:13:15 +05:30
ARUNAVO RAY 187ecc5d60 fix: correctly mirror Gitea release titles and issue/PR labels (#334 + sibling) (#335)
* fix(releases): send Gitea release title as `name`, not `title` (#334)

Gitea/Forgejo expose the release title through the JSON field `name`
(the API Go struct is `Title string `+"`"+`json:"name"`+"`"+`). The release
create and update payloads sent `title:` instead, which Gitea silently
ignores, so every mirrored release landed with a blank title.

Verified live against Gitea 1.24.7: a POST/PATCH with `title` yields
`name: ""`; the same call with `name` sets the title correctly. The
update path also self-heals previously-mirrored releases whose names were
left blank, since the existing-vs-expected name comparison already drives
a PATCH.

Adds gita-release-name.test.ts, which drives the real
mirrorGitHubReleasesToGitea create/update paths with a mocked fetch and
asserts the payload carries `name` (and never `title`).

* fix(issues): reconcile labels on issue/PR update via the labels sub-resource (#334 sibling)

Gitea/Forgejo's `EditIssueOption` has no `labels` field (only
`CreateIssueOption` does), so a `labels` key in a `PATCH .../issues/{index}`
body is silently dropped — the same silent-ignore class as the release
`title` vs `name` bug. The issue and PR-as-issue update paths sent `labels`
in the PATCH body, so label changes never propagated onto already-mirrored
issues (and a deadlock-orphaned issue recovered via PATCH never got its
labels).

Fix: add `reconcileGiteaIssueLabels`, which replaces the label set via
`PUT .../issues/{index}/labels` (idempotent — adds new, removes deleted).
Call it on the two issue update paths and the two PR-issue update paths,
and drop the dead `labels` key from those PATCH bodies. Labels on freshly
created issues still come from CreateIssueOption on the POST.

Verified live against Gitea 1.24.7 (PATCH ignores labels; PUT applies them)
and end-to-end (a drifted mirrored issue reconciled from no-labels to its
GitHub label set). Adds gitea-issue-labels.test.ts driving the real
mirrorGitRepoIssuesToGitea update path; the test carries a self-contained
http-client mock so it is immune to another suite's global module mock.

* test: make #334 regression tests deterministic via pure payload builders

The prior tests drove the real mirror functions with a global `fetch` mock.
That is order/version-fragile: another suite installs a process-global
`mock.module("@/lib/http-client")`, and bun 1.3.13 (CI) runs test files
concurrently, so `globalThis.fetch` races across files and
`isRepoPresentInGitea` (raw fetch) intermittently sees the wrong mock —
green locally on bun 1.3.6, red in CI.

Extract the payload construction into pure, exported builders and assert on
those instead (the repo's existing `classify*` pattern): buildGiteaReleasePayload
(create+update send `name`, never `title`), buildGiteaIssueEditPayload (edit
body never carries `labels`), buildGiteaIssueLabelsPayload (labels sub-resource
body). Behavior is unchanged — the builders return the exact same objects the
call sites built inline — and the fixes remain verified live on Gitea 1.24.7.
2026-07-01 08:12:36 +05:30
Arunavo Ray 632bbd0d4a chore: bump version to 3.20.2 2026-06-24 17:19:10 +05:30
ARUNAVO RAY 0b65e40784 fix(releases): create releases only for tags present in Gitea; stop sending target (#331) (#333)
Release creation failed on some Gitea/Forgejo instances with
"HTTP 404: The target couldn't be found", so no release (and therefore no
assets) was ever created — re-syncing never recovered.

Root cause: the create payload always sent `target: target_commitish`
(e.g. "main"). When the release's git tag is not yet present in the Gitea
mirror — which happens when Gitea's own git mirror clone lags behind the
metadata sync — Gitea tries to *create* the tag from `target`; if that ref
can't be resolved it returns a generic 404 ("The target couldn't be
found"), and if it can, it would create a brand-new tag at the wrong commit.

Reproduced the reporter's exact stack (Forgejo 15 rootless + read_only +
cap_drop ALL + postgres, plus Gitea 1.20-1.26 and Forgejo 1.21-15): a
healthy repo always succeeds — the 404 only occurs when the tag is absent
at create time.

Fix:
- Before creating a release, verify the git tag already exists in Gitea.
  If it isn't synced yet, skip it (logged) and let a later sync create it
  once the mirror has the tag — never create a tag via `target`.
- Drop the `target` field from both the create and update payloads. For a
  mirror the tag is synced from upstream, so Gitea attaches the release to
  the existing tag; `target` is unnecessary and is what triggers the 404.
- Surface skipped-missing-tag releases in the summary log for diagnosability.

Verified end-to-end against the real mirror function on a Forgejo instance:
a release whose tag exists is created with its assets; a release whose tag
was removed is skipped cleanly (no 404, no bogus tag) and picked up once the
tag is present.
2026-06-24 17:18:46 +05:30
59 changed files with 4779 additions and 1178 deletions
+463 -255
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+41 -41
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.20.1",
"version": "3.21.0",
"engines": {
"bun": ">=1.2.9"
},
@@ -55,40 +55,40 @@
},
"dependencies": {
"@astrojs/check": "^0.9.9",
"@astrojs/mdx": "5.0.0",
"@astrojs/node": "10.1.4",
"@astrojs/react": "^5.0.7",
"@better-auth/oauth-provider": "1.6.11",
"@better-auth/sso": "1.6.11",
"@astrojs/mdx": "^7.0.3",
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.1",
"@better-auth/oauth-provider": "1.6.23",
"@better-auth/sso": "1.6.23",
"@octokit/plugin-throttling": "^11.0.3",
"@octokit/rest": "^22.0.1",
"@radix-ui/react-accordion": "^1.2.13",
"@radix-ui/react-avatar": "^1.1.12",
"@radix-ui/react-checkbox": "^1.3.4",
"@radix-ui/react-collapsible": "^1.1.13",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-hover-card": "^1.1.16",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-popover": "^1.1.16",
"@radix-ui/react-progress": "^1.1.9",
"@radix-ui/react-radio-group": "^1.4.0",
"@radix-ui/react-scroll-area": "^1.2.11",
"@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.14",
"@radix-ui/react-tooltip": "^1.2.9",
"@tailwindcss/vite": "^4.3.1",
"@radix-ui/react-accordion": "^1.2.16",
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-checkbox": "^1.3.7",
"@radix-ui/react-collapsible": "^1.1.16",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-hover-card": "^1.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-popover": "^1.1.19",
"@radix-ui/react-progress": "^1.1.12",
"@radix-ui/react-radio-group": "^1.4.3",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-tooltip": "^1.2.12",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.2",
"@tanstack/react-virtual": "^3.14.6",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^6.4.6",
"astro": "^7.1.0",
"bcryptjs": "^3.0.3",
"better-auth": "1.6.11",
"better-auth": "1.6.23",
"buffer": "^6.0.3",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
@@ -96,37 +96,37 @@
"cmdk": "^1.1.1",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"fuse.js": "^7.4.2",
"fuse.js": "^7.5.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.577.0",
"nanoid": "^5.1.11",
"lucide-react": "^1.24.0",
"nanoid": "^6.0.0",
"next-themes": "^0.4.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-icons": "^5.6.0",
"react-icons": "^5.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"uuid": "^13.0.2",
"typescript": "^7.0.2",
"uuid": "^14.0.1",
"vaul": "^1.1.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.3.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.9.3",
"@types/node": "^26.1.1",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-react": "^6.0.3",
"drizzle-kit": "^0.31.10",
"jsdom": "^28.1.0",
"tsx": "^4.22.4",
"vitest": "^4.1.8"
"jsdom": "^29.1.1",
"tsx": "^4.23.1",
"vitest": "^4.1.10"
},
"packageManager": "bun@1.3.10"
}
+21 -1
View File
@@ -13,6 +13,7 @@
*/
import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from "../src/lib/recovery";
import { resetStuckMirrorStatuses } from "../src/lib/stuck-status-recovery";
// Parse command line arguments
const args = process.argv.slice(2);
@@ -41,10 +42,29 @@ async function runStartupRecovery() {
}, timeout);
});
// Reset repositories/organizations stuck in an in-flight status
// ("mirroring"/"syncing") from a previous run (issue #339). This must
// happen BEFORE the needsRecovery early-exit below: scheduler-driven
// syncs create no resilient job records, so a crash mid-scheduled-sync
// leaves stuck repo rows but NO interrupted jobs — the early exit would
// skip them forever. The app is not running while this script executes,
// so every in-flight row is an orphan by definition (this script's own
// process start is the cutoff). Never throws.
console.log('Checking for repositories stuck in an in-flight status...');
const stuckReset = await resetStuckMirrorStatuses();
if (stuckReset.repositories > 0 || stuckReset.organizations > 0) {
console.log(
`✅ Reset ${stuckReset.repositories} stuck repositor${stuckReset.repositories === 1 ? 'y' : 'ies'} ` +
`and ${stuckReset.organizations} stuck organization(s) to "failed" for retry.`
);
} else {
console.log('✅ No stuck repository/organization statuses found.');
}
// Check if recovery is needed first
console.log('Checking if recovery is needed...');
const needsRecovery = await hasJobsNeedingRecovery();
if (!needsRecovery) {
console.log('✅ No jobs need recovery. Startup can proceed.');
process.exit(0);
+4
View File
@@ -6,6 +6,7 @@ import { Button } from '../ui/button';
import { RefreshCw, Check, X, Loader2, Import } from 'lucide-react';
import { Card } from '../ui/card';
import { formatDate, getStatusColor } from '@/lib/utils';
import { useTimeFormat } from '@/hooks/useTimeFormat';
import { Skeleton } from '../ui/skeleton';
import type { FilterParams } from '@/types/filter';
import {
@@ -32,6 +33,9 @@ export default function ActivityList({
filter,
setFilter,
}: ActivityListProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const [expandedItems, setExpandedItems] = useState<Set<string>>(
() => new Set(),
);
@@ -30,6 +30,7 @@ import {
} from "@/components/ui/tooltip";
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
import { formatDate } from "@/lib/utils";
import { useTimeFormat } from "@/hooks/useTimeFormat";
import {
buildClockCronExpression,
getNextCronOccurrence,
@@ -89,6 +90,9 @@ export function AutomationSettings({
isAutoSavingSchedule,
isAutoSavingCleanup,
}: AutomationSettingsProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const browserTimezone =
typeof Intl !== "undefined"
? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
+88 -1
View File
@@ -93,7 +93,7 @@ export function NotificationSettings({
</Label>
<Select
value={notificationConfig.provider}
onValueChange={(value: "ntfy" | "apprise") =>
onValueChange={(value: "ntfy" | "apprise" | "gotify") =>
onNotificationChange({ ...notificationConfig, provider: value })
}
>
@@ -103,6 +103,7 @@ export function NotificationSettings({
<SelectContent>
<SelectItem value="ntfy">Ntfy.sh</SelectItem>
<SelectItem value="apprise">Apprise API</SelectItem>
<SelectItem value="gotify">Gotify</SelectItem>
</SelectContent>
</Select>
</div>
@@ -307,6 +308,92 @@ export function NotificationSettings({
</div>
)}
{/* Gotify configuration */}
{notificationConfig.provider === "gotify" && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Gotify Settings</h3>
<div className="space-y-2">
<Label htmlFor="gotify-url" className="text-sm">
Server URL <span className="text-destructive">*</span>
</Label>
<Input
id="gotify-url"
type="url"
placeholder="https://gotify.example.com"
value={notificationConfig.gotify?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: e.target.value,
token: notificationConfig.gotify?.token || "",
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
<p className="text-xs text-muted-foreground">
URL of your Gotify server
</p>
</div>
<div className="space-y-2">
<Label htmlFor="gotify-token" className="text-sm">
Application token <span className="text-destructive">*</span>
</Label>
<Input
id="gotify-token"
type="password"
placeholder="A1b2C3d4..."
value={notificationConfig.gotify?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: e.target.value,
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
<p className="text-xs text-muted-foreground">
Create an application in Gotify and paste its token here
</p>
</div>
<div className="space-y-2">
<Label htmlFor="gotify-priority" className="text-sm">
Default priority (0-10)
</Label>
<Input
id="gotify-priority"
type="number"
min={0}
max={10}
value={notificationConfig.gotify?.priority ?? 5}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: notificationConfig.gotify?.token || "",
priority: Math.min(10, Math.max(0, Number(e.target.value) || 0)),
},
})
}
/>
<p className="text-xs text-muted-foreground">
Error notifications always use priority 8 regardless of this setting
</p>
</div>
</div>
)}
{/* Event toggles */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Notification Events</h3>
@@ -2,6 +2,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "../ui/checkbox";
import type { ScheduleConfig } from "@/types/config";
import { formatDate } from "@/lib/utils";
import { useTimeFormat } from "@/hooks/useTimeFormat";
import {
Select,
SelectContent,
@@ -24,6 +25,9 @@ export function ScheduleConfigForm({
onAutoSave,
isAutoSaving = false,
}: ScheduleConfigFormProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
+7 -9
View File
@@ -17,6 +17,8 @@ import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
import { withBase } from "@/lib/base-path";
import { formatShortDateTime } from "@/lib/utils/time-format";
import { useTimeFormat } from "@/hooks/useTimeFormat";
// Helper function to format last sync time
function formatLastSyncTime(date: Date | null): string {
@@ -45,17 +47,11 @@ function formatLastSyncTime(date: Date | null): string {
}
// Helper function to format full timestamp
// Locale-aware and respects the user's 12h/24h time format preference.
function formatFullTimestamp(date: Date | null): string {
if (!date) return "";
return new Date(date).toLocaleString("en-US", {
month: "2-digit",
day: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: true
}).replace(',', '');
return formatShortDateTime(date).replace(',', '');
}
export function Dashboard() {
@@ -64,6 +60,8 @@ export function Dashboard() {
const isPageVisible = usePageVisibility();
const { isFullyConfigured } = useConfigStatus();
const { navigationKey } = useNavigation();
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const [repositories, setRepositories] = useState<Repository[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -4,12 +4,16 @@ import { formatDate, getStatusColor } from "@/lib/utils";
import { Button } from "../ui/button";
import { Activity, Clock } from "lucide-react";
import { withBase } from "@/lib/base-path";
import { useTimeFormat } from "@/hooks/useTimeFormat";
interface RecentActivityProps {
activities: MirrorJob[];
}
export function RecentActivity({ activities }: RecentActivityProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
+3
View File
@@ -2,6 +2,7 @@ import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { ModeToggle } from "@/components/theme/ModeToggle";
import { TimeFormatToggle } from "@/components/layout/TimeFormatToggle";
import { Skeleton } from "@/components/ui/skeleton";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus";
@@ -125,6 +126,8 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
</Button>
)}
<TimeFormatToggle />
<ModeToggle />
{isLoading ? <AuthButtonsSkeleton /> : <AccountMenu />}
@@ -0,0 +1,52 @@
import { Clock, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTimeFormat } from "@/hooks/useTimeFormat";
import type { TimeFormatPreference } from "@/lib/utils/time-format";
const OPTIONS: { value: TimeFormatPreference; label: string }[] = [
{ value: "auto", label: "Auto (browser locale)" },
{ value: "12h", label: "12-hour" },
{ value: "24h", label: "24-hour" },
];
export function TimeFormatToggle() {
const { preference, setPreference } = useTimeFormat();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="lg"
className="has-[>svg]:px-3"
title="Time format"
>
<Clock className="h-[1.2rem] w-[1.2rem]" />
<span className="sr-only">Toggle time format</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => setPreference(option.value)}
>
<Check
className={`h-4 w-4 ${
preference === option.value ? "opacity-100" : "opacity-0"
}`}
/>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useCallback, useSyncExternalStore } from "react";
import {
getTimeFormatPreference,
setTimeFormatPreference,
subscribeToTimeFormatChange,
type TimeFormatPreference,
} from "@/lib/utils/time-format";
const getServerSnapshot = (): TimeFormatPreference => "auto";
/**
* Subscribe a component to the user's 12h/24h time format preference.
*
* Any component that renders clock times should call this hook (even if it
* only needs the re-render) so timestamps update immediately when the user
* changes the preference navigation is SPA-style, so components stay
* mounted across pages.
*/
export function useTimeFormat() {
const preference = useSyncExternalStore(
subscribeToTimeFormatChange,
getTimeFormatPreference,
getServerSnapshot
);
const setPreference = useCallback((next: TimeFormatPreference) => {
setTimeFormatPreference(next);
}, []);
return { preference, setPreference };
}
+8 -1
View File
@@ -138,14 +138,21 @@ export const appriseConfigSchema = z.object({
tag: z.string().optional(),
});
export const gotifyConfigSchema = z.object({
url: z.string().default(""),
token: z.string().default(""),
priority: z.number().int().min(0).max(10).default(5),
});
export const notificationConfigSchema = z.object({
enabled: z.boolean().default(false),
provider: z.enum(["ntfy", "apprise"]).default("ntfy"),
provider: z.enum(["ntfy", "apprise", "gotify"]).default("ntfy"),
notifyOnSyncError: z.boolean().default(true),
notifyOnSyncSuccess: z.boolean().default(false),
notifyOnNewRepo: z.boolean().default(false),
ntfy: ntfyConfigSchema.optional(),
apprise: appriseConfigSchema.optional(),
gotify: gotifyConfigSchema.optional(),
});
export type NotificationConfig = z.infer<typeof notificationConfigSchema>;
+245
View File
@@ -0,0 +1,245 @@
/**
* Unit tests for archiveGiteaRepo's return value and sanitizeRepoNameAlphaDashDot
* regression coverage for #331's follow-up (repos falsely flagged as orphaned
* and archived, then unreachable by "Manual Sync" because the DB's
* mirroredLocation/name were never updated to the post-rename name).
*
* archiveGiteaRepo now reports the Gitea-side name it ended up with after a
* rename (mirror path) so callers (repository-cleanup-service.ts) can persist
* it, instead of leaving the DB pointing at a name that no longer exists.
*/
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
const mockHttpGet = mock(async (_url: string, _headers?: any) => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const mockHttpPatch = mock(async (_url: string, _body?: any, _headers?: any) => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const mockHttpPost = mock(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const mockHttpDelete = mock(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const mockHttpPut = mock(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
class MockHttpError extends Error {
constructor(
message: string,
public status: number,
public statusText: string,
public response?: string
) {
super(message);
this.name = "HttpError";
}
}
mock.module("@/lib/http-client", () => ({
httpGet: mockHttpGet,
httpPatch: mockHttpPatch,
httpPost: mockHttpPost,
httpDelete: mockHttpDelete,
httpPut: mockHttpPut,
HttpError: MockHttpError,
}));
import { archiveGiteaRepo, sanitizeRepoNameAlphaDashDot } from "./gitea";
describe("sanitizeRepoNameAlphaDashDot", () => {
test("replaces disallowed characters with a dash", () => {
expect(sanitizeRepoNameAlphaDashDot("my repo!")).toBe("my-repo");
});
test("collapses consecutive disallowed characters into a single dash", () => {
expect(sanitizeRepoNameAlphaDashDot("a___b")).toBe("a-b");
});
test("trims leading and trailing separators/dots", () => {
expect(sanitizeRepoNameAlphaDashDot("--.foo.--")).toBe("foo");
});
test("leaves an already-valid AlphaDashDot name unchanged", () => {
expect(sanitizeRepoNameAlphaDashDot("valid-repo.name")).toBe("valid-repo.name");
});
});
describe("archiveGiteaRepo", () => {
const client = { url: "https://gitea.example.com", token: "test-token" };
let originalConsoleLog: typeof console.log;
let originalConsoleWarn: typeof console.warn;
let originalConsoleError: typeof console.error;
let originalConsoleDebug: typeof console.debug;
beforeEach(() => {
mockHttpGet.mockClear();
mockHttpPatch.mockClear();
mockHttpPost.mockClear();
mockHttpDelete.mockClear();
// Reset to benign defaults; individual tests override with mockImplementationOnce/mockImplementation.
mockHttpGet.mockImplementation(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
mockHttpPatch.mockImplementation(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
originalConsoleLog = console.log;
originalConsoleWarn = console.warn;
originalConsoleError = console.error;
originalConsoleDebug = console.debug;
console.log = mock(() => {});
console.warn = mock(() => {});
console.error = mock(() => {});
console.debug = mock(() => {});
});
afterEach(() => {
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
console.error = originalConsoleError;
console.debug = originalConsoleDebug;
});
test("mirror repo rename returns the new archived name", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "my-repo", mirror: true, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const result = await archiveGiteaRepo(client, "owner", "my-repo");
expect(result).toEqual({ archivedName: "archived-my-repo" });
// Rename PATCH + mirror-interval-disable PATCH
expect(mockHttpPatch).toHaveBeenCalledTimes(2);
const renameCall = mockHttpPatch.mock.calls[0];
expect(String(renameCall[0])).toContain("/api/v1/repos/owner/my-repo");
expect(renameCall[1]).toMatchObject({ name: "archived-my-repo" });
});
test("already-archived mirror repo returns the existing name without re-renaming", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "archived-my-repo", mirror: true, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const result = await archiveGiteaRepo(client, "owner", "archived-my-repo");
expect(result).toEqual({ archivedName: "archived-my-repo" });
expect(mockHttpPatch).not.toHaveBeenCalled();
});
test("non-mirror repo archives natively and returns archivedName: null", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "regular-repo", mirror: false, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
mockHttpPatch.mockImplementationOnce(async () => ({
data: { archived: true },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const result = await archiveGiteaRepo(client, "owner", "regular-repo");
expect(result).toEqual({ archivedName: null });
expect(mockHttpPatch).toHaveBeenCalledTimes(1);
expect(mockHttpPatch.mock.calls[0][1]).toMatchObject({ archived: true });
});
test("rename PATCH failure (primary and timestamped fallback both fail) returns archivedName: null", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "my-repo", mirror: true, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
mockHttpPatch.mockImplementation(async () => {
throw new MockHttpError("Unprocessable Entity", 422, "Unprocessable Entity");
});
const result = await archiveGiteaRepo(client, "owner", "my-repo");
expect(result).toEqual({ archivedName: null });
// Primary rename attempt + timestamped fallback attempt, no interval-disable call
expect(mockHttpPatch).toHaveBeenCalledTimes(2);
});
test("mirror repo rename recovers via timestamped fallback after a primary conflict", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "my-repo", mirror: true, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
let callCount = 0;
mockHttpPatch.mockImplementation(async (url: string, body?: any) => {
callCount++;
if (callCount === 1) {
// Primary rename attempt fails (e.g. AlphaDashDot conflict)
throw new MockHttpError("conflict", 422, "Unprocessable Entity");
}
// Fallback rename attempt and the interval-disable PATCH both succeed
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
});
const result = await archiveGiteaRepo(client, "owner", "my-repo");
expect(result.archivedName).toMatch(/^archived-\d{14}-my-repo$/);
expect(mockHttpPatch).toHaveBeenCalledTimes(3);
});
test("repository not found in Gitea returns archivedName: null", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: null,
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const result = await archiveGiteaRepo(client, "owner", "missing-repo");
expect(result).toEqual({ archivedName: null });
expect(mockHttpPatch).not.toHaveBeenCalled();
});
});
+380 -5
View File
@@ -19,15 +19,19 @@ const mockCreatePreSyncBundleBackup = mock(() =>
let mockShouldCreatePreSyncBackup = false;
let mockShouldBlockSyncOnBackupFailure = true;
// Mock the database module
// Mock the database module. Every db.update(...).set(payload) is captured in
// dbUpdateSetCalls so tests can assert on what got written (e.g. archived
// repos keeping status "archived" after a Manual Sync).
const dbUpdateSetCalls: any[] = [];
const mockDb = {
insert: mock((table: any) => ({
values: mock((data: any) => Promise.resolve({ insertedId: "mock-id" }))
})),
update: mock(() => ({
set: mock(() => ({
where: mock(() => Promise.resolve())
}))
set: mock((data: any) => {
dbUpdateSetCalls.push(data);
return { where: mock(() => Promise.resolve()) };
})
}))
};
@@ -173,10 +177,74 @@ const mockHttpGet = mock(async (url: string, headers?: any) => {
headers: new Headers(),
};
}
// Only reachable at the "archived-{name}" candidate — the base name
// ("starred/broken-repo") deliberately falls through to the generic 404
// below, simulating a repo that archiveGiteaRepo already renamed in Gitea.
// original_url matches the test repository's GitHub source, so the
// fallback candidate's source-identity guard accepts it.
if (url.includes("/api/v1/repos/starred/archived-broken-repo")) {
return {
data: {
id: 792,
name: "archived-broken-repo",
mirror: true,
owner: { login: "starred" },
mirror_interval: "0h",
original_url: "https://github.com/user/broken-repo.git",
private: false,
},
status: 200,
statusText: "OK",
headers: new Headers(),
};
}
// Collision scenario: this archived mirror belongs to a DIFFERENT GitHub
// source (otheruser/collide-repo) that happens to share the base name with
// the test repository (user/collide-repo). The base name
// ("starred/collide-repo") falls through to the generic 404 below, so the
// archived-{name} fallback candidate is the only match — and its
// original_url must cause the source-identity guard to reject it.
if (url.includes("/api/v1/repos/starred/archived-collide-repo")) {
return {
data: {
id: 793,
name: "archived-collide-repo",
mirror: true,
owner: { login: "starred" },
mirror_interval: "0h",
original_url: "https://github.com/otheruser/collide-repo.git",
private: false,
},
status: 200,
statusText: "OK",
headers: new Headers(),
};
}
// Simulates Forgejo silently following a 301 redirect for a renamed repo:
// a GET for the STALE (pre-rename) path returns 200 with the repo's
// CURRENT identity in the response body (name differs from what was
// requested), exactly as Bun's fetch behaves after following Forgejo's
// redirect for a repo renamed from "renamed-repo" to
// "archived-renamed-repo". See #331 follow-up / canonical-identity
// adoption in syncGiteaRepoEnhanced.
if (url.includes("/api/v1/repos/starred/renamed-repo")) {
return {
data: {
id: 891,
name: "archived-renamed-repo",
mirror: true,
owner: { login: "starred" },
private: false,
},
status: 200,
statusText: "OK",
headers: new Headers(),
};
}
if (url.includes("/api/v1/repos/")) {
throw new MockHttpError("Not Found", 404, "Not Found");
}
// Handle org GET requests based on test context
if (url.includes("/api/v1/orgs/starred")) {
orgCheckCount++;
@@ -239,10 +307,18 @@ const mockHttpDelete = mock(async (url: string, headers?: any) => {
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
});
// Observable so tests can assert that the mirror-interval PATCH is (not)
// issued — e.g. archived repos must never have Gitea's periodic pulling
// re-enabled by a Manual Sync.
const mockHttpPatch = mock(async (url: string, body?: any, headers?: any) => {
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
});
mock.module("@/lib/http-client", () => ({
httpGet: mockHttpGet,
httpPost: mockHttpPost,
httpDelete: mockHttpDelete,
httpPatch: mockHttpPatch,
HttpError: MockHttpError
}));
@@ -284,6 +360,8 @@ describe("Enhanced Gitea Operations", () => {
mockHttpGet.mockClear();
mockHttpPost.mockClear();
mockHttpDelete.mockClear();
mockHttpPatch.mockClear();
dbUpdateSetCalls.length = 0;
mockCreatePreSyncBundleBackup.mockClear();
mockCreatePreSyncBundleBackup.mockImplementation(() =>
Promise.resolve({ bundlePath: "/tmp/mock.bundle" })
@@ -612,6 +690,303 @@ describe("Enhanced Gitea Operations", () => {
expect(String(mirrorSyncCalls[0][0])).not.toContain("/api/v1/repos/ceph/test-repo/mirror-sync");
});
test("falls back to the archived-{name} candidate when repository.status is 'archived'", async () => {
// Regression for #331 follow-up: repos archived before mirroredLocation
// was backfilled on rename (or any lingering false-positive orphan hit)
// are unreachable by name/expected-owner alone once archiveGiteaRepo has
// renamed them in Gitea to `archived-{sanitized name}`. syncGiteaRepoEnhanced
// must still find them via "Manual Sync" without manual intervention.
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
},
};
const repository: Repository = {
id: "repoArchived1",
name: "broken-repo",
fullName: "user/broken-repo",
owner: "user",
cloneUrl: "https://github.com/user/broken-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("archived"),
visibility: "public",
userId: "user123",
// No mirroredLocation recorded — this repo predates the DB backfill
// added alongside archiveGiteaRepo's new return value.
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(result).toEqual({ success: true });
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(1);
expect(String(mirrorSyncCalls[0][0])).toContain(
"/api/v1/repos/starred/archived-broken-repo/mirror-sync"
);
// The base (pre-archive) name must have been probed and rejected
// (404) before falling back to the archived-{name} candidate.
const repoInfoGets = mockHttpGet.mock.calls.filter((call) =>
String(call[0]).includes("/api/v1/repos/starred/")
);
expect(
repoInfoGets.some((call) =>
String(call[0]).endsWith("/api/v1/repos/starred/broken-repo")
)
).toBe(true);
expect(
repoInfoGets.some((call) =>
String(call[0]).endsWith("/api/v1/repos/starred/archived-broken-repo")
)
).toBe(true);
});
test("adopts canonical identity from response body when GET follows a stale-name redirect", async () => {
// Regression for #331 follow-up, verified end-to-end on Forgejo 15.0.3:
// when a repo has been renamed (e.g. by the orphan-archive flow, or
// manually by a user), Forgejo answers a GET for the OLD name with a
// 301 redirect to the new name. Bun's fetch follows it silently and
// returns 200 with the repo's CURRENT data in the body, while the
// code still has the stale name it requested. If syncGiteaRepoEnhanced
// kept using the requested (stale) name for the follow-up POST
// .../mirror-sync, that POST would hit the same 301, get its method
// downgraded to GET per the WHATWG redirect spec, and the POST-only
// endpoint would return 405. This must work for non-archived repos
// too — a user renaming a repo in Forgejo manually is the general case.
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
},
};
const repository: Repository = {
id: "repoRenamed1",
name: "renamed-repo",
fullName: "user/renamed-repo",
owner: "user",
cloneUrl: "https://github.com/user/renamed-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("mirrored"),
visibility: "public",
userId: "user123",
// Stale: recorded before the rename happened in Gitea/Forgejo.
mirroredLocation: "starred/renamed-repo",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(result).toEqual({ success: true });
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(1);
expect(String(mirrorSyncCalls[0][0])).toContain(
"/api/v1/repos/starred/archived-renamed-repo/mirror-sync"
);
expect(String(mirrorSyncCalls[0][0])).not.toContain(
"/api/v1/repos/starred/renamed-repo/mirror-sync"
);
});
test("keeps archived repos archived and skips the mirror-interval PATCH on Manual Sync", async () => {
// Documented contract (AutomationSettings.tsx): "Archive renames mirror
// backups with an archived- prefix and disables automatic syncs—use
// Manual Sync when you want to refresh." A successful Manual Sync of an
// archived repo must therefore refresh once WITHOUT (a) flipping status
// to "synced" (which would re-enroll it into the scheduler's auto-sync
// pool), (b) clearing the archived errorMessage annotation, or
// (c) PATCHing the mirror interval (which would re-enable Forgejo's own
// periodic pulling that archiveGiteaRepo disabled).
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
// Would normally trigger the mirror-interval PATCH on every sync.
mirrorInterval: "8h",
},
};
const repository: Repository = {
id: "repoArchived2",
name: "broken-repo",
fullName: "user/broken-repo",
owner: "user",
url: "https://github.com/user/broken-repo",
cloneUrl: "https://github.com/user/broken-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("archived"),
isArchived: true,
visibility: "public",
userId: "user123",
errorMessage: "Repository archived - no longer in GitHub",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(result).toEqual({ success: true });
// The mirror-sync itself must still happen (that's the point of
// Manual Sync on an archived repo).
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(1);
expect(String(mirrorSyncCalls[0][0])).toContain(
"/api/v1/repos/starred/archived-broken-repo/mirror-sync"
);
// No mirror-interval PATCH despite config.giteaConfig.mirrorInterval.
expect(mockHttpPatch).not.toHaveBeenCalled();
// The success-path DB update (the one recording lastMirrored) keeps
// status "archived" and does not clear errorMessage.
const successUpdate = dbUpdateSetCalls.find((data) => "lastMirrored" in data);
expect(successUpdate).toBeDefined();
expect(successUpdate.status).toBe("archived");
expect("errorMessage" in successUpdate).toBe(false);
expect(successUpdate.mirroredLocation).toBe("starred/archived-broken-repo");
});
test("rejects an archived-{name} fallback candidate whose original_url points at a different source", async () => {
// Two sources sharing a base name: the user mirrors user/collide-repo,
// but starred/archived-collide-repo in Gitea is the archived mirror of
// otheruser/collide-repo. The guessed archived-{name} fallback must be
// rejected via its original_url instead of syncing (and rewriting the
// DB row of) the wrong repository. With every candidate exhausted, the
// sync fails with the not-found error.
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
},
};
const repository: Repository = {
id: "repoCollide1",
name: "collide-repo",
fullName: "user/collide-repo",
owner: "user",
url: "https://github.com/user/collide-repo",
cloneUrl: "https://github.com/user/collide-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("archived"),
isArchived: true,
visibility: "public",
userId: "user123",
// No mirroredLocation — forces reliance on the guessed fallback.
createdAt: new Date(),
updatedAt: new Date(),
};
await expect(
syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
)
).rejects.toThrow("Repository collide-repo not found in Gitea. Tried locations:");
// The wrong repo must never receive a mirror-sync POST.
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(0);
// The fallback candidate WAS probed (and then rejected by the guard).
expect(
mockHttpGet.mock.calls.some((call) =>
String(call[0]).endsWith("/api/v1/repos/starred/archived-collide-repo")
)
).toBe(true);
});
test("blocks sync when pre-sync snapshot fails and blocking is enabled", async () => {
mockShouldCreatePreSyncBackup = true;
mockShouldBlockSyncOnBackupFailure = true;
+99 -6
View File
@@ -26,6 +26,7 @@ import {
strategyNeedsDetection,
} from "./repo-backup";
import { detectForcePush } from "./utils/force-push-detection";
import { sanitizeRepoNameAlphaDashDot } from "./gitea";
import {
parseRepositoryMetadataState,
serializeRepositoryMetadataState,
@@ -61,6 +62,27 @@ export interface GiteaRepoInfo {
interface SyncTargetCandidate {
owner: string;
repoName: string;
/**
* True for the guessed `archived-{name}` fallback candidate (see
* syncGiteaRepoEnhanced). Unlike the recorded mirroredLocation or the
* expected-owner candidate, this one is derived purely from the repo NAME,
* so it can collide with a different source repo that shares the same base
* name it must pass an original_url source check before being accepted.
*/
isArchivedFallback?: boolean;
}
/**
* Normalize a git remote URL for source-identity comparison: lowercase,
* strip trailing slashes and a trailing `.git`.
*/
function normalizeSourceUrl(url: string): string {
return url
.trim()
.toLowerCase()
.replace(/\/+$/, "")
.replace(/\.git$/, "")
.replace(/\/+$/, "");
}
function parseMirroredLocation(location?: string | null): SyncTargetCandidate | null {
@@ -329,12 +351,32 @@ export async function syncGiteaRepoEnhanced({
// Resolve sync target in a backward-compatible order:
// 1) recorded mirroredLocation (actual historical mirror location)
// 2) owner derived from current strategy/config
// 3) (archived repos only) the `archived-{name}` rename that
// archiveGiteaRepo applies to mirror repos (see #331 follow-up).
// Repos archived before mirroredLocation was backfilled on rename, or
// any lingering false-positive orphan hit, would otherwise be
// unreachable from "Manual Sync" — the UI's documented way to refresh
// an archived mirror — because the recorded/expected name no longer
// exists and Gitea returns HTTP 405 for it.
const dependencies = deps ?? (await import("./gitea"));
const expectedOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
const recordedTarget = parseMirroredLocation(repository.mirroredLocation);
// Archived state can live in either field: the cleanup service sets both,
// but a failed retry can clobber `status` while `isArchived` survives.
const isArchivedRepo =
repository.status === "archived" || !!repository.isArchived;
const candidateTargets = dedupeSyncTargets([
...(recordedTarget ? [recordedTarget] : []),
{ owner: expectedOwner, repoName: repository.name },
...(isArchivedRepo
? [
{
owner: expectedOwner,
repoName: `archived-${sanitizeRepoNameAlphaDashDot(repository.name)}`,
isArchivedFallback: true,
},
]
: []),
]);
let repoOwner = expectedOwner;
@@ -360,8 +402,45 @@ export async function syncGiteaRepoEnhanced({
continue;
}
repoOwner = target.owner;
repoName = target.repoName;
// The archived-{name} fallback candidate is guessed from the repo NAME
// alone, so it can hit a DIFFERENT source's archived mirror when two
// sources share a base name (e.g. foo/tools and bar/tools both mirrored;
// foo's archived as `archived-tools`). Before accepting it, verify the
// candidate's original_url (the authoritative migration source — see
// GiteaRepoInfo) points at THIS repository's GitHub source. An empty/
// unset original_url is accepted as before (some migrations leave it
// unset). This guard deliberately does NOT apply to the recorded
// mirroredLocation or expected-name candidates.
if (
target.isArchivedFallback &&
typeof candidateInfo.original_url === "string" &&
candidateInfo.original_url.trim() !== ""
) {
const candidateSource = normalizeSourceUrl(candidateInfo.original_url);
const ownSources = [repository.cloneUrl, repository.url]
.filter((u): u is string => typeof u === "string" && u.trim() !== "")
.map(normalizeSourceUrl);
if (!ownSources.includes(candidateSource)) {
console.warn(
`[Sync] Skipping archived-name candidate ${target.owner}/${target.repoName} for ${repository.name}: its original_url (${candidateInfo.original_url}) points at a different source`
);
continue;
}
}
// Adopt the canonical identity from the response, not the requested target.
// Gitea/Forgejo answer GETs for a renamed repo's old name with a 301 that
// fetch follows silently, so `target` may be a stale pre-rename name; a
// follow-up POST (mirror-sync) to the stale path gets its method downgraded
// by the redirect and fails with 405. The response body always carries the
// repo's current name/owner (#331 follow-up, verified on Forgejo 15).
const canonicalOwner =
typeof candidateInfo.owner === "string"
? candidateInfo.owner
: candidateInfo.owner?.login;
repoOwner = canonicalOwner || target.owner;
repoName = candidateInfo.name || target.repoName;
repoInfo = candidateInfo;
break;
}
@@ -621,7 +700,13 @@ export async function syncGiteaRepoEnhanced({
// NOTE: Gitea/Forgejo's PATCH /repos/{owner}/{repo} API does not support
// updating mirror credentials (mirror_username/mirror_password). Repos that
// were originally migrated without credentials must be deleted and re-mirrored.
if (config.giteaConfig?.mirrorInterval) {
//
// Skipped for archived repos: archiveGiteaRepo deliberately disabled
// Gitea's own periodic pulling, and the documented contract
// (AutomationSettings.tsx: "Archive ... disables automatic syncs—use
// Manual Sync when you want to refresh") says a Manual Sync refreshes
// once without re-enabling any automatic syncing.
if (config.giteaConfig?.mirrorInterval && !isArchivedRepo) {
try {
console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}`;
@@ -844,14 +929,22 @@ export async function syncGiteaRepoEnhanced({
metadataState.lastSyncedAt = new Date().toISOString();
}
// Mark repo as "synced" in DB
// Mark repo as "synced" in DB — unless it's archived. The documented
// contract (AutomationSettings.tsx: "Archive ... disables automatic
// syncs—use Manual Sync when you want to refresh") means a Manual Sync
// of an archived repo must NOT re-enroll it into the scheduler's
// auto-sync pool (which selects mirrored/synced/failed/pending) nor
// clear the archived errorMessage annotation shown in the UI; it still
// records lastMirrored and the (possibly corrected) mirroredLocation.
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("synced"),
status: isArchivedRepo
? repoStatusEnum.parse("archived")
: repoStatusEnum.parse("synced"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
...(isArchivedRepo ? {} : { errorMessage: null }),
mirroredLocation: `${repoOwner}/${repoName}`,
metadata: metadataUpdated
? serializeRepositoryMetadataState(metadataState)
+52
View File
@@ -0,0 +1,52 @@
/**
* Regression test for the #334 sibling bug labels silently dropped on issue update.
*
* Gitea/Forgejo's `EditIssueOption` has no `labels` field (only `CreateIssueOption`
* does), so a `labels` key in a `PATCH .../issues/{index}` body is silently ignored.
* The old code put `labels` in the update PATCH, so label changes never propagated
* onto already-mirrored issues. The fix builds the edit body WITHOUT labels
* (`buildGiteaIssueEditPayload`) and reconciles labels separately through the
* sub-resource `PUT .../issues/{index}/labels` (`buildGiteaIssueLabelsPayload`).
*
* Verified live against Gitea 1.24.7: PATCH with `labels` leaves the issue's labels
* unchanged; PUT to the labels sub-resource replaces them. Confirmed end-to-end that
* a drifted (label-less) mirrored issue reconciles back to its GitHub label set.
*/
import { describe, test, expect } from "bun:test";
import { buildGiteaIssueEditPayload, buildGiteaIssueLabelsPayload } from "@/lib/gitea";
describe("buildGiteaIssueEditPayload (#334 sibling)", () => {
test("edit body never carries `labels` (Gitea's EditIssueOption ignores it)", () => {
const payload = buildGiteaIssueEditPayload({
title: "[GH-ISSUE #1] Fix the thing",
body: "desc",
closed: false,
});
expect(payload).not.toHaveProperty("labels");
expect(payload).toEqual({
title: "[GH-ISSUE #1] Fix the thing",
body: "desc",
state: "open",
});
});
test("maps the closed flag to Gitea's `state`", () => {
expect(buildGiteaIssueEditPayload({ title: "t", body: "b", closed: true }).state).toBe("closed");
expect(buildGiteaIssueEditPayload({ title: "t", body: "b", closed: false }).state).toBe("open");
});
});
describe("buildGiteaIssueLabelsPayload (#334 sibling)", () => {
test("replaces the full label set with the resolved Gitea label ids", () => {
expect(buildGiteaIssueLabelsPayload([7, 9])).toEqual({ labels: [7, 9] });
});
test("sends an empty set so upstream label removals propagate", () => {
expect(buildGiteaIssueLabelsPayload([])).toEqual({ labels: [] });
});
test("treats a missing id list as an empty set (defensive)", () => {
expect(buildGiteaIssueLabelsPayload(undefined as any)).toEqual({ labels: [] });
});
});
@@ -0,0 +1,375 @@
/**
* Behavioral tests for issue #343 / PR #344:
* bulk "Mirror Organization" must honor the canonical destination
* precedence (starred mode > repo override > org override > strategy).
*
* NOTE: these tests replace @/lib/db, @/lib/gitea-enhanced, @/lib/http-client,
* @/lib/helpers, and globalThis.fetch with mocks. bun's mock.module (and the
* fetch swap) are process-wide and leak into other test files, so in the
* shared test process this file registers NOTHING: it re-runs itself in an
* isolated child process (`bun test <this file>` with GITEA_ORG_MIRROR_DEST_ISOLATED=1)
* where the mocks are safely contained.
*/
import { describe, test, expect, mock, beforeEach } from "bun:test";
const CHILD_FLAG = "GITEA_ORG_MIRROR_DEST_ISOLATED";
const isChild = !!process.env[CHILD_FLAG];
if (!isChild) {
test("bulk org mirror destination routing (#343) — isolated child suite", () => {
const res = Bun.spawnSync({
cmd: [process.execPath, "test", import.meta.path],
env: { ...process.env, [CHILD_FLAG]: "1" },
stdout: "pipe",
stderr: "pipe",
});
if (res.exitCode !== 0) {
console.error(res.stdout.toString());
console.error(res.stderr.toString());
}
expect(res.exitCode).toBe(0);
}, 60_000);
}
// ---------------------------------------------------------------------------
// Shared mutable state the module mocks read from / write to
// ---------------------------------------------------------------------------
/** Rows returned by the FIRST select on the repositories table (the orgRepos query). */
let orgRepoRows: any[] = [];
/** Row returned for getOrganizationConfig (select from organizations .limit(1)). */
let orgConfigRows: any[] = [];
/** Counts selects against the repositories table. */
let repoSelectCount = 0;
/** Every httpPost call: { url, payload }. */
let httpPostCalls: Array<{ url: string; payload: any }> = [];
/** Every org get-or-create: orgName -> deterministic id. */
let orgCreateCalls: string[] = [];
// Deterministic id derived purely from the name — must not depend on call
// order: bun may re-instantiate mock factories, so an order-dependent counter
// can diverge between the mocked flow and the test's assertions.
function orgIdFor(name: string): number {
let h = 0;
for (const c of name) h = (h * 31 + c.charCodeAt(0)) % 100_000;
return 100 + h;
}
// ---------------------------------------------------------------------------
// Module mocks (must be registered before importing ./gitea)
// ---------------------------------------------------------------------------
const repositoriesTable = { __table: "repositories" } as any;
const organizationsTable = { __table: "organizations" } as any;
function promiseWithLimit(rows: any[], limitRows?: any[]) {
const p: any = Promise.resolve(rows);
p.limit = () => Promise.resolve(limitRows ?? rows);
return p;
}
// Everything below only exists in the isolated child process — registering
// these mocks in the shared test process poisons other test files.
if (isChild) {
mock.module("@/lib/db", () => {
const mockDb = {
select: (_fields?: any) => ({
from: (table: any) => ({
where: (_cond: any) => {
if (table === repositoriesTable) {
repoSelectCount++;
// First repositories select in mirrorGitHubOrgToGitea is the
// orgRepos query; everything after (idempotency checks, name
// claims) must see no rows.
const rows = repoSelectCount === 1 ? orgRepoRows : [];
return promiseWithLimit(rows, []);
}
if (table === organizationsTable) {
return promiseWithLimit(orgConfigRows, orgConfigRows);
}
return promiseWithLimit([], []);
},
}),
}),
update: (_table: any) => ({
set: (_data: any) => ({ where: (_cond: any) => Promise.resolve() }),
}),
insert: (_table: any) => ({ values: (_data: any) => Promise.resolve() }),
delete: (_table: any) => ({ where: (_cond: any) => Promise.resolve() }),
};
return {
db: mockDb,
repositories: repositoriesTable,
organizations: organizationsTable,
configs: {},
mirrorJobs: {},
users: {},
events: {},
sessions: {},
accounts: {},
};
});
mock.module("@/lib/helpers", () => ({
createMirrorJob: mock(async () => "job-id"),
}));
const actualHttp = await import("./http-client");
const actualEnhanced = await import("./gitea-enhanced");
const actualConfigEncryption = await import("./utils/config-encryption");
mock.module("@/lib/http-client", () => ({
...actualHttp,
httpGet: mock(async (url: string) => {
throw new actualHttp.HttpError(`GET ${url} -> 404`, 404, "not found");
}),
httpPost: mock(async (url: string, payload: any) => {
httpPostCalls.push({ url, payload });
return { data: { id: 1, ...payload }, status: 201, statusText: "Created", headers: new Headers() };
}),
httpPut: mock(async () => ({ data: {}, status: 200, statusText: "OK", headers: new Headers() })),
httpPatch: mock(async () => ({ data: {}, status: 200, statusText: "OK", headers: new Headers() })),
httpDelete: mock(async () => ({ data: {}, status: 204, statusText: "No Content", headers: new Headers() })),
}));
mock.module("@/lib/gitea-enhanced", () => ({
...actualEnhanced,
getOrCreateGiteaOrgEnhanced: mock(async ({ orgName }: any) => {
orgCreateCalls.push(orgName);
return orgIdFor(orgName);
}),
getGiteaRepoInfo: mock(async () => null),
handleExistingNonMirrorRepo: mock(async () => {}),
}));
// NOTE: @/lib/utils/mirror-source-match is deliberately NOT mocked. Its real
// implementation resolves to "no existing mirror / name available" naturally
// under the db/fetch/gitea-enhanced mocks above, and module-mocking it
// poisons mirror-source-match.test.ts (bun mock.module is process-wide).
mock.module("@/lib/utils/config-encryption", () => ({
...actualConfigEncryption,
decryptConfigTokens: (config: any) => config,
}));
// isRepoPresentInGitea uses global fetch directly.
globalThis.fetch = mock(async () =>
new Response("not found", { status: 404 })
) as any;
} // end if (isChild)
const { mirrorGitHubOrgToGitea } = isChild
? await import("./gitea")
: ({ mirrorGitHubOrgToGitea: undefined as any });
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeConfig(overrides: any = {}): any {
return {
id: "config-1",
userId: "user-1",
githubConfig: {
token: "gh-token",
owner: "me",
...(overrides.githubConfig || {}),
},
giteaConfig: {
url: "https://gitea.test",
token: "gitea-token",
defaultOwner: "meuser",
addTopics: false,
...(overrides.giteaConfig || {}),
},
};
}
function makeOrg(overrides: any = {}): any {
return {
id: "org-db-1",
userId: "user-1",
name: "A",
membershipRole: "member",
isIncluded: true,
status: "imported",
repositoryCount: 1,
createdAt: new Date(),
updatedAt: new Date(),
destinationOrg: null,
...overrides,
};
}
function makeRepo(overrides: any = {}): any {
return {
id: "repo-1",
userId: "user-1",
configId: "config-1",
name: "r1",
fullName: "A/r1",
owner: "A",
organization: "A",
url: "https://github.com/A/r1",
cloneUrl: "https://github.com/A/r1.git",
isPrivate: false,
isForked: false,
forkedFrom: null,
hasIssues: false,
isStarred: false,
isArchived: false,
size: 0,
hasLFS: false,
hasSubmodules: false,
defaultBranch: "main",
visibility: "public",
status: "imported",
destinationOrg: null,
lastMirrored: null,
errorMessage: null,
mirroredLocation: "",
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
const fakeOctokit = {} as any;
function migrateCalls() {
return httpPostCalls.filter((c) => c.url.includes("/repos/migrate"));
}
beforeEach(() => {
orgRepoRows = [];
orgConfigRows = [];
repoSelectCount = 0;
httpPostCalls = [];
orgCreateCalls = [];
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe.skipIf(!isChild)("mirrorGitHubOrgToGitea destination routing (#343)", () => {
test("Scenario 1: preserve strategy honors organization destinationOrg override", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } });
const organization = makeOrg({ destinationOrg: "B" });
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
// The override org must be created; the GitHub-named org must NOT be.
expect(orgCreateCalls).toContain("B");
expect(orgCreateCalls).not.toContain("A");
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(migrates[0].payload.uid).toBe(orgIdFor("B"));
expect(migrates[0].payload.repo_name).toBe("r1");
});
test("Scenario 2: mixed strategy sends org repos to the GitHub-named org with a defined uid", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "mixed" } });
const organization = makeOrg();
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
// The main-branch bug: uid was undefined -> dropped by JSON.stringify ->
// Gitea defaulted the owner to the authenticated user.
expect(migrates[0].payload.uid).toBeDefined();
expect(migrates[0].payload.uid).toBe(orgIdFor("A"));
expect(orgCreateCalls).toContain("A");
});
test("Scenario 3: starred repo in a bulk org mirror follows starred-repo mode", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "mixed" } });
const organization = makeOrg();
orgRepoRows = [
makeRepo({ id: "repo-2", name: "tools", fullName: "A/tools", isStarred: true }),
];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(orgCreateCalls).toContain("starred");
expect(migrates[0].payload.uid).toBe(orgIdFor("starred"));
});
test("per-repo destinationOrg override beats org override and strategy", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } });
const organization = makeOrg({ destinationOrg: "B" });
orgRepoRows = [makeRepo({ destinationOrg: "C" })];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(migrates[0].payload.uid).toBe(orgIdFor("C"));
expect(orgCreateCalls).toContain("C");
});
test("regression: preserve strategy without overrides keeps GitHub org name and creates the org once", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } });
const organization = makeOrg();
orgRepoRows = [
makeRepo(),
makeRepo({ id: "repo-3", name: "r2", fullName: "A/r2", cloneUrl: "https://github.com/A/r2.git" }),
];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(2);
for (const call of migrates) {
expect(call.payload.uid).toBe(orgIdFor("A"));
}
// Pre-created once at the top; the per-repo loop must reuse it.
expect(orgCreateCalls.filter((n) => n === "A").length).toBe(1);
});
test("regression: single-org strategy without overrides routes to the configured org", async () => {
const config = makeConfig({
githubConfig: { mirrorStrategy: "single-org" },
giteaConfig: { organization: "hub" },
});
const organization = makeOrg();
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(migrates[0].payload.uid).toBe(orgIdFor("hub"));
expect(orgCreateCalls.filter((n) => n === "hub").length).toBe(1);
});
test("regression: flat-user strategy without overrides mirrors to the user account (repo_owner, no org)", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "flat-user" } });
const organization = makeOrg();
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(migrates[0].payload.repo_owner).toBe("meuser");
expect(migrates[0].payload.uid).toBeUndefined();
expect(orgCreateCalls.length).toBe(0);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Regression test for #334 "Release titles not being mirrored properly".
*
* Root cause: the release create/update payloads sent the release title under the
* JSON key `title`, but Gitea/Forgejo's release API expects `name` (the API Go
* struct is `Title string \`json:"name"\``). `title` is silently dropped, so every
* mirrored release landed with a blank name.
*
* `buildGiteaReleasePayload` is the single source of truth for both the create
* (POST) and update (PATCH) bodies. Verified live against Gitea 1.24.7: a payload
* with `title` yields `name: ""`; a payload with `name` sets the title correctly.
*/
import { describe, test, expect } from "bun:test";
import { buildGiteaReleasePayload } from "@/lib/gitea";
describe("buildGiteaReleasePayload (#334)", () => {
test("carries the release title under `name`, never `title`", () => {
const payload = buildGiteaReleasePayload(
{ tag_name: "v0.19.0", name: "v0.19.0", draft: false, prerelease: false },
"## Features\n- something"
);
expect(payload.name).toBe("v0.19.0");
expect(payload).not.toHaveProperty("title");
expect(payload).toEqual({
tag_name: "v0.19.0",
name: "v0.19.0",
body: "## Features\n- something",
draft: false,
prerelease: false,
});
});
test("falls back to tag_name when the GitHub release name is empty or null", () => {
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3", name: null }, "x").name).toBe("v1.2.3");
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3", name: "" }, "x").name).toBe("v1.2.3");
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3" }, "x").name).toBe("v1.2.3");
});
test("passes draft/prerelease/body through unchanged", () => {
const payload = buildGiteaReleasePayload(
{ tag_name: "v2.0.0", name: "Two", draft: true, prerelease: true },
"notes body"
);
expect(payload.body).toBe("notes body");
expect(payload.draft).toBe(true);
expect(payload.prerelease).toBe(true);
expect(payload.tag_name).toBe("v2.0.0");
});
});
+262 -63
View File
@@ -1995,11 +1995,20 @@ export async function mirrorGitHubOrgToGitea({
const mirrorStrategy = config.githubConfig?.mirrorStrategy ||
(config.giteaConfig?.preserveOrgStructure ? "preserve" : "flat-user");
let giteaOrgId: number;
let giteaOrgId: number | undefined;
let targetOrgName: string;
// Determine the target organization based on strategy
if (mirrorStrategy === "single-org" && config.giteaConfig?.organization) {
if (organization.destinationOrg) {
// Organization-level override takes precedence over the strategy
targetOrgName = organization.destinationOrg;
giteaOrgId = await getOrCreateGiteaOrg({
orgId: organization.id,
orgName: targetOrgName,
config,
});
console.log(`Using organization override: ${organization.name} -> ${targetOrgName}`);
} else if (mirrorStrategy === "single-org" && config.giteaConfig?.organization) {
// For single-org strategy, use the configured destination organization
targetOrgName = config.giteaConfig.organization || config.giteaConfig.defaultOwner;
giteaOrgId = await getOrCreateGiteaOrg({
@@ -2017,9 +2026,15 @@ export async function mirrorGitHubOrgToGitea({
config,
});
} else {
// For flat-user strategy, we shouldn't create organizations at all
// Skip organization creation and let individual repos be handled by getGiteaRepoOwner
console.log(`Using flat-user strategy: repos will be placed under user account`);
// flat-user: no organizations should be created at all.
// mixed: org repos resolve per-repo via getGiteaRepoOwnerAsync in the
// loop below (creating their target orgs on demand), so there is
// nothing to pre-create here either.
console.log(
mirrorStrategy === "mixed"
? `Using mixed strategy: repos will be resolved per-repo (orgs created on demand)`
: `Using flat-user strategy: repos will be placed under user account`
);
targetOrgName = config.giteaConfig?.defaultOwner || "";
}
@@ -2062,23 +2077,35 @@ export async function mirrorGitHubOrgToGitea({
`Starting mirror for repository: ${repo.name} from GitHub org ${organization.name}`
);
// Mirror the repository based on strategy
if (mirrorStrategy === "flat-user") {
// For flat-user strategy, mirror directly to user account
// Resolve per repo with the canonical precedence
const owner = await getGiteaRepoOwnerAsync({ config, repository: repoData });
if (owner === config.giteaConfig?.defaultOwner) {
await mirrorGithubRepoToGitea({
octokit,
repository: repoData,
config,
});
} else {
// For preserve and single-org strategies, use organization
} else if (owner === targetOrgName && giteaOrgId !== undefined) {
await mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository: repoData,
giteaOrgId: giteaOrgId!,
giteaOrgId,
orgName: targetOrgName,
});
} else {
const ownerOrgId = await getOrCreateGiteaOrg({
orgName: owner,
config,
});
await mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository: repoData,
giteaOrgId: ownerOrgId,
orgName: owner,
});
}
return repo;
@@ -2186,6 +2213,98 @@ export const syncGiteaRepo = async ({
}
};
/**
* Build the JSON body for creating/updating a Gitea release.
*
* Gitea/Forgejo expose the release title through the JSON field `name`, not
* `title` (the API Go struct is `Title string \`json:"name"\``); sending `title`
* is silently ignored and leaves the release name blank (#334). Create and update
* send the same fields; `target` is intentionally omitted (see #331/#333) so Gitea
* attaches the release to the already-synced tag instead of 404-ing on the target.
*/
export function buildGiteaReleasePayload(
release: { tag_name: string; name?: string | null; draft?: boolean; prerelease?: boolean },
releaseNote: string
): { tag_name: string; name: string; body: string; draft?: boolean; prerelease?: boolean } {
return {
tag_name: release.tag_name,
name: release.name || release.tag_name,
body: releaseNote,
draft: release.draft,
prerelease: release.prerelease,
};
}
/**
* Build the JSON body for a Gitea issue / PR-as-issue edit (PATCH .../issues/{index}).
*
* Deliberately excludes `labels`: Gitea's `EditIssueOption` has no `labels` field
* (only `CreateIssueOption` does), so any `labels` key here is silently dropped
* the same class of bug as the release `title` mix-up (#334 sibling). Labels are
* applied separately via the labels sub-resource (see buildGiteaIssueLabelsPayload).
*/
export function buildGiteaIssueEditPayload(opts: {
title: string;
body: string;
closed: boolean;
}): { title: string; body: string; state: "open" | "closed" } {
return { title: opts.title, body: opts.body, state: opts.closed ? "closed" : "open" };
}
/**
* Build the JSON body for the Gitea issue labels sub-resource
* (PUT .../issues/{index}/labels), which replaces the full label set idempotently.
*/
export function buildGiteaIssueLabelsPayload(labelIds: number[]): { labels: number[] } {
return { labels: labelIds ?? [] };
}
/**
* Replace the label set on an existing Gitea issue (or PR-as-issue) via the
* dedicated labels sub-resource.
*
* Gitea/Forgejo's `EditIssueOption` has no `labels` field (only
* `CreateIssueOption` does), so a `labels` key in a `PATCH .../issues/{index}`
* body is silently dropped by the JSON decoder the same class of bug as the
* release `title` vs `name` mix-up (#334). Label changes on an already-mirrored
* issue therefore have to go through `PUT .../issues/{index}/labels`, which
* replaces the whole set idempotently: it both applies newly added labels and
* removes ones deleted upstream.
*
* Best-effort: labels are secondary metadata, so a transient failure here is
* logged and left to self-heal on the next sync rather than failing (and
* retrying) the entire issue + comment mirror.
*/
async function reconcileGiteaIssueLabels({
config,
decryptedConfig,
giteaOwner,
repoName,
issueNumber,
labelIds,
}: {
config: Partial<Config>;
decryptedConfig: Config;
giteaOwner: string;
repoName: string;
issueNumber: number;
labelIds: number[];
}): Promise<void> {
try {
await httpPut(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${issueNumber}/labels`,
buildGiteaIssueLabelsPayload(labelIds),
{ Authorization: `token ${decryptedConfig.giteaConfig!.token}` }
);
} catch (error) {
console.warn(
`[Labels] Failed to reconcile labels on issue #${issueNumber}: ${
error instanceof Error ? error.message : String(error)
} (will retry on next sync)`
);
}
}
export const mirrorGitRepoIssuesToGitea = async ({
config,
octokit,
@@ -2437,12 +2556,11 @@ export const mirrorGitRepoIssuesToGitea = async ({
targetIssueNumber = existingIssue.number;
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
{
buildGiteaIssueEditPayload({
title: issuePayload.title,
body: issuePayload.body,
state: issue.state === "closed" ? "closed" : "open",
labels: issuePayload.labels,
},
closed: issue.state === "closed",
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
@@ -2487,12 +2605,11 @@ export const mirrorGitRepoIssuesToGitea = async ({
);
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
{
buildGiteaIssueEditPayload({
title: issuePayload.title,
body: issuePayload.body,
state: issue.state === "closed" ? "closed" : "open",
labels: issuePayload.labels,
},
closed: issue.state === "closed",
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
@@ -2531,6 +2648,21 @@ export const mirrorGitRepoIssuesToGitea = async ({
}
}
// Gitea's EditIssueOption ignores `labels`, so the PATCH above can't change
// them on an already-mirrored issue — reconcile via the labels sub-resource.
// Only needed on the update paths; a freshly POSTed issue already got its
// labels from CreateIssueOption. (#334 sibling)
if (existingIssue) {
await reconcileGiteaIssueLabels({
config,
decryptedConfig,
giteaOwner,
repoName,
issueNumber: targetIssueNumber,
labelIds: giteaLabelIds,
});
}
// Clone comments
const comments = await octokit.paginate(
octokit.rest.issues.listComments,
@@ -2881,6 +3013,14 @@ export async function mirrorGitHubReleasesToGitea({
const repoOwner = giteaOwner || (await getGiteaRepoOwnerAsync({ config, repository }));
const repoName = giteaRepoName || repository.name;
// Derive GITHUB coordinates from fullName, matching the issues/PRs/labels/
// milestones mirror functions (`const [owner, repo] = repository.fullName.split("/")`).
// repository.name/owner can drift from the GitHub source (e.g. Gitea-side
// renames), so fullName is authoritative; fall back only if it's malformed.
const [fullNameOwner, fullNameRepo] = (repository.fullName || "").split("/");
const githubOwner = fullNameOwner && fullNameRepo ? fullNameOwner : repository.owner;
const githubRepo = fullNameOwner && fullNameRepo ? fullNameRepo : repository.name;
// Verify the repository exists in Gitea before attempting to mirror releases
console.log(`[Releases] Verifying repository ${repoName} exists at ${repoOwner}`);
const repoExists = await isRepoPresentInGitea({
@@ -2906,8 +3046,8 @@ export async function mirrorGitHubReleasesToGitea({
while (releases.length < releaseLimit) {
const response = await octokit.rest.repos.listReleases({
owner: repository.owner,
repo: repository.name,
owner: githubOwner,
repo: githubRepo,
per_page: perPage,
page,
});
@@ -2938,6 +3078,7 @@ export async function mirrorGitHubReleasesToGitea({
let mirroredCount = 0;
let skippedCount = 0;
let skippedMissingTagCount = 0;
let totalAssetsUploaded = 0;
let totalAssetsFailed = 0;
@@ -2994,14 +3135,7 @@ export async function mirrorGitHubReleasesToGitea({
await httpPatch(
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${existingRelease.id}`,
{
tag_name: release.tag_name,
target: release.target_commitish,
title: release.name || release.tag_name,
body: releaseNote,
draft: release.draft,
prerelease: release.prerelease,
},
buildGiteaReleasePayload(release, releaseNote),
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
@@ -3040,23 +3174,39 @@ export async function mirrorGitHubReleasesToGitea({
continue;
}
// The git tag must already exist in Gitea before we create a release for it.
// For a mirror, tags are synced from upstream by Gitea's own git mirror, which
// can lag behind this metadata sync (e.g. a large/slow initial clone). If the
// tag isn't present yet, skip and let a later sync pick it up — do NOT ask Gitea
// to create the release against a `target` branch:
// - if the target can't be resolved Gitea returns 404 "The target couldn't be
// found" and the release is lost (#331),
// - if it can, Gitea would create a brand-new tag at the wrong commit.
const tagExists = await httpGet(
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/tags/${encodeURIComponent(release.tag_name)}`,
{ Authorization: `token ${decryptedConfig.giteaConfig.token}` }
)
.then(() => true)
.catch(() => false);
if (!tagExists) {
console.warn(
`[Releases] Tag ${release.tag_name} is not present in Gitea yet — skipping release for now (the git mirror may still be syncing; it will be retried on the next sync)`
);
skippedMissingTagCount++;
continue;
}
// Create new release with changelog/body content (includes GitHub date header)
if (originalReleaseNote) {
console.log(`[Releases] Including changelog for ${release.tag_name} (${originalReleaseNote.length} characters + GitHub date header)`);
} else {
console.log(`[Releases] Creating release ${release.tag_name} with GitHub date header (no changelog)`);
}
const createReleaseResponse = await httpPost(
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases`,
{
tag_name: release.tag_name,
target: release.target_commitish,
title: release.name || release.tag_name,
body: releaseNote,
draft: release.draft,
prerelease: release.prerelease,
},
buildGiteaReleasePayload(release, releaseNote),
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
@@ -3087,9 +3237,15 @@ export async function mirrorGitHubReleasesToGitea({
}
console.log(
`✅ Mirrored/Updated ${mirroredCount} releases to Gitea (${skippedCount} already up-to-date); assets uploaded: ${totalAssetsUploaded}, failed: ${totalAssetsFailed}`
`✅ Mirrored/Updated ${mirroredCount} releases to Gitea (${skippedCount} already up-to-date, ${skippedMissingTagCount} skipped: tag not synced yet); assets uploaded: ${totalAssetsUploaded}, failed: ${totalAssetsFailed}`
);
if (skippedMissingTagCount > 0) {
console.warn(
`[Releases] ${skippedMissingTagCount} release(s) skipped because their git tag is not in Gitea yet for ${repository.fullName} — these will be created automatically once the git mirror finishes syncing the tags`
);
}
if (totalAssetsFailed > 0) {
console.error(
`[Releases] ⚠️ ${totalAssetsFailed} release asset(s) failed to mirror for ${repository.fullName} — they will be retried on the next sync`
@@ -3438,12 +3594,11 @@ export async function mirrorGitRepoPullRequestsToGitea({
if (existingPrIssue) {
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
{
buildGiteaIssueEditPayload({
title: issueData.title,
body: issueData.body,
state: issueData.closed ? "closed" : "open",
labels: issueData.labels,
},
closed: issueData.closed,
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
@@ -3482,6 +3637,20 @@ export async function mirrorGitRepoPullRequestsToGitea({
}
}
// Gitea drops `labels` on issue edit, so the "pull-request" marker label
// can't be set via the PATCH above — reconcile it on the update path.
// (#334 sibling)
if (existingPrIssue) {
await reconcileGiteaIssueLabels({
config,
decryptedConfig,
giteaOwner,
repoName,
issueNumber: existingPrIssue.number,
labelIds: issueData.labels,
});
}
successCount++;
console.log(`[Pull Requests] ✅ Successfully created issue for PR #${pr.number}`);
} catch (apiError) {
@@ -3526,12 +3695,11 @@ export async function mirrorGitRepoPullRequestsToGitea({
if (existingPrIssue) {
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
{
buildGiteaIssueEditPayload({
title: basicIssueData.title,
body: basicIssueData.body,
state: basicIssueData.closed ? "closed" : "open",
labels: basicIssueData.labels,
},
closed: basicIssueData.closed,
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
@@ -3569,6 +3737,19 @@ export async function mirrorGitRepoPullRequestsToGitea({
}
}
// Same as the enriched path — reconcile the marker label via the labels
// sub-resource since PATCH ignores it. (#334 sibling)
if (existingPrIssue) {
await reconcileGiteaIssueLabels({
config,
decryptedConfig,
giteaOwner,
repoName,
issueNumber: existingPrIssue.number,
labelIds: basicIssueData.labels,
});
}
successCount++;
console.log(`[Pull Requests] ✅ Created basic issue for PR #${pr.number}`);
} catch (error) {
@@ -3898,29 +4079,43 @@ export async function deleteGiteaRepo(
}
}
/**
* Sanitize a repository name to satisfy Gitea's AlphaDashDot rule for repo
* names (letters, digits, `.`, and `-`; no leading/trailing separators).
*
* Extracted from archiveGiteaRepo (module-level, exported) so gitea-enhanced.ts
* can build the identical `archived-{name}` candidate when probing for a repo
* that was renamed by this exact archive flow either just now, or by an
* older version of this code before mirroredLocation was backfilled on rename
* (see #331 follow-up).
*/
export function sanitizeRepoNameAlphaDashDot(name: string): string {
// Replace anything that's not [A-Za-z0-9.-] with '-'
const base = name.replace(/[^A-Za-z0-9.-]+/g, "-").replace(/-+/g, "-");
// Trim leading/trailing separators and dots for safety
return base.replace(/^[.-]+/, "").replace(/[.-]+$/, "");
}
/**
* Archive a repository in Gitea
*
*
* IMPORTANT: This function NEVER deletes data. It only marks repositories as archived.
* - For regular repos: Uses Gitea's archive feature (makes read-only)
* - For mirror repos: Renames with [ARCHIVED] prefix (Gitea doesn't allow archiving mirrors)
*
*
* This ensures backups are preserved even when the GitHub source disappears.
*
* Returns the Gitea-side name the repository ended up with after a rename
* (mirror path), or `null` when no rename occurred (regular-repo archive
* path, or any failure). Callers that persist `mirroredLocation` should only
* update it when `archivedName` is non-null.
*/
export async function archiveGiteaRepo(
client: { url: string; token: string },
owner: string,
repo: string
): Promise<void> {
): Promise<{ archivedName: string | null }> {
try {
// Helper: sanitize to Gitea's AlphaDashDot rule
const sanitizeRepoNameAlphaDashDot = (name: string): string => {
// Replace anything that's not [A-Za-z0-9.-] with '-'
const base = name.replace(/[^A-Za-z0-9.-]+/g, "-").replace(/-+/g, "-");
// Trim leading/trailing separators and dots for safety
return base.replace(/^[.-]+/, "").replace(/[.-]+$/, "");
};
// First, check if this is a mirror repository
const repoResponse = await httpGet(
`${client.url}/api/v1/repos/${owner}/${repo}`,
@@ -3931,7 +4126,7 @@ export async function archiveGiteaRepo(
if (!repoResponse.data) {
console.warn(`[Archive] Repository ${owner}/${repo} not found in Gitea. Skipping.`);
return;
return { archivedName: null };
}
if (repoResponse.data?.mirror) {
@@ -3953,7 +4148,7 @@ export async function archiveGiteaRepo(
normalizedName.startsWith('archived-')
) {
console.log(`[Archive] Repository ${owner}/${repo} already marked as archived. Skipping.`);
return;
return { archivedName: currentName };
}
// Use a safe prefix and sanitize the name to satisfy AlphaDashDot rule
@@ -3998,7 +4193,7 @@ export async function archiveGiteaRepo(
// If this also fails, log but don't throw - data remains preserved
console.error(`[Archive] Failed to rename mirror repository ${owner}/${repo}:`, e2);
console.log(`[Archive] Repository ${owner}/${repo} remains accessible but not marked as archived`);
return;
return { archivedName: null };
}
}
@@ -4022,6 +4217,8 @@ export async function archiveGiteaRepo(
// Non-critical - repo is still preserved even if we can't change interval
console.debug(`[Archive] Could not disable mirror interval (non-critical):`, intervalError);
}
return { archivedName };
} else {
// For non-mirror repositories, use Gitea's native archive feature
// This makes the repository read-only but preserves all data
@@ -4042,10 +4239,11 @@ export async function archiveGiteaRepo(
// If archive fails, log but data is still preserved in Gitea
console.error(`[Archive] Failed to archive repository ${owner}/${repo}: ${response.status}`);
console.log(`[Archive] Repository ${owner}/${repo} remains accessible but not marked as archived`);
return;
return { archivedName: null };
}
console.log(`[Archive] Successfully archived repository ${owner}/${repo} (now read-only)`);
return { archivedName: null };
}
} catch (error) {
// Even on error, the repository data is preserved in Gitea
@@ -4053,5 +4251,6 @@ export async function archiveGiteaRepo(
console.error(`[Archive] Could not mark repository ${owner}/${repo} as archived:`, error);
console.log(`[Archive] Repository ${owner}/${repo} data is preserved but not marked as archived`);
// Don't throw - we want cleanup to continue for other repos
return { archivedName: null };
}
}
+49
View File
@@ -71,6 +71,32 @@ describe("sendNotification", () => {
expect(url).toBe("http://apprise:8000/notify/my-token");
});
test("sends gotify notification when provider is gotify", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "gotify",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
gotify: {
url: "https://gotify.example.com",
token: "my-app-token",
priority: 5,
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
expect(opts.headers["X-Gotify-Key"]).toBe("my-app-token");
});
test("does not throw when fetch fails", async () => {
mockFetch = mock(() => Promise.reject(new Error("Network error")));
globalThis.fetch = mockFetch as any;
@@ -140,6 +166,29 @@ describe("sendNotification", () => {
expect(mockFetch).not.toHaveBeenCalled();
});
test("skips notification when gotify token is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "gotify",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
gotify: {
url: "https://gotify.example.com",
token: "",
priority: 5,
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe("testNotification", () => {
+18
View File
@@ -2,6 +2,7 @@ import type { NotificationConfig } from "@/types/config";
import type { NotificationEvent } from "./providers/ntfy";
import { sendNtfyNotification } from "./providers/ntfy";
import { sendAppriseNotification } from "./providers/apprise";
import { sendGotifyNotification } from "./providers/gotify";
import { db, configs } from "@/lib/db";
import { eq, sql } from "drizzle-orm";
import { decrypt } from "@/lib/utils/encryption";
@@ -52,6 +53,12 @@ export async function sendNotification(
return;
}
await sendAppriseNotification(config.apprise, event);
} else if (config.provider === "gotify") {
if (!config.gotify?.url || !config.gotify?.token) {
console.warn("[NotificationService] Gotify URL or token is not configured, skipping notification");
return;
}
await sendGotifyNotification(config.gotify, event);
}
} catch (error) {
console.error("[NotificationService] Failed to send notification:", error);
@@ -83,6 +90,11 @@ export async function testNotification(
return { success: false, error: "Apprise URL and token are required" };
}
await sendAppriseNotification(notificationConfig.apprise, event);
} else if (notificationConfig.provider === "gotify") {
if (!notificationConfig.gotify?.url || !notificationConfig.gotify?.token) {
return { success: false, error: "Gotify URL and token are required" };
}
await sendGotifyNotification(notificationConfig.gotify, event);
} else {
return { success: false, error: `Unknown provider: ${notificationConfig.provider}` };
}
@@ -166,6 +178,12 @@ export async function triggerJobNotification({
token: decrypt(decryptedConfig.apprise.token),
};
}
if (decryptedConfig.provider === "gotify" && decryptedConfig.gotify?.token) {
decryptedConfig.gotify = {
...decryptedConfig.gotify,
token: decrypt(decryptedConfig.gotify.token),
};
}
// Build event
const repoLabel = repositoryName || organizationName || "Unknown";
+106
View File
@@ -0,0 +1,106 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { sendGotifyNotification } from "./gotify";
import type { NotificationEvent } from "./ntfy";
import type { GotifyConfig } from "@/types/config";
describe("sendGotifyNotification", () => {
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
const baseConfig: GotifyConfig = {
url: "https://gotify.example.com",
token: "AbCdEf123456",
priority: 5,
};
const baseEvent: NotificationEvent = {
title: "Test Notification",
message: "This is a test",
type: "sync_success",
};
test("constructs correct URL from config", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
});
test("strips trailing slash from URL", async () => {
await sendGotifyNotification(
{ ...baseConfig, url: "https://gotify.example.com/" },
baseEvent
);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
});
test("sends token via X-Gotify-Key header", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["X-Gotify-Key"]).toBe("AbCdEf123456");
});
test("sends title and message in JSON body", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.title).toBe("Test Notification");
expect(body.message).toBe("This is a test");
});
test("uses priority 8 for sync_error events", async () => {
const errorEvent: NotificationEvent = {
...baseEvent,
type: "sync_error",
};
await sendGotifyNotification(baseConfig, errorEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(8);
});
test("uses config priority for non-error events", async () => {
await sendGotifyNotification(
{ ...baseConfig, priority: 2 },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(2);
});
test("defaults to priority 5 when not configured", async () => {
await sendGotifyNotification(
{ ...baseConfig, priority: undefined as any },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(5);
});
test("throws on non-200 response", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("unauthorized", { status: 401 }))
);
globalThis.fetch = mockFetch as any;
expect(
sendGotifyNotification(baseConfig, baseEvent)
).rejects.toThrow("Gotify error: 401");
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { GotifyConfig } from "@/types/config";
import type { NotificationEvent } from "./ntfy";
export async function sendGotifyNotification(config: GotifyConfig, event: NotificationEvent): Promise<void> {
const url = `${config.url.replace(/\/$/, "")}/message`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Gotify-Key": config.token,
};
const body = JSON.stringify({
title: event.title,
message: event.message,
priority: event.type === "sync_error" ? 8 : (config.priority ?? 5),
});
const resp = await fetch(url, { method: "POST", body, headers });
if (!resp.ok) throw new Error(`Gotify error: ${resp.status} ${await resp.text()}`);
}
+14 -16
View File
@@ -4,9 +4,10 @@
*/
import { findInterruptedJobs, resumeInterruptedJob } from './helpers';
import { resetStuckMirrorStatuses } from './stuck-status-recovery';
import { db, repositories, organizations, mirrorJobs, configs } from './db';
import { eq, and, lt, inArray, sql } from 'drizzle-orm';
import { mirrorGithubRepoToGitea, mirrorGitHubOrgRepoToGiteaOrg, syncGiteaRepo } from './gitea';
import { mirrorGithubRepoToGitea, syncGiteaRepo } from './gitea';
import { createGitHubClient } from './github';
import { processWithResilience } from './utils/concurrency';
import { repositoryVisibilityEnum, repoStatusEnum } from '@/types/Repository';
@@ -121,6 +122,13 @@ export async function initializeRecovery(options: {
// Clean up stale jobs first
await cleanupStaleJobs();
// Reset repositories/organizations stuck in an in-flight status
// ("mirroring"/"syncing") with no live process behind them (issue
// #339). Job-level recovery below only reconciles mirrorJobs rows;
// repository.status is never reconciled by it, so rows orphaned by
// a crash would otherwise stay "syncing" forever. Never throws.
await resetStuckMirrorStatuses();
// Find interrupted jobs (with per-job logging — this is the
// active recovery path that will immediately try to resume them)
const interruptedJobs = await findInterruptedJobs({ logFound: true });
@@ -290,21 +298,11 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
mirroredLocation: repo.mirroredLocation || "",
};
// Mirror the repository based on whether it's in an organization
if (repo.organization && config.giteaConfig.preserveOrgStructure) {
await mirrorGitHubOrgRepoToGiteaOrg({
config,
octokit,
orgName: repo.organization,
repository: repoData,
});
} else {
await mirrorGithubRepoToGitea({
octokit,
repository: repoData,
config,
});
}
await mirrorGithubRepoToGitea({
octokit,
repository: repoData,
config,
});
return repo;
},
@@ -0,0 +1,52 @@
/**
* Unit tests for the pure orphan-verdict decision logic regression coverage
* for issue #331's root cause: `identifyOrphanedRepositories()` treated a DB
* repository as "orphaned" the moment it was missing from a single bulk
* GitHub fetch (owned+collaborator+org repos, plus starred repos). That bulk
* fetch can be transiently incomplete (rate-limit timing, GraphQL star-list
* pagination quirks, org-allowlist edge cases, etc.), producing false
* positives that got archived (renamed to `archived-{name}` in Gitea/Forgejo)
* even though the repo was never actually removed/unstarred on GitHub.
*
* The fix adds a second, targeted confirmation call for any repo that merely
* *looks* orphaned from the bulk list before finalizing it as such.
* `resolveOrphanVerdict` is the pure decision function extracted from that
* flow (similar in spirit to classifyAssetsForReconciliation /
* classifyReleasesForReconciliation in gitea-releases.test.ts) so the
* decision logic itself is unit-testable without hitting the DB or octokit.
*/
import { describe, test, expect } from "bun:test";
import { resolveOrphanVerdict } from "./repository-cleanup-service";
describe("resolveOrphanVerdict", () => {
test("repo present in the bulk fetch is never orphaned, regardless of the direct check", () => {
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: true, directCheckConfirmsGone: true })
).toBe(false);
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: true, directCheckConfirmsGone: false })
).toBe(false);
});
test("repo missing from the bulk fetch is orphaned only when the direct check confirms it's gone (404)", () => {
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: false, directCheckConfirmsGone: true })
).toBe(true);
});
test("repo missing from the bulk fetch but still found by the direct check is NOT orphaned (bulk fetch was incomplete)", () => {
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: false, directCheckConfirmsGone: false })
).toBe(false);
});
test("repo missing from the bulk fetch whose direct check itself failed (network error, rate limit, etc.) fails safe as NOT orphaned", () => {
// directCheckConfirmsGone is only true for a clean, explicit 404 — any
// other outcome (including a failed verification call) is represented
// as false by the caller, which must resolve to "not orphaned" here.
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: false, directCheckConfirmsGone: false })
).toBe(false);
});
});
+143 -9
View File
@@ -15,6 +15,37 @@ import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
let cleanupInterval: NodeJS.Timeout | null = null;
let isCleanupRunning = false;
/**
* Decide whether a DB repository that appears to be missing from the bulk
* GitHub fetch should actually be treated as orphaned.
*
* The bulk fetch (owned+collaborator+org repos, plus starred repos) that
* feeds `fullNameFoundInBulkList` can be transiently incomplete rate-limit
* timing, GraphQL star-list pagination quirks, org-allowlist edge cases,
* etc. so a repo missing from it is only a *candidate*, not a confirmed
* orphan. It is only orphaned when a direct, targeted GitHub call ALSO
* confirms the repo is gone (a clean 404). Any other outcome the repo
* still exists, or the direct check itself failed for some other reason
* (network error, rate limit, 5xx, timeout) must NOT be treated as
* orphaned; this fails safe and matches the existing fail-safe philosophy
* already in this module for GitHub API errors.
*
* Pure/exported so the decision logic is unit-testable without hitting the
* DB or octokit (see repository-cleanup-service.test.ts).
*/
export function resolveOrphanVerdict({
fullNameFoundInBulkList,
directCheckConfirmsGone,
}: {
fullNameFoundInBulkList: boolean;
directCheckConfirmsGone: boolean;
}): boolean {
if (fullNameFoundInBulkList) {
return false;
}
return directCheckConfirmsGone;
}
/**
* Identify orphaned repositories for a user
* These are repositories that exist in our database (and likely in Gitea)
@@ -80,8 +111,12 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
.where(eq(repositories.userId, userId));
// Only identify repositories as orphaned if we successfully accessed GitHub
// This prevents false positives when GitHub is down or account is inaccessible
const orphanedRepos = dbRepos.filter(repo => {
// This prevents false positives when GitHub is down or account is inaccessible.
//
// First pass (sync, cheap): filter down to repos that merely *look*
// orphaned based on map membership against the single bulk fetch above.
// This is the false-positive-prone signal — see resolveOrphanVerdict.
const candidateOrphans = dbRepos.filter(repo => {
// Skip repositories we've already archived/preserved
if (repo.status === 'archived' || repo.isArchived) {
console.log(`[Repository Cleanup] Skipping ${repo.fullName} - already archived`);
@@ -97,6 +132,8 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
const githubRepo = githubReposByFullName.get(repo.fullName);
if (!githubRepo) {
// Missing from the bulk list — candidate for direct confirmation below,
// not yet a confirmed orphan.
return true;
}
@@ -107,11 +144,93 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
return false;
});
if (candidateOrphans.length === 0) {
return [];
}
// Second pass (async, targeted): confirm each candidate directly against
// GitHub before finalizing it as orphaned. This only adds extra API calls
// for the (presumably small) set of repos that look orphaned, not for
// every repo, so it shouldn't meaningfully increase rate-limit pressure
// in the common case (few or no orphans per run). Promise.allSettled so
// one repo's verification failure can't block the others.
const verificationOutcomes = await Promise.allSettled(
candidateOrphans.map(async (repo) => {
if (repo.isStarred) {
try {
await octokit.rest.activity.checkRepoIsStarredByAuthenticatedUser({
owner: repo.owner,
repo: repo.name,
});
// Resolves (no throw) => still starred; the bulk star fetch
// missed it. Fail safe: do not treat as orphaned.
return { repo, directCheckConfirmsGone: false };
} catch (starError: any) {
if (starError?.status === 404) {
return { repo, directCheckConfirmsGone: true };
}
console.warn(
`[Repository Cleanup] Direct star-check for ${repo.fullName} failed with a non-404 error; skipping this cycle to be safe: ${
starError instanceof Error ? starError.message : String(starError)
}`
);
return { repo, directCheckConfirmsGone: false };
}
}
try {
await octokit.rest.repos.get({ owner: repo.owner, repo: repo.name });
// Resolves (no throw) => repo still exists; the bulk fetch missed
// it (e.g. an org-allowlist edge case). Fail safe: not orphaned.
return { repo, directCheckConfirmsGone: false };
} catch (repoError: any) {
if (repoError?.status === 404) {
return { repo, directCheckConfirmsGone: true };
}
console.warn(
`[Repository Cleanup] Direct existence check for ${repo.fullName} failed with a non-404 error; skipping this cycle to be safe: ${
repoError instanceof Error ? repoError.message : String(repoError)
}`
);
return { repo, directCheckConfirmsGone: false };
}
})
);
const orphanedRepos = verificationOutcomes
.map((outcome, index) => {
if (outcome.status !== 'fulfilled') {
const repo = candidateOrphans[index];
console.warn(
`[Repository Cleanup] Direct orphan verification threw unexpectedly for ${repo.fullName}; skipping this cycle to be safe: ${
outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)
}`
);
return null;
}
const { repo, directCheckConfirmsGone } = outcome.value;
const isOrphaned = resolveOrphanVerdict({
fullNameFoundInBulkList: false,
directCheckConfirmsGone,
});
if (!isOrphaned) {
return null;
}
console.log(
`[Repository Cleanup] Confirmed orphaned via direct GitHub check: ${repo.fullName}`
);
return repo;
})
.filter((repo): repo is (typeof dbRepos)[number] => repo !== null);
if (orphanedRepos.length > 0) {
console.log(`[Repository Cleanup] Found ${orphanedRepos.length} orphaned repositories for user ${userId}`);
}
return orphanedRepos;
} catch (error) {
console.error(`[Repository Cleanup] Error identifying orphaned repositories for user ${userId}:`, error);
@@ -185,15 +304,30 @@ async function handleOrphanedRepository(
// Non-fatal; continue with best guess
}
await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
// Update database status
await db.update(repositories).set({
const { archivedName } = await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
// Update database status. If the archive call renamed the repo in Gitea
// (mirror path), persist the new location so a subsequent "Manual Sync"
// (the UI's documented path for refreshing an archived mirror) can find
// it by its actual current name instead of the stale pre-rename one —
// otherwise syncGiteaRepoEnhanced looks up a name that no longer exists
// and Gitea returns HTTP 405 ("not a pull mirror").
//
// Only mirroredLocation gets the Gitea-side `archived-{name}` value.
// Do NOT write it into `name`: repositories.name is consumed as the
// GITHUB repo name elsewhere (release listing, force-push detection),
// and mirroredLocation alone is what syncGiteaRepoEnhanced resolves
// first when locating the Gitea mirror.
const dbUpdate: Record<string, any> = {
status: 'archived',
isArchived: true,
errorMessage: 'Repository archived - no longer in GitHub',
updatedAt: new Date(),
}).where(eq(repositories.id, repo.id));
};
if (archivedName && archivedName !== giteaRepoName) {
dbUpdate.mirroredLocation = `${giteaOwner}/${archivedName}`;
}
await db.update(repositories).set(dbUpdate).where(eq(repositories.id, repo.id));
// Create event
await publishEvent({
+14 -1
View File
@@ -15,6 +15,7 @@ import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForI
import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
import { createMirrorJob } from '@/lib/helpers';
import { getNextScheduledRun, isCronExpression, normalizeTimezone } from '@/lib/utils/schedule-utils';
import { resetStuckMirrorStatuses } from '@/lib/stuck-status-recovery';
let schedulerInterval: NodeJS.Timeout | null = null;
let isSchedulerRunning = false;
@@ -723,8 +724,20 @@ async function schedulerLoop(): Promise<void> {
}
isSchedulerRunning = true;
try {
// Heal repositories/organizations stuck in an in-flight status
// ("mirroring"/"syncing") from a crash or restart (issue #339). Runs on
// every tick, before the enabled-config filtering, so stuck rows are
// reset even for users without scheduling enabled. Never throws.
const stuckReset = await resetStuckMirrorStatuses();
if (stuckReset.repositories > 0 || stuckReset.organizations > 0) {
console.log(
`[Scheduler] Reset ${stuckReset.repositories} stuck repositor${stuckReset.repositories === 1 ? 'y' : 'ies'} ` +
`and ${stuckReset.organizations} stuck organization(s) from in-flight status to "failed"`
);
}
// Get all active configurations with scheduling enabled
const activeConfigs = await db
.select()
+197
View File
@@ -0,0 +1,197 @@
/**
* Tests for stuck in-flight status recovery (issue #339).
*
* Repositories interrupted mid-mirror/mid-sync (container restart, OOM,
* crash) were left stuck at "mirroring"/"syncing" forever: the scheduler's
* sync pool never selects those statuses, job-level recovery only reconciles
* mirrorJobs rows (and the scheduler path creates none), and the UI disables
* the Sync button for in-flight statuses.
*
* The decision logic is tested directly as pure functions. The wiring into
* the scheduler loop, initializeRecovery, and the startup-recovery script is
* asserted by reading the source (same pattern as
* gitea-mirror-failure-recovery.test.ts) because behavioral tests of those
* modules require process-wide module mocks that pollute other test files.
*/
import { describe, test, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
IN_FLIGHT_REPO_STATUSES,
IN_FLIGHT_ORG_STATUSES,
STUCK_IN_FLIGHT_THRESHOLD_MS,
computeStuckStatusCutoff,
isStuckInFlight,
buildStuckResetErrorMessage,
buildStuckResetUpdate,
resetStuckMirrorStatuses,
} from "./stuck-status-recovery";
const HOUR = 60 * 60 * 1000;
describe("computeStuckStatusCutoff", () => {
test("uses process start as cutoff while the process is younger than the threshold", () => {
// Process started 10 minutes ago; now - 2h would reach back BEFORE the
// process started. The cutoff must clamp to process start so rows written
// by this process are never considered stuck.
const processStart = new Date("2026-07-16T10:00:00Z");
const now = new Date("2026-07-16T10:10:00Z");
const cutoff = computeStuckStatusCutoff(now, processStart);
expect(cutoff.getTime()).toBe(processStart.getTime());
});
test("uses now - threshold once the process has been up longer than the threshold", () => {
const processStart = new Date("2026-07-16T00:00:00Z");
const now = new Date("2026-07-16T10:00:00Z"); // up for 10 hours
const cutoff = computeStuckStatusCutoff(now, processStart);
expect(cutoff.getTime()).toBe(now.getTime() - STUCK_IN_FLIGHT_THRESHOLD_MS);
});
test("honors a custom threshold", () => {
const processStart = new Date("2026-07-16T00:00:00Z");
const now = new Date("2026-07-16T10:00:00Z");
const cutoff = computeStuckStatusCutoff(now, processStart, 30 * 60 * 1000);
expect(cutoff.getTime()).toBe(now.getTime() - 30 * 60 * 1000);
});
test("threshold matches the 2-hour staleness window from isRepoCurrentlyMirroring", () => {
expect(STUCK_IN_FLIGHT_THRESHOLD_MS).toBe(2 * HOUR);
});
});
describe("isStuckInFlight", () => {
const cutoff = new Date("2026-07-16T08:00:00Z");
const before = new Date("2026-07-16T05:00:00Z"); // older than cutoff
const after = new Date("2026-07-16T09:00:00Z"); // newer than cutoff
test("a 'syncing' repo last updated before the cutoff is stuck", () => {
expect(isStuckInFlight({ status: "syncing", updatedAt: before }, cutoff)).toBe(true);
});
test("a 'mirroring' repo last updated before the cutoff is stuck", () => {
expect(isStuckInFlight({ status: "mirroring", updatedAt: before }, cutoff)).toBe(true);
});
test("an in-flight repo updated after the cutoff is NOT stuck (live work is protected)", () => {
expect(isStuckInFlight({ status: "syncing", updatedAt: after }, cutoff)).toBe(false);
expect(isStuckInFlight({ status: "mirroring", updatedAt: after }, cutoff)).toBe(false);
});
test("terminal or queued statuses are never stuck, no matter how old", () => {
for (const status of ["synced", "mirrored", "failed", "imported", "pending-approval", "archived", "ignored"]) {
expect(isStuckInFlight({ status, updatedAt: before }, cutoff)).toBe(false);
}
});
test("an in-flight repo with no updatedAt is treated as stuck", () => {
expect(isStuckInFlight({ status: "syncing", updatedAt: null }, cutoff)).toBe(true);
});
test("in-flight status lists cover exactly the statuses set before network work", () => {
// gitea-enhanced.ts sets "syncing"; gitea.ts sets "mirroring" for repos
// and orgs. Nothing else is written as an intermediate status.
expect([...IN_FLIGHT_REPO_STATUSES].sort()).toEqual(["mirroring", "syncing"]);
expect([...IN_FLIGHT_ORG_STATUSES]).toEqual(["mirroring"]);
});
});
describe("buildStuckResetUpdate / buildStuckResetErrorMessage", () => {
test("resets to 'failed' with the provided timestamp", () => {
const now = new Date("2026-07-16T12:00:00Z");
const update = buildStuckResetUpdate("syncing", now);
expect(update.status).toBe("failed");
expect(update.updatedAt).toBe(now);
expect(update.errorMessage.length).toBeGreaterThan(0);
});
test("error message names the stuck status and the interrupted operation", () => {
const syncMessage = buildStuckResetErrorMessage("syncing");
expect(syncMessage).toContain('"syncing"');
expect(syncMessage).toContain("interrupted sync");
const mirrorMessage = buildStuckResetErrorMessage("mirroring");
expect(mirrorMessage).toContain('"mirroring"');
expect(mirrorMessage).toContain("interrupted mirror");
});
test("error message tells the user how work resumes", () => {
const message = buildStuckResetErrorMessage("syncing");
expect(message).toContain("next scheduled run");
expect(message).toContain("Retry");
});
});
describe("resetStuckMirrorStatuses", () => {
test("never throws even when the db layer misbehaves", async () => {
// The global test setup replaces @/lib/db with a stub whose select chain
// does not return arrays. The function must swallow that (it runs inside
// the scheduler loop and recovery — housekeeping must never block them)
// and report zero resets.
const result = await resetStuckMirrorStatuses({
cutoff: new Date(),
now: new Date(),
});
expect(result).toEqual({ repositories: 0, organizations: 0 });
});
});
describe("wiring (source regression)", () => {
const read = (...segments: string[]) =>
readFileSync(join(import.meta.dir, ...segments), "utf8");
test("scheduler loop resets stuck statuses on every tick, before config filtering", () => {
const source = read("scheduler-service.ts");
expect(source).toContain('from \'@/lib/stuck-status-recovery\'');
const loopStart = source.indexOf("async function schedulerLoop");
expect(loopStart).toBeGreaterThan(-1);
const loopBody = source.slice(loopStart);
const resetCall = loopBody.indexOf("resetStuckMirrorStatuses(");
const configQuery = loopBody.indexOf("const activeConfigs");
expect(resetCall).toBeGreaterThan(-1);
expect(configQuery).toBeGreaterThan(-1);
// Must run before the enabled-config filtering so stuck rows heal even
// for users without scheduling enabled.
expect(resetCall).toBeLessThan(configQuery);
});
test("initializeRecovery resets stuck statuses before resuming interrupted jobs", () => {
const source = read("recovery.ts");
expect(source).toContain("from './stuck-status-recovery'");
const initStart = source.indexOf("export async function initializeRecovery");
expect(initStart).toBeGreaterThan(-1);
const initBody = source.slice(initStart);
const resetCall = initBody.indexOf("resetStuckMirrorStatuses(");
const findJobs = initBody.indexOf("findInterruptedJobs(");
expect(resetCall).toBeGreaterThan(-1);
expect(findJobs).toBeGreaterThan(-1);
expect(resetCall).toBeLessThan(findJobs);
});
test("startup-recovery script resets stuck statuses BEFORE the no-jobs early exit", () => {
const source = read("..", "..", "scripts", "startup-recovery.ts");
const resetCall = source.indexOf("resetStuckMirrorStatuses(");
const needsRecoveryCheck = source.indexOf("hasJobsNeedingRecovery()");
expect(resetCall).toBeGreaterThan(-1);
expect(needsRecoveryCheck).toBeGreaterThan(-1);
// Scheduler-driven syncs create no resilient job records, so a crash
// mid-scheduled-sync leaves stuck repos but NO interrupted jobs. If the
// reset ran after the early exit it would be skipped in exactly the case
// that matters (issue #339).
expect(resetCall).toBeLessThan(needsRecoveryCheck);
});
});
+251
View File
@@ -0,0 +1,251 @@
/**
* Stuck in-flight status recovery (issue #339).
*
* Repositories are marked "mirroring"/"syncing" (organizations: "mirroring")
* in the DB before long-running network work starts. Every in-process failure
* path resets the status via catch blocks, but a hard interruption (container
* restart, OOM kill, host reboot, deploy) kills the process before the catch
* runs and leaves the row stuck in an in-flight status forever:
*
* - the scheduler's sync pool only selects mirrored/synced/failed/pending
* (scheduler-service.ts), so a stuck repo is never picked up again;
* - the UI disables the Sync/Mirror button for in-flight statuses
* (RepositoryTable.tsx), so the user cannot restart it either;
* - job-level recovery (recovery.ts) only reconciles mirrorJobs rows the
* scheduler's sync path does not create resilient job records at all, so
* an interrupted scheduled sync leaves nothing for recovery to find.
*
* This module resets those orphaned rows to "failed" (with an explanatory
* errorMessage) so the scheduler's next run and the UI's Retry button can
* pick them up again. It is invoked from:
*
* 1. the scheduler loop (every minute) heals stuck rows at runtime;
* 2. initializeRecovery() heals on the startup/middleware recovery path;
* 3. scripts/startup-recovery.ts heals before the app starts serving.
*
* Cutoff semantics: a row is only reset when its updatedAt is older than
* max(process start, now - 2h). Rows written by the current process are
* therefore never touched while the process is younger than the threshold,
* and long-lived processes use the same 2-hour staleness window that
* isRepoCurrentlyMirroring (gitea.ts) already applies to in-flight statuses.
*/
import { db, repositories, organizations } from "@/lib/db";
import { inArray, eq } from "drizzle-orm";
import { repoStatusEnum } from "@/types/Repository";
import { createMirrorJob } from "@/lib/helpers";
/** Repository statuses that indicate in-flight work. */
export const IN_FLIGHT_REPO_STATUSES = ["mirroring", "syncing"] as const;
/** Organization statuses that indicate in-flight work. */
export const IN_FLIGHT_ORG_STATUSES = ["mirroring"] as const;
/**
* How long an in-flight status may go without an update before it is
* considered stuck. Matches the 2-hour staleness window used by
* isRepoCurrentlyMirroring in gitea.ts.
*/
export const STUCK_IN_FLIGHT_THRESHOLD_MS = 2 * 60 * 60 * 1000;
/**
* Captured at module load, before any request handling can start a mirror or
* sync in this process. Any in-flight row older than this was written by a
* previous (crashed/restarted) process.
*/
const PROCESS_START = new Date();
export function getProcessStart(): Date {
return PROCESS_START;
}
/**
* Compute the cutoff before which an in-flight status counts as stuck:
* max(processStart, now - threshold).
*
* - Early in the process lifetime the cutoff is the process start, so rows
* stuck by a PREVIOUS process are reset immediately after a restart while
* rows written by THIS process are never touched.
* - Once the process has been up longer than the threshold, the cutoff is
* now - threshold, healing operations that stalled at runtime.
*/
export function computeStuckStatusCutoff(
now: Date,
processStart: Date = PROCESS_START,
thresholdMs: number = STUCK_IN_FLIGHT_THRESHOLD_MS
): Date {
return new Date(Math.max(processStart.getTime(), now.getTime() - thresholdMs));
}
/**
* Whether a row with an in-flight status counts as stuck relative to the
* cutoff. A missing updatedAt is treated as stuck (nothing can prove the
* work is still alive).
*/
export function isStuckInFlight(
row: { status: string; updatedAt: Date | null },
cutoff: Date
): boolean {
if (
!(IN_FLIGHT_REPO_STATUSES as readonly string[]).includes(row.status) &&
!(IN_FLIGHT_ORG_STATUSES as readonly string[]).includes(row.status)
) {
return false;
}
if (!row.updatedAt) {
return true;
}
return new Date(row.updatedAt).getTime() < cutoff.getTime();
}
/** Human-readable explanation stored in errorMessage on reset. */
export function buildStuckResetErrorMessage(previousStatus: string): string {
const operation = previousStatus === "syncing" ? "sync" : "mirror";
return (
`Detected interrupted ${operation}: status was stuck at "${previousStatus}" ` +
`(the application was likely restarted or crashed mid-operation). ` +
`The status was reset automatically; the next scheduled run will retry, ` +
`or you can use Retry to run it now.`
);
}
/** The DB update payload applied to a stuck row. */
export function buildStuckResetUpdate(
previousStatus: string,
now: Date
): { status: "failed"; errorMessage: string; updatedAt: Date } {
return {
status: repoStatusEnum.parse("failed") as "failed",
errorMessage: buildStuckResetErrorMessage(previousStatus),
updatedAt: now,
};
}
export interface StuckStatusResetResult {
repositories: number;
organizations: number;
}
/**
* Reset repositories and organizations stuck in an in-flight status to
* "failed" so the scheduler and the UI's Retry action can pick them up.
*
* Never throws: errors are logged and reflected as zero counts so callers
* (scheduler loop, recovery) are never blocked by this housekeeping step.
*/
export async function resetStuckMirrorStatuses(options: {
cutoff?: Date;
now?: Date;
} = {}): Promise<StuckStatusResetResult> {
const now = options.now ?? new Date();
const cutoff = options.cutoff ?? computeStuckStatusCutoff(now);
const result: StuckStatusResetResult = { repositories: 0, organizations: 0 };
// --- Repositories ---
try {
const inFlightRepos = await db
.select({
id: repositories.id,
userId: repositories.userId,
name: repositories.name,
fullName: repositories.fullName,
status: repositories.status,
updatedAt: repositories.updatedAt,
})
.from(repositories)
.where(inArray(repositories.status, [...IN_FLIGHT_REPO_STATUSES]));
const stuckRepos = inFlightRepos.filter((repo) =>
isStuckInFlight(repo, cutoff)
);
for (const repo of stuckRepos) {
await db
.update(repositories)
.set(buildStuckResetUpdate(repo.status, now))
.where(eq(repositories.id, repo.id));
// Activity-log entry + SSE event so the UI updates live. Push
// notifications are skipped: a restart can reset many rows at once
// and this is internal housekeeping, not a user-triggered failure.
await createMirrorJob({
userId: repo.userId,
repositoryId: repo.id,
repositoryName: repo.name,
message: `Reset stuck repository status: ${repo.fullName ?? repo.name}`,
details:
`Repository was stuck at "${repo.status}" since ` +
`${repo.updatedAt ? new Date(repo.updatedAt).toISOString() : "an unknown time"} ` +
`with no active job — most likely an application restart or crash interrupted it. ` +
`Status was reset to "failed" so it can be retried.`,
status: "failed",
skipNotification: true,
});
console.log(
`[StuckStatusRecovery] Reset repository ${repo.fullName ?? repo.name} from "${repo.status}" to "failed" (stuck since ${
repo.updatedAt ? new Date(repo.updatedAt).toISOString() : "unknown"
})`
);
}
result.repositories = stuckRepos.length;
} catch (error) {
console.error(
"[StuckStatusRecovery] Failed to reset stuck repository statuses:",
error
);
}
// --- Organizations ---
try {
const inFlightOrgs = await db
.select({
id: organizations.id,
userId: organizations.userId,
name: organizations.name,
status: organizations.status,
updatedAt: organizations.updatedAt,
})
.from(organizations)
.where(inArray(organizations.status, [...IN_FLIGHT_ORG_STATUSES]));
const stuckOrgs = inFlightOrgs.filter((org) => isStuckInFlight(org, cutoff));
for (const org of stuckOrgs) {
await db
.update(organizations)
.set(buildStuckResetUpdate(org.status, now))
.where(eq(organizations.id, org.id));
await createMirrorJob({
userId: org.userId,
organizationId: org.id,
organizationName: org.name,
message: `Reset stuck organization status: ${org.name}`,
details:
`Organization was stuck at "${org.status}" since ` +
`${org.updatedAt ? new Date(org.updatedAt).toISOString() : "an unknown time"} ` +
`with no active job — most likely an application restart or crash interrupted it. ` +
`Status was reset to "failed" so it can be retried.`,
status: "failed",
skipNotification: true,
});
console.log(
`[StuckStatusRecovery] Reset organization ${org.name} from "${org.status}" to "failed" (stuck since ${
org.updatedAt ? new Date(org.updatedAt).toISOString() : "unknown"
})`
);
}
result.organizations = stuckOrgs.length;
} catch (error) {
console.error(
"[StuckStatusRecovery] Failed to reset stuck organization statuses:",
error
);
}
return result;
}
+6 -5
View File
@@ -1,5 +1,6 @@
import { describe, test, expect } from "bun:test";
import { jsonResponse, formatDate, formatDateShort, truncate, safeParse, parseErrorMessage, showErrorToast } from "./utils";
import { formatDateTime } from "./utils/time-format";
describe("jsonResponse", () => {
test("creates a Response with JSON content", () => {
@@ -44,10 +45,11 @@ describe("formatDate", () => {
const date = new Date("2023-01-15T12:30:45Z");
const formatted = formatDate(date);
// The exact format might depend on the locale, so we'll check for parts
// The exact format depends on the system locale and the user's time
// format preference, so check for locale-independent parts and that it
// delegates to the shared time-format utility.
expect(formatted).toContain("2023");
expect(formatted).toContain("January");
expect(formatted).toContain("15");
expect(formatted).toBe(formatDateTime(date));
});
test("formats a date string", () => {
@@ -55,8 +57,7 @@ describe("formatDate", () => {
const formatted = formatDate(dateStr);
expect(formatted).toContain("2023");
expect(formatted).toContain("January");
expect(formatted).toContain("15");
expect(formatted).toBe(formatDateTime(dateStr));
});
test("returns 'Never' for null or undefined", () => {
+3 -7
View File
@@ -3,6 +3,7 @@ import { twMerge } from "tailwind-merge";
import { httpRequest, HttpError } from "@/lib/http-client";
import type { RepoStatus } from "@/types/Repository";
import { withBase } from "@/lib/base-path";
import { formatDateTime } from "@/lib/utils/time-format";
export const API_BASE = withBase("/api");
@@ -23,13 +24,8 @@ export function generateRandomString(length: number): string {
export function formatDate(date?: Date | string | null): string {
if (!date) return "Never";
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
// Locale-aware and respects the user's 12h/24h time format preference.
return formatDateTime(date);
}
export function formatDateShort(date?: Date | string | null): string | undefined {
+66
View File
@@ -191,3 +191,69 @@ test("DB row missing starredDuplicateStrategy defaults to suffix on read", () =>
const ui = mapDbToUiConfig({ githubConfig: { owner: "octo", token: "" } });
expect(ui.githubConfig.starredDuplicateStrategy).toBe("suffix");
});
// Regression for #338: saving any setting from the Configuration page reset
// giteaConfig.mirrorInterval (set via GITEA_MIRROR_INTERVAL) back to "8h"
// because the mapper hardcoded the default instead of preserving the stored value.
test("mapUiToDbConfig preserves env-configured mirrorInterval on save", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, {
giteaConfig: { mirrorInterval: "10m" },
});
expect(db.giteaConfig.mirrorInterval).toBe("10m");
});
test("mapUiToDbConfig defaults mirrorInterval to 8h without existing config", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions);
expect(db.giteaConfig.mirrorInterval).toBe("8h");
});
test("mapUiToDbConfig preserves non-UI fields from existing config on save", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, {
githubConfig: { type: "organization", includeArchived: true, includePublic: false },
giteaConfig: {
createOrg: false,
templateOwner: "templates",
templateRepo: "base",
addTopics: false,
topicPrefix: "gh-",
preserveVisibility: true,
},
});
expect(db.githubConfig.type).toBe("organization");
expect(db.githubConfig.includeArchived).toBe(true);
expect(db.githubConfig.includePublic).toBe(false);
expect(db.giteaConfig.createOrg).toBe(false);
expect(db.giteaConfig.templateOwner).toBe("templates");
expect(db.giteaConfig.templateRepo).toBe("base");
expect(db.giteaConfig.addTopics).toBe(false);
expect(db.giteaConfig.topicPrefix).toBe("gh-");
expect(db.giteaConfig.preserveVisibility).toBe(true);
});
test("mapUiToDbConfig keeps env-configured full-copy forkStrategy when forks are included", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, {
giteaConfig: { forkStrategy: "full-copy" },
});
expect(db.giteaConfig.forkStrategy).toBe("full-copy");
});
test("mapUiToDbConfig lets skipForks override a stored forkStrategy", () => {
const ui = buildMinimalUiConfigs();
const withSkip: AdvancedOptions = { ...ui.advancedOptions, skipForks: true };
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, withSkip, {
giteaConfig: { forkStrategy: "full-copy" },
});
expect(db.giteaConfig.forkStrategy).toBe("skip");
});
test("mapUiToDbConfig resets a stale skip forkStrategy to reference when skipForks is unchecked", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, {
giteaConfig: { forkStrategy: "skip" },
});
expect(db.giteaConfig.forkStrategy).toBe("reference");
});
+39 -23
View File
@@ -51,28 +51,37 @@ function normalizeOrgList(orgs: string[] | undefined): string[] {
/**
* Maps UI config structure to database schema structure
*
* `existing` is the stored DB config (if any). Fields the Configuration form
* doesn't expose (mirrorInterval, topics, templates, ...) are preserved from it
* so a UI save can't silently reset values configured via environment variables
* (e.g. GITEA_MIRROR_INTERVAL, see issue #338).
*/
export function mapUiToDbConfig(
githubConfig: GitHubConfig,
giteaConfig: GiteaConfig,
mirrorOptions: MirrorOptions,
advancedOptions: AdvancedOptions
advancedOptions: AdvancedOptions,
existing?: {
githubConfig?: Partial<DbGitHubConfig>;
giteaConfig?: Partial<DbGiteaConfig>;
}
): { githubConfig: DbGitHubConfig; giteaConfig: DbGiteaConfig } {
// Map GitHub config to match database schema fields
const dbGithubConfig: DbGitHubConfig = {
// Map username to owner field
owner: githubConfig.username,
type: "personal", // Default to personal, could be made configurable
type: existing?.githubConfig?.type || "personal", // Not in UI; preserve stored value
token: githubConfig.token || "",
// Map checkbox fields with proper names
includeStarred: githubConfig.mirrorStarred,
includePrivate: githubConfig.privateRepositories,
includeCollaboratorRepos: githubConfig.includeCollaboratorRepos ?? true,
includeForks: !advancedOptions.skipForks, // Note: UI has skipForks, DB has includeForks
skipForks: advancedOptions.skipForks, // Add skipForks field
includeArchived: false, // Not in UI yet, default to false
includePublic: true, // Not in UI yet, default to true
includeArchived: existing?.githubConfig?.includeArchived ?? false, // Not in UI; preserve stored value
includePublic: existing?.githubConfig?.includePublic ?? true, // Not in UI; preserve stored value
// Organization related fields — opt-in allowlist (empty = all org repos)
includeOrganizations: normalizeOrgList(githubConfig.includeOrganizations),
@@ -102,28 +111,35 @@ export function mapUiToDbConfig(
organization: giteaConfig.organization, // Add organization field
preserveOrgStructure: giteaConfig.mirrorStrategy === "preserve" || giteaConfig.mirrorStrategy === "mixed", // Add preserveOrgStructure field
// Mirror interval and options
mirrorInterval: "8h", // Default value, could be made configurable
// Mirror interval — not in UI; preserve the stored value so a save doesn't
// reset an env-configured GITEA_MIRROR_INTERVAL back to the default (#338)
mirrorInterval: existing?.giteaConfig?.mirrorInterval || "8h",
lfs: mirrorOptions.mirrorLFS || false, // LFS mirroring option
wiki: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.wiki,
// Visibility settings
visibility: giteaConfig.visibility || "default",
preserveVisibility: false, // This should be a separate field, not the same as preserveOrgStructure
// Organization creation
createOrg: true, // Default to true
// Template settings (not in UI yet)
templateOwner: undefined,
templateRepo: undefined,
// Topics
addTopics: true, // Default to true
topicPrefix: undefined,
// Fork strategy
forkStrategy: advancedOptions.skipForks ? "skip" : "reference",
preserveVisibility: existing?.giteaConfig?.preserveVisibility ?? false, // Not in UI; preserve stored value
// Organization creation — not in UI; preserve stored value
createOrg: existing?.giteaConfig?.createOrg ?? true,
// Template settings (not in UI yet) — preserve stored values
templateOwner: existing?.giteaConfig?.templateOwner,
templateRepo: existing?.giteaConfig?.templateRepo,
// Topics — not in UI; preserve stored values
addTopics: existing?.giteaConfig?.addTopics ?? true,
topicPrefix: existing?.giteaConfig?.topicPrefix,
// Fork strategy — skipForks is the only UI control; keep an env-configured
// "full-copy" instead of downgrading it, but reset a stale "skip" once the
// user unchecks skipForks.
forkStrategy: advancedOptions.skipForks
? "skip"
: existing?.giteaConfig?.forkStrategy && existing.giteaConfig.forkStrategy !== "skip"
? existing.giteaConfig.forkStrategy
: "reference",
// Mirror options from UI
issueConcurrency: giteaConfig.issueConcurrency ?? 3,
+231
View File
@@ -0,0 +1,231 @@
import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test";
import {
TIME_FORMAT_STORAGE_KEY,
isTimeFormatPreference,
getTimeFormatPreference,
setTimeFormatPreference,
subscribeToTimeFormatChange,
resolveHour12,
formatDateTime,
formatShortDateTime,
formatTime,
} from "./time-format";
// A fixed instant; assertions below are timezone-agnostic (they check the
// hour cycle / AM-PM marker, not exact clock values).
const FIXED_DATE = new Date("2023-01-15T12:30:45Z");
const MERIDIEM = /AM|PM/i;
// The Bun test runtime has no DOM. Install minimal localStorage/window shims
// for the persistence and subscription tests, and remove them afterwards.
const g = globalThis as any;
const createdLocalStorage = typeof g.localStorage === "undefined";
const createdWindow = typeof g.window === "undefined";
beforeAll(() => {
if (createdLocalStorage) {
const store = new Map<string, string>();
g.localStorage = {
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
};
}
if (createdWindow) {
const target = new EventTarget();
g.window = target;
}
});
afterAll(() => {
if (createdLocalStorage) delete g.localStorage;
if (createdWindow) delete g.window;
});
beforeEach(() => {
g.localStorage.removeItem(TIME_FORMAT_STORAGE_KEY);
});
describe("isTimeFormatPreference", () => {
test("accepts valid preferences", () => {
expect(isTimeFormatPreference("auto")).toBe(true);
expect(isTimeFormatPreference("12h")).toBe(true);
expect(isTimeFormatPreference("24h")).toBe(true);
});
test("rejects invalid values", () => {
expect(isTimeFormatPreference("24")).toBe(false);
expect(isTimeFormatPreference("")).toBe(false);
expect(isTimeFormatPreference(null)).toBe(false);
expect(isTimeFormatPreference(undefined)).toBe(false);
expect(isTimeFormatPreference(12)).toBe(false);
});
});
describe("resolveHour12", () => {
test("maps preferences onto Intl's hour12 option", () => {
expect(resolveHour12("12h")).toBe(true);
expect(resolveHour12("24h")).toBe(false);
expect(resolveHour12("auto")).toBeUndefined();
});
});
describe("preference persistence", () => {
test("defaults to 'auto' when nothing is stored", () => {
expect(getTimeFormatPreference()).toBe("auto");
});
test("round-trips a stored preference", () => {
setTimeFormatPreference("24h");
expect(getTimeFormatPreference()).toBe("24h");
setTimeFormatPreference("12h");
expect(getTimeFormatPreference()).toBe("12h");
});
test("falls back to 'auto' for corrupted stored values", () => {
g.localStorage.setItem(TIME_FORMAT_STORAGE_KEY, "bogus");
expect(getTimeFormatPreference()).toBe("auto");
});
});
describe("subscribeToTimeFormatChange", () => {
test("notifies on preference change and stops after unsubscribe", () => {
let calls = 0;
const unsubscribe = subscribeToTimeFormatChange(() => {
calls++;
});
setTimeFormatPreference("24h");
expect(calls).toBe(1);
setTimeFormatPreference("12h");
expect(calls).toBe(2);
unsubscribe();
setTimeFormatPreference("auto");
expect(calls).toBe(2);
});
test("notifies on cross-tab storage events for our key only", () => {
let calls = 0;
const unsubscribe = subscribeToTimeFormatChange(() => {
calls++;
});
g.window.dispatchEvent(
Object.assign(new Event("storage"), { key: TIME_FORMAT_STORAGE_KEY })
);
expect(calls).toBe(1);
g.window.dispatchEvent(
Object.assign(new Event("storage"), { key: "theme" })
);
expect(calls).toBe(1);
unsubscribe();
});
});
describe("formatTime", () => {
test("forces 24-hour time regardless of a 12-hour locale", () => {
const formatted = formatTime(FIXED_DATE, {
locale: "en-US",
preference: "24h",
});
expect(formatted).not.toMatch(MERIDIEM);
expect(formatted).toMatch(/^\d{2}:\d{2}$/);
});
test("forces 12-hour time regardless of a 24-hour locale", () => {
const formatted = formatTime(FIXED_DATE, {
locale: "de-DE",
preference: "12h",
});
expect(formatted).toMatch(MERIDIEM);
});
test("'auto' follows the locale convention (12h for en-US)", () => {
const formatted = formatTime(FIXED_DATE, {
locale: "en-US",
preference: "auto",
});
expect(formatted).toMatch(MERIDIEM);
});
test("'auto' follows the locale convention (24h for de-DE)", () => {
const formatted = formatTime(FIXED_DATE, {
locale: "de-DE",
preference: "auto",
});
expect(formatted).not.toMatch(MERIDIEM);
});
test("uses the stored preference when none is passed", () => {
setTimeFormatPreference("24h");
const formatted = formatTime(FIXED_DATE, { locale: "en-US" });
expect(formatted).not.toMatch(MERIDIEM);
expect(formatted).toMatch(/^\d{2}:\d{2}$/);
});
});
describe("formatDateTime", () => {
test("includes the full date and respects the 24h preference", () => {
const formatted = formatDateTime(FIXED_DATE, {
locale: "en-US",
preference: "24h",
});
expect(formatted).toContain("January");
expect(formatted).toContain("2023");
expect(formatted).not.toMatch(MERIDIEM);
expect(formatted).toMatch(/\d{2}:\d{2}/);
});
test("includes the full date and respects the 12h preference", () => {
const formatted = formatDateTime(FIXED_DATE, {
locale: "en-US",
preference: "12h",
});
expect(formatted).toContain("January");
expect(formatted).toContain("2023");
expect(formatted).toMatch(MERIDIEM);
});
test("accepts ISO strings and epoch milliseconds", () => {
const fromString = formatDateTime("2023-01-15T12:30:45Z", {
locale: "en-US",
preference: "24h",
});
const fromNumber = formatDateTime(FIXED_DATE.getTime(), {
locale: "en-US",
preference: "24h",
});
expect(fromString).toBe(fromNumber);
});
});
describe("formatShortDateTime", () => {
test("renders a compact numeric date with time", () => {
const formatted = formatShortDateTime(FIXED_DATE, {
locale: "en-US",
preference: "12h",
});
expect(formatted).toMatch(/\d{2}\/\d{2}\/\d{2}/);
expect(formatted).toMatch(MERIDIEM);
});
test("respects the 24h preference", () => {
const formatted = formatShortDateTime(FIXED_DATE, {
locale: "en-US",
preference: "24h",
});
expect(formatted).not.toMatch(MERIDIEM);
expect(formatted).toMatch(/\d{2}:\d{2}/);
});
});
+159
View File
@@ -0,0 +1,159 @@
/**
* Shared time/date formatting with a user-configurable 12h/24h preference.
*
* All timestamp rendering in the UI should go through this module so that:
* - By default ("auto") times follow the browser locale's convention instead
* of a hardcoded locale (previously "en-US" forced 12-hour AM/PM for everyone).
* - Users can explicitly force 12-hour or 24-hour time. The preference is a
* pure display concern, so it is persisted client-side in localStorage
* (same mechanism as the theme preference) rather than in the database.
*/
export type TimeFormatPreference = "auto" | "12h" | "24h";
export const TIME_FORMAT_STORAGE_KEY = "timeFormat";
export const TIME_FORMAT_CHANGE_EVENT = "gitea-mirror:time-format-change";
export function isTimeFormatPreference(
value: unknown
): value is TimeFormatPreference {
return value === "auto" || value === "12h" || value === "24h";
}
/**
* Read the persisted preference. Safe to call during SSR / in tests where
* localStorage does not exist (falls back to "auto").
*/
export function getTimeFormatPreference(): TimeFormatPreference {
if (typeof localStorage === "undefined") return "auto";
try {
const stored = localStorage.getItem(TIME_FORMAT_STORAGE_KEY);
return isTimeFormatPreference(stored) ? stored : "auto";
} catch {
return "auto";
}
}
/**
* Persist the preference and notify listeners in this tab. Other tabs are
* notified via the native "storage" event.
*/
export function setTimeFormatPreference(preference: TimeFormatPreference): void {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(TIME_FORMAT_STORAGE_KEY, preference);
}
} catch {
// Persisting is best-effort (e.g. storage disabled); still notify listeners.
}
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(TIME_FORMAT_CHANGE_EVENT, { detail: preference })
);
}
}
/**
* Subscribe to preference changes (same tab via custom event, other tabs via
* the "storage" event). Returns an unsubscribe function. Compatible with
* React's useSyncExternalStore.
*/
export function subscribeToTimeFormatChange(callback: () => void): () => void {
if (typeof window === "undefined") return () => {};
const onCustom = () => callback();
const onStorage = (event: StorageEvent) => {
if (event.key === TIME_FORMAT_STORAGE_KEY) callback();
};
window.addEventListener(TIME_FORMAT_CHANGE_EVENT, onCustom);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(TIME_FORMAT_CHANGE_EVENT, onCustom);
window.removeEventListener("storage", onStorage);
};
}
/** Map a preference onto Intl's hour12 option ("auto" defers to the locale). */
export function resolveHour12(
preference: TimeFormatPreference
): boolean | undefined {
if (preference === "12h") return true;
if (preference === "24h") return false;
return undefined;
}
export interface FormatDateTimeOptions {
/** Override the stored preference (mainly for tests). */
preference?: TimeFormatPreference;
/** Override the browser/system locale (mainly for tests). */
locale?: string;
}
function buildOptions(
base: Intl.DateTimeFormatOptions,
preference: TimeFormatPreference
): Intl.DateTimeFormatOptions {
const hour12 = resolveHour12(preference);
return hour12 === undefined ? base : { ...base, hour12 };
}
/**
* Full date + time, e.g. "January 15, 2023, 12:30 PM" / "15. Januar 2023, 12:30".
* Locale defaults to the browser locale; hour cycle follows the user preference.
*/
export function formatDateTime(
date: Date | string | number,
options: FormatDateTimeOptions = {}
): string {
const preference = options.preference ?? getTimeFormatPreference();
return new Intl.DateTimeFormat(
options.locale,
buildOptions(
{
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
},
preference
)
).format(new Date(date));
}
/**
* Compact date + time, e.g. "01/15/23, 12:30 PM" / "15.01.23, 12:30".
* Used where space is tight (dashboard status cards).
*/
export function formatShortDateTime(
date: Date | string | number,
options: FormatDateTimeOptions = {}
): string {
const preference = options.preference ?? getTimeFormatPreference();
return new Intl.DateTimeFormat(
options.locale,
buildOptions(
{
year: "2-digit",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
},
preference
)
).format(new Date(date));
}
/**
* Time only, e.g. "12:30 PM" / "12:30".
*/
export function formatTime(
date: Date | string | number,
options: FormatDateTimeOptions = {}
): string {
const preference = options.preference ?? getTimeFormatPreference();
return new Intl.DateTimeFormat(
options.locale,
buildOptions({ hour: "2-digit", minute: "2-digit" }, preference)
).format(new Date(date));
}
+48 -24
View File
@@ -94,38 +94,48 @@ export const POST: APIRoute = async ({ request, locals }) => {
const existingConfig = existingConfigResult[0];
// Parse the stored configs once — used both to preserve fields the
// Configuration form doesn't expose (e.g. env-configured mirrorInterval,
// see issue #338) and to preserve tokens when the form submits them empty.
let existingGithub: Record<string, any> | undefined;
let existingGitea: Record<string, any> | undefined;
if (existingConfig) {
try {
existingGithub =
typeof existingConfig.githubConfig === "string"
? JSON.parse(existingConfig.githubConfig)
: existingConfig.githubConfig;
existingGitea =
typeof existingConfig.giteaConfig === "string"
? JSON.parse(existingConfig.giteaConfig)
: existingConfig.giteaConfig;
} catch (parseError) {
console.error("Failed to parse existing config:", parseError);
}
}
// Map UI structure to database schema structure first
const { githubConfig: mappedGithubConfig, giteaConfig: mappedGiteaConfig } = mapUiToDbConfig(
githubConfig,
giteaConfig,
mirrorOptions,
advancedOptions
advancedOptions,
{ githubConfig: existingGithub, giteaConfig: existingGitea }
);
// Preserve tokens if fields are empty
if (existingConfig) {
try {
const existingGithub =
typeof existingConfig.githubConfig === "string"
? JSON.parse(existingConfig.githubConfig)
: existingConfig.githubConfig;
const existingGitea =
typeof existingConfig.giteaConfig === "string"
? JSON.parse(existingConfig.giteaConfig)
: existingConfig.giteaConfig;
// Decrypt existing tokens before preserving
if (!mappedGithubConfig.token && existingGithub.token) {
mappedGithubConfig.token = decrypt(existingGithub.token);
}
if (!mappedGiteaConfig.token && existingGitea.token) {
mappedGiteaConfig.token = decrypt(existingGitea.token);
}
} catch (tokenError) {
console.error("Failed to preserve tokens:", tokenError);
try {
// Decrypt existing tokens before preserving
if (!mappedGithubConfig.token && existingGithub?.token) {
mappedGithubConfig.token = decrypt(existingGithub.token);
}
if (!mappedGiteaConfig.token && existingGitea?.token) {
mappedGiteaConfig.token = decrypt(existingGitea.token);
}
} catch (tokenError) {
console.error("Failed to preserve tokens:", tokenError);
}
// Encrypt tokens before saving
@@ -162,6 +172,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
token: encrypt(processedNotificationConfig.apprise.token),
};
}
// Encrypt gotify token if present
if (processedNotificationConfig.gotify?.token) {
processedNotificationConfig.gotify = {
...processedNotificationConfig.gotify,
token: encrypt(processedNotificationConfig.gotify.token),
};
}
}
if (existingConfig) {
@@ -344,6 +361,13 @@ export const GET: APIRoute = async ({ request, locals }) => {
notificationConfig.apprise = { ...notificationConfig.apprise, token: "" };
}
}
if (notificationConfig.gotify?.token) {
try {
notificationConfig.gotify = { ...notificationConfig.gotify, token: decrypt(notificationConfig.gotify.token) };
} catch {
notificationConfig.gotify = { ...notificationConfig.gotify, token: "" };
}
}
}
return new Response(JSON.stringify({
+8 -1
View File
@@ -119,14 +119,21 @@ export interface AppriseConfig {
tag?: string;
}
export interface GotifyConfig {
url: string;
token: string;
priority: number;
}
export interface NotificationConfig {
enabled: boolean;
provider: "ntfy" | "apprise";
provider: "ntfy" | "apprise" | "gotify";
notifyOnSyncError: boolean;
notifyOnSyncSuccess: boolean;
notifyOnNewRepo: boolean;
ntfy?: NtfyConfig;
apprise?: AppriseConfig;
gotify?: GotifyConfig;
}
export interface Config extends ConfigType {}
+14 -9
View File
@@ -9,27 +9,32 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^5.0.6",
"@astrojs/react": "^5.0.7",
"@radix-ui/react-slot": "^1.2.5",
"@astrojs/mdx": "^7.0.3",
"@astrojs/react": "^6.0.1",
"@radix-ui/react-slot": "^1.3.0",
"@splinetool/react-spline": "^4.1.0",
"@splinetool/runtime": "^1.12.97",
"@tailwindcss/vite": "^4.3.1",
"@splinetool/runtime": "^1.12.98",
"@tailwindcss/vite": "^4.3.3",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^6.4.6",
"astro": "^7.1.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1"
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"tw-animate-css": "^1.4.0"
},
"packageManager": "pnpm@10.32.1"
"packageManager": "pnpm@10.32.1",
"pnpm": {
"overrides": {
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1"
}
}
}
+1113 -682
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
# Robots.txt for Gitea Mirror
User-agent: *
Allow: /
Sitemap: https://gitea-mirror.com/sitemap.xml
Sitemap: https://gitea-mirror.raylabs.io/sitemap.xml
# Crawl-delay for responsible crawling
User-agent: *
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://gitea-mirror.com/</loc>
<loc>https://gitea-mirror.raylabs.io/</loc>
<lastmod>2025-01-08</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
+3 -2
View File
@@ -1,11 +1,12 @@
---
import { Github, Book, MessageSquare, Bug } from 'lucide-react';
import { Book, MessageSquare, Bug } from 'lucide-react';
import { GithubIcon } from './icons/GithubIcon';
const links = [
{
title: "Source Code",
href: "https://github.com/RayLabsHQ/gitea-mirror",
icon: Github
icon: GithubIcon
},
{
title: "Documentation",
+3 -2
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Github, Star } from 'lucide-react';
import { Star } from 'lucide-react';
import { GithubIcon } from './icons/GithubIcon';
import { Button } from './ui/button';
export function GitHubButton() {
@@ -44,7 +45,7 @@ export function GitHubButton() {
asChild
>
<a href="https://github.com/RayLabsHQ/gitea-mirror" target="_blank" rel="noopener noreferrer" className="flex items-center">
<Github className="w-4 h-4 mr-2" />
<GithubIcon className="w-4 h-4 mr-2" />
<span>Star on GitHub</span>
{stars !== null && (
<>
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
// GitHub brand mark. Replaces lucide-react's `Github` icon, which was removed
// along with all brand icons in lucide-react v1.
export function GithubIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className={className}
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}
+1 -1
View File
@@ -7,7 +7,7 @@ const {
content: {
title = 'Use Case',
description = 'Explore how Gitea Mirror helps engineering teams stay resilient.',
canonical = 'https://gitea-mirror.com/use-cases',
canonical = 'https://gitea-mirror.raylabs.io/use-cases',
}
} = Astro.props;
---
+1 -1
View File
@@ -7,7 +7,7 @@ const {
content: {
title = 'Use Case',
description = 'Explore how Gitea Mirror helps engineering teams stay resilient.',
canonical = 'https://gitea-mirror.com/use-cases',
canonical = 'https://gitea-mirror.raylabs.io/use-cases',
}
} = Astro.props;
---
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "GitHub Backup Tools Compared: Self-Hosted vs Cloud Solutions"
description: "Compare Gitea Mirror with BackHub, Rewind, GitHub Enterprise Backup, and manual scripts. Choose the best GitHub backup solution for your needs."
canonical: "https://gitea-mirror.com/comparison/github-backup-tools/"
canonical: "https://gitea-mirror.raylabs.io/comparison/github-backup-tools/"
---
# GitHub Backup Tools Compared: Finding the Right Solution
+1 -1
View File
@@ -12,7 +12,7 @@ import FAQ from "../components/FAQ.astro";
import Footer from "../components/Footer.astro";
import { PromoBanner } from "../components/PromoBanner";
const siteUrl = "https://gitea-mirror.com";
const siteUrl = "https://gitea-mirror.raylabs.io";
const title = "GitHub Backup Tool | Self-Hosted Repository Backup to Gitea";
const description =
"Automatically backup GitHub repos to your own Gitea server. Preserve issues, PRs, releases & wiki. Self-hosted, Docker-ready. Free alternative to cloud backup services.";
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Backup GitHub Repositories with Gitea Mirror"
description: "Run a homelab-friendly playbook to mirror GitHub into self-hosted Gitea with automated schedules, health checks, and restore drills."
canonical: "https://gitea-mirror.com/use-cases/backup-github-repositories/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/backup-github-repositories/"
---
# Backup GitHub Repositories with Gitea Mirror
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Deploy Gitea Mirror with the Helm Chart"
description: "Install the Gitea Mirror backup service on Kubernetes with the official Helm chart, including secrets, persistence, and upgrade workflow."
canonical: "https://gitea-mirror.com/use-cases/deploy-with-helm-chart/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/deploy-with-helm-chart/"
---
# Deploy Gitea Mirror with the Helm Chart
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Automate GitHub Backups"
description: "Replace fragile cron scripts with scheduled mirrors, health checks, and logging that keep GitHub backups trustworthy."
canonical: "https://gitea-mirror.com/use-cases/github-backup-automation/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/github-backup-automation/"
---
# Automate GitHub Backups
+1 -1
View File
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseIndexLayout.astro
title: "Gitea Mirror Use Cases"
description: "Homelab-friendly playbooks that keep GitHub repos mirrored inside Gitea without promising enterprise guarantees."
canonical: "https://gitea-mirror.com/use-cases/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/"
---
import { ArrowRight, ShieldAlert, Sparkles, Home } from 'lucide-react';
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Preserve GitHub History Forever"
description: "Archive commits, issues, releases, and LFS assets into Gitea so hobby projects survive account removals or repo deletions."
canonical: "https://gitea-mirror.com/use-cases/preserve-github-history/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/preserve-github-history/"
---
# Preserve GitHub History Forever
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Self-Hosted GitHub Backup on Proxmox LXC | Gitea Mirror Homelab Setup"
description: "Deploy a dedicated GitHub backup appliance in your Proxmox homelab using the one-line LXC installer. Automatic syncing, snapshot-ready, homelab-optimized."
canonical: "https://gitea-mirror.com/use-cases/proxmox-lxc-homelab/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/proxmox-lxc-homelab/"
---
# Self-Hosted GitHub Backup on Proxmox LXC
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Build a Starred Repo Collection"
description: "Mirror your starred GitHub projects into a dedicated Gitea library so favorites remain available offline."
canonical: "https://gitea-mirror.com/use-cases/starred-repos-collection/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/starred-repos-collection/"
---
# Build a Starred Repo Collection
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Sync GitHub to Your Self-Hosted Gitea"
description: "Keep a homelab Gitea instance continuously updated with GitHub by using Gitea Mirror's discovery, scheduling, and metadata sync."
canonical: "https://gitea-mirror.com/use-cases/sync-github-to-self-hosted-gitea/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/sync-github-to-self-hosted-gitea/"
---
# Sync GitHub to Your Self-Hosted Gitea
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "Stay Ready to Leave GitHub"
description: "Use Gitea Mirror to keep an always-current fallback so policy or pricing changes at GitHub never stall your projects."
canonical: "https://gitea-mirror.com/use-cases/vendor-lock-in-prevention/"
canonical: "https://gitea-mirror.raylabs.io/use-cases/vendor-lock-in-prevention/"
---
# Stay Ready to Leave GitHub