Compare commits

...
13 Commits
Author SHA1 Message Date
Arunavo Ray 8446811e8d chore: bump version to 3.27.2 2026-08-21 17:37:40 +05:30
ARUNAVO RAY 0c41fac9c0 fix(auth): trust registered SSO provider origins and honor deleteFromGitea (#366) (#367)
Three fixes for the two problems reported in #366:

1. Auto-trust registered SSO identity provider origins. better-auth
   1.6.23 (shipped in v3.21.0) added SSRF hardening to the SSO plugin:
   sign-in rejects IdP endpoints whose hostnames resolve to private
   addresses unless the origin is in trustedOrigins. Homelab split-DNS
   setups (IdP domain resolving to a LAN IP from inside the container)
   broke on every sign-in with a 400. Registering a provider is an
   explicit operator action, so its issuer and endpoint origins are now
   added to trusted origins automatically.

2. Surface SSO sign-in errors in the login form. The auth client
   resolves with { data, error } instead of throwing, so server-side
   rejections were silently swallowed - the button flipped back from
   "Redirecting..." with no feedback and nothing in the logs.

3. Honor deleteFromGitea (CLEANUP_DELETE_FROM_GITEA). It was documented
   as "Delete repositories from Gitea" defaulting to false, but cleanup
   never read it and always archived/deleted orphans on the Gitea side.
   It now gates the Gitea-side operation: when disabled (default),
   orphans are only marked archived or removed in gitea-mirror's own
   database and the Gitea/Forgejo copies stay untouched.

Verified end to end against a live server: sign-in with a private-IP
IdP returns 400 discovery_private_host on v3.27.1 and a 200 with the
authorization URL on this branch; the login form now shows the server
error as a toast; delete cleanup with the flag off removes only the DB
row while the flag on contacts Gitea.
2026-08-21 17:37:32 +05:30
Arunavo Ray f18fc4bfe6 chore: bump version to 3.27.1 2026-08-21 07:09:35 +05:30
ARUNAVO RAY d63d55f52d fix(ui): surface Mirror Options on mobile repo cards and desktop org menu (#365)
The per-repo overrides from #361 were reachable only from the desktop
repositories table. The mobile card layout had no dropdown at all, and
the organizations page had the inverse gap: the item existed in the
mobile card menu but not the desktop one.

Mobile repo cards get a three-dot menu in the header opening the same
MirrorOverridesDialog, plus the Custom badge shown when a repository
overrides something. The organizations desktop dropdown gains the same
Mirror Options item the mobile variant already had.
2026-08-21 07:09:32 +05:30
ARUNAVO RAY 2867076d71 docs(www): align use-case articles with the actual configuration UI (#364)
Four older articles directed readers to UI elements by names that no
longer exist. Verified against src/components/config/:

  "Content & Data" section        -> "Mirror Content"
  "Mirror metadata" toggle        -> "Repository metadata"
  "Git LFS (Large File Storage)"  -> "Git LFS objects"
  "Mirror releases"               -> "Releases & tags"
  "Preserve structure" strategy   -> "Preserve Structure"

The new articles from #363 already used current names or generic
wording, so only the older four needed changes.
2026-08-20 06:41:41 +05:30
ARUNAVO RAY 7a935ff1b1 feat(www): SEO overhaul with sitemap, metadata, interlinking, and five new articles (#363)
* feat(www): generated sitemap, article metadata, and de-orphaned comparison page

The hand-written sitemap listed one URL of 24 and was dated January 2025.
astro.config now sets `site` and uses @astrojs/sitemap, so every built
page lands in sitemap-index.xml on each build. robots.txt points at the
new index and drops the duplicate user-agent block.

Use-case layouts gain the OG/Twitter tags the docs layout already had,
and all three article layouts now emit TechArticle JSON-LD.

Nothing on the site linked to /comparison/github-backup-tools/. It now
has a header nav item, a footer link, and a homepage CTA next to the
use-cases button. The footer Documentation link points at the site docs
instead of the GitHub tree, and footer internal links no longer open in
new tabs.

* feat(www): five new articles and an interlinking pass

New guides, each grounded in shipped behavior:

- Migrate from GitHub to Gitea (parallel-run migration playbook)
- Mirror Git LFS repositories (why one bad LFS repo fails its whole
  migration, and the per-repo override from v3.27.0 that fixes it)
- GitHub API rate limits (ETag conditional requests making 304s free)
- Mirror issues and pull requests (what transfers, the PR-to-issue
  conversion, why processing is sequential)
- GitHub backup on Unraid (based on the RayLabsHQ CA template)

All five are registered in the use-cases index; appended after the
existing entries so the homepage's featured three stay unchanged.

Interlinking pass across the existing articles: contextual inline
links where the text already referenced a topic, plus a short related
guides section per article. The comparison page's next steps now cover
the new guides, and the docs quickstart bridges to the use-case section.
Every article now carries 4 to 9 internal links; a build-time check
found 0 broken links across 440.
2026-08-20 06:33:04 +05:30
Arunavo Ray 85b72e9366 chore: bump version to 3.27.0 2026-08-20 06:03:34 +05:30
ARUNAVO RAY 0f700c7197 feat: per-repository and per-organization mirror option overrides (#362)
* feat: per-repository and per-organization mirror option overrides

Closes the gap reported in #361: a repository whose LFS fetch fails could
not be mirrored at all, because LFS was a single global switch. Gitea runs
the LFS fetch inside its own migration, so a failure aborts the whole
migration with nothing to salvage. The only fix available to us is to stop
asking for LFS on that repository.

Mirror options now resolve across three tiers, per flag, most specific
first: repository override, then organization override, then the global
config. NULL at a tier means inherit, so overriding LFS on one repo leaves
its other options alone.

Adds resolveMirrorOptions(), which replaces 24 scattered reads of
config.giteaConfig.* across three near-identical blocks: the two mirror
paths in gitea.ts and the sync path in gitea-enhanced.ts. The third was
easy to miss and mattered: without it, overrides would have been honored
on first mirror and silently ignored on every scheduled sync afterwards.

The starredCodeOnly clamp is folded into the resolver and deliberately
outranks explicit overrides, preserving the existing behavior that starred
repos mirror code only.

Editing is on the objects themselves, via the existing three-dot menus on
the Repositories and Organizations pages, not in Configuration, which keeps
holding the global defaults. A repositories filter and a row badge make it
possible to find which repos deviate.

Malformed override JSON degrades to "inherit" rather than throwing, so a
bad value can never break a mirror run.

* fix(ui): disable mirror toggles that cannot take effect, and explain why

A toggle the runtime will ignore should not look editable. Three cases
were doing exactly that, so they now share one mechanism:
getMirrorOverrideGating() returns a reason string per flag, and the dialog
disables the control and prints that reason underneath.

Starred clamp. When a repo is starred and starredCodeOnly is set, the
resolver forces every metadata flag off regardless of the override. The
dialog previously let the user set them anyway. The clamped set now lives
in STARRED_CLAMPED_KEYS, shared by the resolver and the gating helper so
the flags the UI disables cannot drift from the ones the runtime clamps.
LFS is deliberately excluded from that set, since turning LFS off per
repository is the point of #361, and a test pins that.

Labels. shouldMirrorLabels is `mirrorLabels && !mirrorIssues` in both
mirror paths, so labels cannot take effect while issues are mirrored. That
gate reacts live to the in-progress edit rather than only the saved state.

Inherit hint. For a repository the hint now reflects global -> org instead
of global alone, via a new name-keyed GET
/api/organizations/mirror-overrides that reuses
loadOrganizationMirrorOverrides. Personal repos skip the fetch, a failed
fetch degrades to the global values rather than blocking the dialog, and
the hint is suppressed while in flight so it is never briefly wrong.

Widens the UI to mirrorLabels and mirrorMilestones. mirrorMetadata stays
out: no mirror path reads it, so a per-object override would resolve
correctly and then do nothing. See the report for that finding.

useGiteaConfig additionally returns advancedOptions so callers can read
starredCodeOnly without a second request.

* fix(ui): read inherited mirror flags from mirrorOptions, not giteaConfig

/api/config does not return the mirror flags on giteaConfig. On the way
out, mapDbToUiConfig reshapes them into a separate mirrorOptions object
using different names (mirrorLFS, not lfs) and nesting the metadata flags
under metadataComponents with short names (issues, not mirrorIssues).

The dialog was written against the DB shape, so every lookup returned
undefined. Coerced to false, that is indistinguishable from a real "off",
which produced two symptoms: every inherit hint read "currently off"
regardless of the actual global config, and the labels gate never fired
because the effective issues value it keys on came from the same dead
source.

Adds mirrorOptionsToFlags() as the single conversion point, matching the
derivation in mapUiToDbConfig exactly, including that mirrorMetadata is a
master switch over the metadata components while lfs and mirrorReleases
sit outside it. Both dialogs now go through it, and useGiteaConfig returns
mirrorOptions alongside advancedOptions.

Server-side mirroring was never affected: the resolver reads config
straight from the DB, where the flags really do live on giteaConfig.

The existing tests could not catch this because they build a synthetic
config already in DB shape, so a client/server mismatch is invisible to
them. The new tests push flags through the real config-mapper in both
directions and assert the derived flags equal what mapUiToDbConfig would
persist, so they fail if that mapping changes again. One test pins the
root cause directly: that giteaConfig in the API payload carries no flags.

* fix(ui): surface the mirror-options filter on desktop, and on organizations

The overrides filter only existed inside the mobile filter drawer, so on
desktop the Repositories page showed a "Custom" badge on overridden rows
with no way to filter to them. Being able to answer "which repos deviate
from my defaults" on this page is the reason the Configuration-page
listing was dropped, so desktop could not do the one job it was given.

Adds the control to the desktop filter row next to status and sort, using
the bare Select-with-placeholder style those use rather than the drawer's
labelled markup. The mobile control is unchanged.

The Organizations page had a wider version of the same gap: it renders the
"Custom options" badge but never had this filter on either layout, and
OrganizationsList did not filter on it at all. Added the predicate plus
the control in both its layouts, so the two pages behave the same.

Also folds hasOverrides into activeFilterCount on both pages and into the
Organizations clear-all reset. Without that the mobile filter badge
undercounted an active overrides filter, and clearing filters left it set.

* fix(ui): stop double-applying the mirrorMetadata switch when reading config

mirrorOptionsToFlags ANDed each metadata component with mirrorMetadata on
the way in. That is a write-path rule: mapUiToDbConfig already applies it
when persisting, so a config saved through the settings UI has it baked
into the stored flag. Applying it again on read double-applies it.

The read path does not need it. mapDbToUiConfig puts the raw stored values
into metadataComponents, so metadataComponents.issues is exactly
giteaConfig.mirrorIssues, which is the field gitea.ts and
gitea-enhanced.ts read. Mapping the components straight through is 1:1
with runtime behavior.

Double-applying was invisible while the stored state was self-consistent,
since false && false is still false. It diverged when the state did not
come from the UI write path, which env vars allow: MIRROR_METADATA=false
with MIRROR_ISSUES=true stores mirrorMetadata:false, mirrorIssues:true.
The runtime mirrors issues; the dialog reported off. Measured against a
config in that state, five flags misreported.

I previously described this as an unrecoverable loss in the API shape.
That was wrong. Both values reach the client separately and unmerged, so
the payload carries full information and the loss was one we introduced.

Rewrites the test that asserted the derived flags match what
mapUiToDbConfig would persist. Comparing against the write derivation is
what pinned the bug in place. It now asserts the property that matters,
that flags derived from the API payload equal the stored flags the runtime
reads, plus a case checking agreement with resolveMirrorOptions on an
inconsistent config. Both fail if the AND returns.
2026-08-20 06:03:19 +05:30
Arunavo Ray a295a73066 fix(deps): patch nanoid, js-yaml and postcss in the www lockfile
Clears three Dependabot alerts on the marketing site:

  nanoid   3.3.16 -> 3.3.18  (infinite loop when size is zero)
  js-yaml  4.3.0  -> 4.3.1   (quadratic CPU on !!omap resolution)
  postcss  8.5.19 -> 8.5.26  (attacker-controlled source parsing)

All three are transitive and moved within their existing ranges, so the
change is version and integrity hashes only with no graph churn.
Verified with a frozen-lockfile install and a full site build.
2026-08-17 14:16:02 +05:30
Arunavo Ray 67cd0e2c60 docs: restore star history chart with scoped access token
GitHub restricted the stargazers API to repo admins and collaborators,
which broke the unauthenticated chart. Star History now needs a token
sealed into the image URL.

Uses a fine-grained token limited to this repository instead of a
classic public_repo token. The stargazers endpoint requires Contents
write, and classic public_repo grants that across every public repo the
account can push to, so scoping it to one repo keeps the blast radius
small. Legend moved to top-left to sit clear of the curve.
2026-08-17 14:11:31 +05:30
ARUNAVO RAY c9edc2132b Update star history links and image sources 2026-08-16 21:19:36 +05:30
Arunavo Ray d4db47c76e chore: bump version to 3.26.2 2026-08-07 17:03:41 +05:30
Arunavo Ray 91eb195494 fix(ui): smooth marquee scrolling and widen its hover target
Animating text-indent forced a relayout every frame, which made the
marquee visibly choppy. The scroll is now a compositor-driven transform.
The content stays plain inline text while at rest so the native ellipsis
still renders, and swaps to an inline-block only for the duration of the
animation.

The hover target is now the whole repository cell instead of just the
text, via a shared MarqueeTrigger wrapper. Name and path scroll together
at the same speed, so the motion reads as one block.
2026-08-07 13:38:47 +05:30
56 changed files with 5354 additions and 304 deletions

No files matched your search

+4 -4
View File
@@ -521,11 +521,11 @@ GNU Affero General Public License v3.0 (AGPL-3.0) - see [LICENSE](LICENSE) file
## Star History
<a href="https://www.star-history.com/#RayLabsHQ/gitea-mirror&type=date&legend=bottom-right">
<a href="https://www.star-history.com/?repos=RayLabsHQ%2Fgitea-mirror&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=date&theme=dark&legend=bottom-right" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=date&legend=bottom-right" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=RayLabsHQ/gitea-mirror&type=date&legend=bottom-right" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=RayLabsHQ/gitea-mirror&type=date&theme=dark&legend=top-left&sealed_token=LFtWMyznw4suPqb4AAuXEsJNxZsLAnPrarSxYyLqeFLFlV8VzOi75LAVa50puyktOwRHd0XK2Ilgi0EFPxQxkFW1_2bEhDESK08QVQenFjLbKuLQI9E5h1c4R1_m3ZIKTISQijXxiOkAKwX7DMEUD96Uto-Ge8_09sVTeeOcfGp9aXOUK6nDV2oKhDfI" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=RayLabsHQ/gitea-mirror&type=date&legend=top-left&sealed_token=LFtWMyznw4suPqb4AAuXEsJNxZsLAnPrarSxYyLqeFLFlV8VzOi75LAVa50puyktOwRHd0XK2Ilgi0EFPxQxkFW1_2bEhDESK08QVQenFjLbKuLQI9E5h1c4R1_m3ZIKTISQijXxiOkAKwX7DMEUD96Uto-Ge8_09sVTeeOcfGp9aXOUK6nDV2oKhDfI" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=RayLabsHQ/gitea-mirror&type=date&legend=top-left&sealed_token=LFtWMyznw4suPqb4AAuXEsJNxZsLAnPrarSxYyLqeFLFlV8VzOi75LAVa50puyktOwRHd0XK2Ilgi0EFPxQxkFW1_2bEhDESK08QVQenFjLbKuLQI9E5h1c4R1_m3ZIKTISQijXxiOkAKwX7DMEUD96Uto-Ge8_09sVTeeOcfGp9aXOUK6nDV2oKhDfI" />
</picture>
</a>
+1 -1
View File
@@ -279,7 +279,7 @@ Configure automatic cleanup of old events and data.
| Variable | Description | Default | Options |
|----------|-------------|---------|---------|
| `CLEANUP_DELETE_FROM_GITEA` | Delete repositories from Gitea | `false` | `true`, `false` |
| `CLEANUP_DELETE_FROM_GITEA` | Apply the orphaned-repo action on the Gitea side too. When `false` (default), cleanup only updates gitea-mirror's own database (orphans are marked archived or removed from the repo list) and the Gitea/Forgejo copies are left untouched | `false` | `true`, `false` |
| `CLEANUP_DELETE_IF_NOT_IN_GITHUB` | Delete repos not found in GitHub (automatically enables cleanup) | `true` | `true`, `false` |
| `CLEANUP_ORPHANED_REPO_ACTION` | Action for orphaned repositories. **Note**: `archive` is recommended to preserve backups | `archive` | `skip`, `archive`, `delete` |
| `CLEANUP_DRY_RUN` | Test mode without actual deletion | `false` | `true`, `false` |
+1
View File
@@ -115,6 +115,7 @@ Working Authentik deployments (see [#134](https://github.com/RayLabsHQ/gitea-mir
Notes:
- Make sure `BETTER_AUTH_URL` and (if you serve the UI from multiple origins) `BETTER_AUTH_TRUSTED_ORIGINS` point at the public URL users reach. A mismatch can surface as 500 errors after redirect.
- **Internal / split-DNS IdPs**: since v3.21.0 the SSO sign-in refuses identity providers whose hostnames resolve to private addresses (SSRF hardening in the auth library) unless the IdP's origin is trusted. Providers you register through the UI are trusted automatically, but if you register a *new* provider whose hostname resolves to a LAN IP from inside the container (common with split-horizon DNS), add its origin to `BETTER_AUTH_TRUSTED_ORIGINS` first, e.g. `BETTER_AUTH_TRUSTED_ORIGINS=https://auth.example.com`.
- Set the **Domain** field to the email domain your Authentik users actually have. Auto-linking to an existing local admin only happens for emails in that domain — see [Account Linking](#account-linking) for the trust model. (Authentik's default email scope mapping returns `email_verified: False` for OIDC clients, which is why account linking is gated on the domain match here rather than the IdP's verified-email claim.)
- If you created an Authentik provider before v3.8.10 you should delete it and re-add it after upgrading; older versions saved incomplete endpoint data which leads to the `url.startsWith` error explained in the Troubleshooting section.
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `organizations` ADD `mirror_overrides` text;--> statement-breakpoint
ALTER TABLE `repositories` ADD `mirror_overrides` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -99,6 +99,13 @@
"when": 1780377747526,
"tag": "0013_slim_galactus",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1787182504975,
"tag": "0014_needy_white_tiger",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.26.1",
"version": "3.27.2",
"engines": {
"bun": ">=1.2.9"
},
+64
View File
@@ -294,6 +294,66 @@ function verify0013Migration(db: any) {
assert(normName.dflt_value === null, `Expected normalized_name to have no default, got ${normName.dflt_value}`);
}
function seedPre0014Database(db: any) {
// Migrations 0000-0013 have run, so repositories/organizations exist but
// lack mirror_overrides. Seed one of each so the column-add can be verified
// against pre-existing rows (they must come out NULL = "inherit").
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-ovr', 'ovr@example.com', 'ovr', 'Override User')");
db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre14', 'u-ovr', 'Default', 1, '{}', '{}', '{}', '{}')");
db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, normalized_name) VALUES ('org-pre14', 'u-ovr', 'cfg-pre14', 'ExampleOrg', 'https://example.com/a.png', 'exampleorg')");
db.run(
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) " +
"VALUES ('repo-pre14', 'u-ovr', 'cfg-pre14', 'browser-use', 'ExampleOrg/browser-use', 'exampleorg/browser-use', 'https://github.com/ExampleOrg/browser-use', 'https://github.com/ExampleOrg/browser-use.git', 'ExampleOrg', 'main')",
);
}
function verify0014Migration(db: any) {
// Both tables gained a nullable mirror_overrides column with no default.
for (const table of ["repositories", "organizations"]) {
const cols = db
.query(`PRAGMA table_info(${table})`)
.all() as Array<{ name: string; type: string; notnull: number; dflt_value: string | null }>;
const col = cols.find((c) => c.name === "mirror_overrides");
assert(col, `Expected ${table}.mirror_overrides column to exist`);
assert(col.notnull === 0, `Expected ${table}.mirror_overrides to be nullable`);
assert(
col.dflt_value === null,
`Expected ${table}.mirror_overrides to have no default, got ${col.dflt_value}`,
);
}
// Pre-existing rows inherit (NULL), rather than being backfilled with {}.
const repoRow = db
.query("SELECT id, mirror_overrides FROM repositories WHERE id = 'repo-pre14'")
.get() as { id: string; mirror_overrides: string | null } | null;
assert(repoRow, "Expected pre-existing repository row to survive migration");
assert(
repoRow.mirror_overrides === null,
`Expected existing repository to inherit (NULL mirror_overrides), got ${repoRow.mirror_overrides}`,
);
const orgRow = db
.query("SELECT id, mirror_overrides FROM organizations WHERE id = 'org-pre14'")
.get() as { id: string; mirror_overrides: string | null } | null;
assert(orgRow, "Expected pre-existing organization row to survive migration");
assert(
orgRow.mirror_overrides === null,
`Expected existing organization to inherit (NULL mirror_overrides), got ${orgRow.mirror_overrides}`,
);
// The new column round-trips a JSON overrides payload (the #361 case:
// mirror this repo but skip its LFS objects).
db.run(
"UPDATE repositories SET mirror_overrides = '{\"lfs\":false}' WHERE id = 'repo-pre14'",
);
const updated = db
.query("SELECT mirror_overrides FROM repositories WHERE id = 'repo-pre14'")
.get() as { mirror_overrides: string } | null;
assert(updated, "Expected repository row after override update");
const parsed = JSON.parse(updated.mirror_overrides);
assert(parsed.lfs === false, `Expected persisted lfs override false, got ${updated.mirror_overrides}`);
}
const MIGRATION_0012_TIMESTAMP = 1774062000000;
const MIGRATION_0013_TIMESTAMP = 1780377747526;
@@ -409,6 +469,10 @@ const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
seed: seedPre0013Database,
verify: verify0013Migration,
},
"0014_needy_white_tiger": {
seed: seedPre0014Database,
verify: verify0014Migration,
},
};
function lintMigrations(selectedMigrations: Migration[]) {
+10 -1
View File
@@ -69,13 +69,22 @@ export function LoginForm() {
typeof window !== 'undefined'
? new URL(withBase('/'), window.location.origin).toString()
: `http://localhost:4321${withBase('/')}`;
await authClient.signIn.sso({
const result = await authClient.signIn.sso({
email: ssoEmail || undefined,
domain: domain,
providerId: providerId,
callbackURL,
scopes: ['openid', 'email', 'profile'], // TODO: This is not being respected by the SSO plugin.
});
// The auth client resolves with { data, error } instead of throwing, so
// a server-side rejection (e.g. the SSO plugin refusing an IdP whose
// hostname resolves to a private address) never reaches the catch below.
if (result?.error) {
showErrorToast(
new Error(result.error.message || `SSO sign-in failed (HTTP ${result.error.status})`),
toast
);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
@@ -0,0 +1,245 @@
import { useEffect, useMemo, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { MirrorOverrides } from "@/lib/db/schema";
import {
getMirrorOverrideGating,
MIRROR_OVERRIDE_LABELS,
UI_MIRROR_OVERRIDE_KEYS,
type MirrorOverrideKey,
} from "@/lib/utils/mirror-overrides";
/**
* Tri-state value for one flag in the dialog. "inherit" persists as absent,
* which is what lets the next tier out supply the value.
*/
type TriState = "inherit" | "on" | "off";
function toTriState(value: boolean | null | undefined): TriState {
if (value === true) return "on";
if (value === false) return "off";
return "inherit";
}
function fromTriState(value: TriState): boolean | undefined {
if (value === "on") return true;
if (value === "off") return false;
return undefined;
}
export interface MirrorOverridesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Display name of the repo or org being edited. */
targetName: string;
/** "repository" or "organization", used for wording and gating rules. */
targetKind: "repository" | "organization";
value?: MirrorOverrides | null;
/**
* Effective values from the tiers above this one, used to label what
* "Inherit" actually resolves to right now. For a repository this should
* already be global merged with its organization's overrides.
*/
inheritedFrom?: Partial<Record<MirrorOverrideKey, boolean>>;
inheritedLabel?: string;
/**
* True while the inherited values are still being fetched. The hint is
* suppressed rather than shown wrong and then corrected.
*/
inheritedLoading?: boolean;
/** Repository-only: drives the starred-code-only gating. */
isStarred?: boolean;
starredCodeOnly?: boolean;
onSave: (overrides: MirrorOverrides | null) => Promise<void>;
}
export function MirrorOverridesDialog({
open,
onOpenChange,
targetName,
targetKind,
value,
inheritedFrom,
inheritedLabel = "global settings",
inheritedLoading = false,
isStarred,
starredCodeOnly,
onSave,
}: MirrorOverridesDialogProps) {
const [draft, setDraft] = useState<Record<MirrorOverrideKey, TriState>>(
() => buildDraft(value)
);
const [isSaving, setIsSaving] = useState(false);
// Re-seed whenever the dialog opens or targets a different object, so a
// previous edit never leaks into the next one.
useEffect(() => {
if (open) setDraft(buildDraft(value));
}, [open, value]);
// What each flag currently resolves to, including the in-progress edit.
// Drives the labels gate so it reacts live as issues is toggled.
const effective = useMemo(() => {
const next: Partial<Record<MirrorOverrideKey, boolean>> = {};
for (const key of UI_MIRROR_OVERRIDE_KEYS) {
const pinned = fromTriState(draft[key]);
next[key] = pinned ?? inheritedFrom?.[key] ?? false;
}
return next;
}, [draft, inheritedFrom]);
const gating = useMemo(
() =>
getMirrorOverrideGating({
targetKind,
isStarred,
starredCodeOnly,
effective,
}),
[targetKind, isStarred, starredCodeOnly, effective]
);
const overriddenCount = useMemo(
() => Object.values(draft).filter((state) => state !== "inherit").length,
[draft]
);
const handleSave = async () => {
setIsSaving(true);
try {
// Gated flags keep whatever the user previously stored. The clamp is a
// runtime concern, so a disabled toggle must not silently erase data.
const next: MirrorOverrides = {};
for (const key of UI_MIRROR_OVERRIDE_KEYS) {
const resolved = fromTriState(draft[key]);
if (typeof resolved === "boolean") next[key] = resolved;
}
await onSave(Object.keys(next).length > 0 ? next : null);
onOpenChange(false);
} finally {
setIsSaving(false);
}
};
const handleResetAll = () => {
setDraft(buildDraft(null));
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Mirror options</DialogTitle>
<DialogDescription>
Override what gets mirrored for this {targetKind}.{" "}
<span className="font-medium text-foreground">{targetName}</span>{" "}
inherits anything left on Inherit.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
{UI_MIRROR_OVERRIDE_KEYS.map((key) => {
const disabledReason = gating[key];
const isDisabled = !!disabledReason;
const inherited = inheritedFrom?.[key];
const showHint =
!inheritedLoading &&
!isDisabled &&
draft[key] === "inherit" &&
inherited !== undefined;
return (
<div key={key} className="space-y-1">
<div className="flex items-center justify-between gap-4">
<Label
htmlFor={`override-${key}`}
className={isDisabled ? "flex-1 text-muted-foreground" : "flex-1"}
>
{MIRROR_OVERRIDE_LABELS[key]}
{showHint && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
currently {inherited ? "on" : "off"}
</span>
)}
</Label>
<Select
value={draft[key]}
disabled={isDisabled}
onValueChange={(next) =>
setDraft((prev) => ({ ...prev, [key]: next as TriState }))
}
>
<SelectTrigger id={`override-${key}`} className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Inherit</SelectItem>
<SelectItem value="on">On</SelectItem>
<SelectItem value="off">Off</SelectItem>
</SelectContent>
</Select>
</div>
{isDisabled && (
<p className="text-xs text-muted-foreground">
{disabledReason}
</p>
)}
</div>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Inherit uses the {inheritedLabel}. Git LFS is worth turning off for
repositories whose LFS fetch fails, since Gitea aborts the whole
migration when it cannot pull them.
</p>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="ghost"
onClick={handleResetAll}
disabled={isSaving || overriddenCount === 0}
>
Reset all
</Button>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function buildDraft(
value: MirrorOverrides | null | undefined
): Record<MirrorOverrideKey, TriState> {
const draft = {} as Record<MirrorOverrideKey, TriState>;
for (const key of UI_MIRROR_OVERRIDE_KEYS) {
draft[key] = toTriState(value?.[key]);
}
return draft;
}
+54 -1
View File
@@ -475,7 +475,7 @@ export function Organization() {
// Check if any filters are active
const hasActiveFilters = !!(filter.membershipRole || filter.status);
const activeFilterCount = [filter.membershipRole, filter.status].filter(Boolean).length;
const activeFilterCount = [filter.membershipRole, filter.status, filter.hasOverrides].filter(Boolean).length;
// Clear all filters
const clearFilters = () => {
@@ -483,6 +483,7 @@ export function Organization() {
searchTerm: filter.searchTerm,
membershipRole: "",
status: "",
hasOverrides: "",
});
};
@@ -653,6 +654,37 @@ export function Organization() {
</SelectContent>
</Select>
</div>
{/* Mirror Options Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-muted-foreground">By</span> Mirror options
{filter.hasOverrides && (
<span className="ml-auto text-xs text-muted-foreground">
{filter.hasOverrides === "overridden" ? "Custom" : "Default"}
</span>
)}
</label>
<Select
value={filter.hasOverrides || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
hasOverrides:
value === "all" ? "" : (value as "overridden" | "default"),
}))
}
>
<SelectTrigger className="w-full h-10">
<SelectValue placeholder="All organizations" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All organizations</SelectItem>
<SelectItem value="overridden">Custom options</SelectItem>
<SelectItem value="default">Using defaults</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DrawerFooter className="gap-2 px-4 pt-2 pb-4 border-t">
@@ -793,6 +825,27 @@ export function Organization() {
))}
</SelectContent>
</Select>
{/* Mirror Options Filter */}
<Select
value={filter.hasOverrides || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
hasOverrides:
value === "all" ? "" : (value as "overridden" | "default"),
}))
}
>
<SelectTrigger className="w-[170px] h-10">
<SelectValue placeholder="All mirror options" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All mirror options</SelectItem>
<SelectItem value="overridden">Custom options</SelectItem>
<SelectItem value="default">Using defaults</SelectItem>
</SelectContent>
</Select>
</div>
{/* Action buttons */}
@@ -1,16 +1,18 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, Trash2 } from "lucide-react";
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, SlidersHorizontal, Trash2 } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si";
import type { Organization } from "@/lib/db/schema";
import type { MirrorOverrides, Organization } from "@/lib/db/schema";
import type { FilterParams } from "@/types/filter";
import Fuse from "fuse.js";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url";
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
import { MirrorOverridesDialog } from "@/components/config/MirrorOverridesDialog";
import { hasMirrorOverrides, mirrorOptionsToFlags } from "@/lib/utils/mirror-overrides";
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { withBase } from "@/lib/base-path";
import {
@@ -65,7 +67,28 @@ export function OrganizationList({
onRefresh,
onDelete,
}: OrganizationListProps) {
const { giteaConfig } = useGiteaConfig();
const { giteaConfig, mirrorOptions } = useGiteaConfig();
const [overridesTarget, setOverridesTarget] = useState<Organization | null>(null);
const handleUpdateMirrorOverrides = async (
orgId: string,
overrides: MirrorOverrides | null
) => {
const response = await fetch(`${withBase("/api/organizations")}/${orgId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mirrorOverrides: overrides }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to update mirror options");
}
if (onRefresh) {
await onRefresh();
}
};
// Helper function to construct Gitea organization URL
const getGiteaOrgUrl = (organization: Organization): string | null => {
@@ -122,6 +145,13 @@ export function OrganizationList({
result = result.filter((org) => org.status === filter.status);
}
if (filter.hasOverrides) {
const wantOverridden = filter.hasOverrides === "overridden";
result = result.filter(
(org) => hasMirrorOverrides(org.mirrorOverrides) === wantOverridden
);
}
if (filter.searchTerm) {
const fuse = new Fuse(result, {
keys: ["name", "type"],
@@ -281,13 +311,24 @@ export function OrganizationList({
</div>
{/* Status badge */}
<Badge variant={statusBadge.variant} className="flex items-center gap-1">
{StatusIcon && <StatusIcon className={cn(
"h-3.5 w-3.5",
org.status === "mirroring" && "animate-pulse"
)} />}
{statusBadge.label}
</Badge>
<div className="flex items-center gap-2">
{hasMirrorOverrides(org.mirrorOverrides) && (
<Badge
variant="outline"
className="text-amber-600 dark:text-amber-400 border-amber-500/40"
title="This organization overrides the global mirror options"
>
Custom options
</Badge>
)}
<Badge variant={statusBadge.variant} className="flex items-center gap-1">
{StatusIcon && <StatusIcon className={cn(
"h-3.5 w-3.5",
org.status === "mirroring" && "animate-pulse"
)} />}
{statusBadge.label}
</Badge>
</div>
</div>
{/* Destination override section */}
@@ -429,6 +470,10 @@ export function OrganizationList({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOverridesTarget(org)}>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Mirror Options
</DropdownMenuItem>
{org.status !== "ignored" && (
<DropdownMenuItem
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
@@ -590,8 +635,12 @@ export function OrganizationList({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOverridesTarget(org)}>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Mirror Options
</DropdownMenuItem>
{org.status !== "ignored" && (
<DropdownMenuItem
<DropdownMenuItem
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
>
<Ban className="h-4 w-4 mr-2" />
@@ -600,7 +649,7 @@ export function OrganizationList({
)}
{onDelete && (
<>
{org.status !== "ignored" && <DropdownMenuSeparator />}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => org.id && onDelete(org.id)}
@@ -685,6 +734,23 @@ export function OrganizationList({
</Card>
);
})}
<MirrorOverridesDialog
open={!!overridesTarget}
onOpenChange={(open) => {
if (!open) setOverridesTarget(null);
}}
targetKind="organization"
targetName={overridesTarget?.name ?? ""}
value={overridesTarget?.mirrorOverrides ?? null}
inheritedFrom={mirrorOptionsToFlags(mirrorOptions)}
inheritedLabel="global settings"
onSave={async (overrides) => {
if (overridesTarget?.id) {
await handleUpdateMirrorOverrides(overridesTarget.id, overrides);
}
}}
/>
</div>
);
}
+55 -1
View File
@@ -1028,7 +1028,7 @@ export default function Repository() {
// Check if any filters are active
const hasActiveFilters = !!(filter.owner || filter.organization || filter.status);
const activeFilterCount = [filter.owner, filter.organization, filter.status].filter(Boolean).length;
const activeFilterCount = [filter.owner, filter.organization, filter.status, filter.hasOverrides].filter(Boolean).length;
// Clear all filters
const clearFilters = () => {
@@ -1037,6 +1037,7 @@ export default function Repository() {
status: "",
organization: "",
owner: "",
hasOverrides: "",
sort: filter.sort || "imported-desc",
});
};
@@ -1179,6 +1180,39 @@ export default function Repository() {
</Select>
</div>
{/* Mirror Options Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-muted-foreground">By</span> Mirror options
{filter.hasOverrides && (
<span className="ml-auto text-xs text-muted-foreground">
{filter.hasOverrides === "overridden" ? "Custom" : "Default"}
</span>
)}
</label>
<Select
value={filter.hasOverrides || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
hasOverrides:
value === "all"
? ""
: (value as "overridden" | "default"),
}))
}
>
<SelectTrigger className="w-full h-10">
<SelectValue placeholder="All repositories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All repositories</SelectItem>
<SelectItem value="overridden">Custom options</SelectItem>
<SelectItem value="default">Using defaults</SelectItem>
</SelectContent>
</Select>
</div>
{/* Sort Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
@@ -1307,6 +1341,26 @@ export default function Repository() {
</SelectContent>
</Select>
<Select
value={filter.hasOverrides || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
hasOverrides:
value === "all" ? "" : (value as "overridden" | "default"),
}))
}
>
<SelectTrigger className="w-[170px] h-10">
<SelectValue placeholder="All mirror options" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All mirror options</SelectItem>
<SelectItem value="overridden">Custom options</SelectItem>
<SelectItem value="default">Using defaults</SelectItem>
</SelectContent>
</Select>
<Select
value={filter.sort || "imported-desc"}
onValueChange={(value) =>
+164 -8
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
getCoreRowModel,
getFilteredRowModel,
@@ -9,9 +9,9 @@ import {
type SortingState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, Trash2, X } from "lucide-react";
import { FlipHorizontal, GitFork, MoreVertical, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, SlidersHorizontal, Trash2, X } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si";
import type { Repository } from "@/lib/db/schema";
import type { MirrorOverrides, Repository } from "@/lib/db/schema";
import { Button } from "@/components/ui/button";
import { formatLastSyncTime } from "@/lib/utils";
import { buildGiteaWebUrl } from "@/lib/gitea-url";
@@ -26,7 +26,13 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { InlineDestinationEditor } from "./InlineDestinationEditor";
import { MarqueeText } from "@/components/ui/marquee-text";
import { MarqueeText, MarqueeTrigger } from "@/components/ui/marquee-text";
import { MirrorOverridesDialog } from "@/components/config/MirrorOverridesDialog";
import {
hasMirrorOverrides,
mirrorOptionsToFlags,
type MirrorOverrideKey,
} from "@/lib/utils/mirror-overrides";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { withBase } from "@/lib/base-path";
@@ -101,7 +107,83 @@ export default function RepositoryTable({
}: RepositoryTableProps) {
const tableParentRef = useRef<HTMLDivElement>(null);
const lastSelectedIndexRef = useRef<number | null>(null);
const { giteaConfig } = useGiteaConfig();
const { giteaConfig, advancedOptions, mirrorOptions } = useGiteaConfig();
const [overridesTarget, setOverridesTarget] = useState<Repository | null>(null);
// Organization-tier overrides for the repo being edited, so the dialog's
// "Inherit" hint reflects global -> org rather than global alone.
const [orgOverrides, setOrgOverrides] = useState<MirrorOverrides | null>(null);
const [orgOverridesLoading, setOrgOverridesLoading] = useState(false);
useEffect(() => {
const orgName = overridesTarget?.organization;
// Personal repos have no org tier to fetch.
if (!overridesTarget || !orgName) {
setOrgOverrides(null);
setOrgOverridesLoading(false);
return;
}
let cancelled = false;
setOrgOverridesLoading(true);
setOrgOverrides(null);
(async () => {
try {
const response = await fetch(
`${withBase("/api/organizations/mirror-overrides")}?name=${encodeURIComponent(orgName)}`
);
if (!response.ok) throw new Error("Failed to load organization overrides");
const data = await response.json();
if (!cancelled) setOrgOverrides(data.mirrorOverrides ?? null);
} catch {
// Degrade to the global values rather than blocking the dialog.
if (!cancelled) setOrgOverrides(null);
} finally {
if (!cancelled) setOrgOverridesLoading(false);
}
})();
// Ignore a late response for a repo the user already navigated away from.
return () => {
cancelled = true;
};
}, [overridesTarget]);
// Global defaults with the organization tier layered on top.
// The globals come from mirrorOptions, not giteaConfig: /api/config reshapes
// the mirror flags on the way out and does not put them on giteaConfig.
const inheritedMirrorOptions = useMemo(() => {
const globals = mirrorOptionsToFlags(mirrorOptions);
if (!orgOverrides) return globals;
for (const key of Object.keys(globals) as MirrorOverrideKey[]) {
const orgValue = orgOverrides[key];
if (typeof orgValue === "boolean") globals[key] = orgValue;
}
return globals;
}, [mirrorOptions, orgOverrides]);
const handleUpdateMirrorOverrides = async (
repoId: string,
overrides: MirrorOverrides | null
) => {
const response = await fetch(`${withBase("/api/repositories")}/${repoId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mirrorOverrides: overrides }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to update mirror options");
}
if (onRefresh) {
await onRefresh();
}
};
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
// Call API to update repository destination
@@ -152,6 +234,7 @@ export default function RepositoryTable({
filter.status,
filter.owner,
filter.organization,
filter.hasOverrides,
].some((val) => val?.toString().trim() !== "");
const columnFilters = useMemo<ColumnFiltersState>(() => {
@@ -169,8 +252,12 @@ export default function RepositoryTable({
next.push({ id: "organization", value: filter.organization });
}
if (filter.hasOverrides) {
next.push({ id: "hasOverrides", value: filter.hasOverrides });
}
return next;
}, [filter.status, filter.owner, filter.organization]);
}, [filter.status, filter.owner, filter.organization, filter.hasOverrides]);
const sorting = useMemo(() => getTableSorting(filter.sort), [filter.sort]);
@@ -195,6 +282,13 @@ export default function RepositoryTable({
accessorFn: (row) => row.status,
filterFn: "equalsString",
},
{
id: "hasOverrides",
accessorFn: (row) =>
hasMirrorOverrides(row.mirrorOverrides) ? "overridden" : "default",
filterFn: "equalsString",
enableGlobalFilter: false,
},
{
id: "importedAt",
accessorFn: (row) => getTimestamp(row.importedAt),
@@ -324,8 +418,22 @@ export default function RepositoryTable({
{repo.isPrivate && <Badge variant="secondary" className="text-xs h-5"><Lock className="h-3 w-3 mr-1" />Private</Badge>}
{repo.isForked && <Badge variant="secondary" className="text-xs h-5"><GitFork className="h-3 w-3 mr-1" />Fork</Badge>}
{repo.isStarred && <Badge variant="secondary" className="text-xs h-5"><Star className="h-3 w-3 mr-1" />Starred</Badge>}
{hasMirrorOverrides(repo.mirrorOverrides) && <Badge variant="outline" className="text-xs h-5"><SlidersHorizontal className="h-3 w-3 mr-1" />Custom</Badge>}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" disabled={isLoading}>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOverridesTarget(repo)}>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Mirror Options
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Repository details */}
@@ -756,7 +864,7 @@ export default function RepositoryTable({
</div>
{/* Repository */}
<div className="h-full py-3 flex items-center gap-2 flex-[2.3] min-w-0">
<MarqueeTrigger className="h-full py-3 flex items-center gap-2 flex-[2.3] min-w-0">
<div className="flex-1 min-w-0">
<div className="font-medium flex items-center gap-1 min-w-0">
<MarqueeText>{repo.name}</MarqueeText>
@@ -778,7 +886,22 @@ export default function RepositoryTable({
Fork
</span>
)}
</div>
{hasMirrorOverrides(repo.mirrorOverrides) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-2 shrink-0 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-600 dark:text-amber-400">
Custom
</span>
</TooltipTrigger>
<TooltipContent>
This repository overrides the global mirror
options
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</MarqueeTrigger>
{/* Owner */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">{repo.owner}</p>
@@ -848,6 +971,7 @@ export default function RepositoryTable({
onDelete={onDelete && repo.id ? () => onDelete(repo.id as string) : undefined}
onApproveSync={onApproveSync ? () => onApproveSync({ repoId: repo.id ?? "" }) : undefined}
onDismissSync={onDismissSync ? () => onDismissSync({ repoId: repo.id ?? "" }) : undefined}
onEditMirrorOptions={repo.id ? () => setOverridesTarget(repo) : undefined}
/>
</div>
{/* Links */}
@@ -947,6 +1071,30 @@ export default function RepositoryTable({
</div>
</>
)}
<MirrorOverridesDialog
open={!!overridesTarget}
onOpenChange={(open) => {
if (!open) setOverridesTarget(null);
}}
targetKind="repository"
targetName={overridesTarget?.fullName ?? ""}
value={overridesTarget?.mirrorOverrides ?? null}
inheritedFrom={inheritedMirrorOptions}
inheritedLoading={orgOverridesLoading}
isStarred={!!overridesTarget?.isStarred}
starredCodeOnly={!!advancedOptions?.starredCodeOnly}
inheritedLabel={
overridesTarget?.organization
? "organization or global settings"
: "global settings"
}
onSave={async (overrides) => {
if (overridesTarget?.id) {
await handleUpdateMirrorOverrides(overridesTarget.id, overrides);
}
}}
/>
</div>
);
}
@@ -961,6 +1109,7 @@ function RepoActionButton({
onDelete,
onApproveSync,
onDismissSync,
onEditMirrorOptions,
}: {
repo: { id: string; status: string };
isLoading: boolean;
@@ -971,6 +1120,7 @@ function RepoActionButton({
onDelete?: () => void;
onApproveSync?: () => void;
onDismissSync?: () => void;
onEditMirrorOptions?: () => void;
}) {
// For pending-approval repos, show approve/dismiss actions
if (repo.status === "pending-approval") {
@@ -1087,6 +1237,12 @@ function RepoActionButton({
</DropdownMenuTrigger>
</div>
<DropdownMenuContent align="end">
{onEditMirrorOptions && (
<DropdownMenuItem onClick={onEditMirrorOptions}>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Mirror Options
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onSkip(true)}>
<Ban className="h-4 w-4 mr-2" />
Ignore Repository
+96 -34
View File
@@ -1,6 +1,39 @@
import { useCallback, useRef, useState } from "react";
import {
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
const MarqueeHoverContext = createContext<boolean | null>(null);
/**
* Shared hover target: every MarqueeText inside starts scrolling when the
* pointer is anywhere over this element, so the hit area isn't limited to
* the text itself.
*/
export function MarqueeTrigger({
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
const [hovering, setHovering] = useState(false);
return (
<div
{...props}
className={className}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
<MarqueeHoverContext.Provider value={hovering}>
{children}
</MarqueeHoverContext.Provider>
</div>
);
}
interface MarqueeTextProps {
children: React.ReactNode;
className?: string;
@@ -9,49 +42,78 @@ interface MarqueeTextProps {
}
/**
* Single-line text that truncates with an ellipsis and, when the content
* overflows, scrolls the hidden portion into view on hover (sliding back on
* mouse leave). Animates text-indent rather than transform so the content
* stays inline and native ellipsis rendering keeps working at rest.
* Single-line text that truncates with an ellipsis and scrolls the hidden
* portion into view on hover (sliding back on mouse leave). The scroll is a
* compositor-driven transform for smoothness; the content stays plain inline
* text while at rest because native ellipsis rendering doesn't work on the
* inline-block wrapper the transform needs.
*/
export function MarqueeText({ children, className, speed = 60 }: MarqueeTextProps) {
const ref = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLSpanElement>(null);
const groupHovering = useContext(MarqueeHoverContext);
const [selfHovering, setSelfHovering] = useState(false);
const hovering = groupHovering ?? selfHovering;
const [shift, setShift] = useState(0);
const [hovering, setHovering] = useState(false);
const [settled, setSettled] = useState(true);
const [scrolling, setScrolling] = useState(false);
const [resting, setResting] = useState(true);
const handleMouseEnter = useCallback(() => {
const el = ref.current;
if (!el) return;
// Measured on every hover so virtualized rows and resizes stay accurate
const overflow = Math.max(0, el.scrollWidth - el.clientWidth);
setShift(overflow);
setHovering(true);
if (overflow > 0) setSettled(false);
}, []);
useEffect(() => {
if (hovering) {
const container = containerRef.current;
if (!container) return;
// Measured on every hover so virtualized rows and resizes stay accurate
const overflow = Math.max(0, container.scrollWidth - container.clientWidth);
if (overflow === 0) return;
setShift(overflow);
setResting(false);
// Start the transform a frame after the inline-block swap commits so
// the transition animates instead of jumping to the end position
const raf = requestAnimationFrame(() =>
requestAnimationFrame(() => setScrolling(true))
);
return () => cancelAnimationFrame(raf);
}
const handleMouseLeave = useCallback(() => setHovering(false), []);
const scrolling = hovering && shift > 0;
setScrolling(false);
// If the text never actually moved (left before the start delay ended),
// no transition runs and no transitionend will fire — rest immediately
const content = contentRef.current;
const transform = content ? getComputedStyle(content).transform : "none";
if (transform === "none" || transform === "matrix(1, 0, 0, 1, 0, 0)") {
setResting(true);
}
}, [hovering]);
return (
<div
ref={ref}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onTransitionEnd={() => {
if (!hovering) setSettled(true);
}}
ref={containerRef}
onMouseEnter={groupHovering === null ? () => setSelfHovering(true) : undefined}
onMouseLeave={groupHovering === null ? () => setSelfHovering(false) : undefined}
className={cn("min-w-0 overflow-hidden whitespace-nowrap", className)}
style={{
textOverflow: settled && !scrolling ? "ellipsis" : "clip",
textIndent: scrolling ? -shift : 0,
transition: scrolling
? `text-indent ${shift / speed}s linear 0.3s`
: "text-indent 0.3s ease-out",
}}
style={{ textOverflow: resting ? "ellipsis" : "clip" }}
>
{children}
<span
ref={contentRef}
onTransitionEnd={(e) => {
if (e.propertyName === "transform" && !scrolling) setResting(true);
}}
style={
resting
? undefined
: {
display: "inline-block",
willChange: "transform",
transform: scrolling ? `translateX(-${shift}px)` : "translateX(0)",
transition: scrolling
? `transform ${shift / speed}s linear 0.3s`
: "transform 0.3s ease-out",
}
}
>
{children}
</span>
</div>
);
}
+21 -1
View File
@@ -1,11 +1,21 @@
import { useCallback, useEffect, useState } from 'react';
import { useAuth } from './useAuth';
import { apiRequest } from '@/lib/utils';
import type { ConfigApiResponse, GiteaConfig } from '@/types/config';
import type { AdvancedOptions, ConfigApiResponse, GiteaConfig, MirrorOptions } from '@/types/config';
import { getCachedConfig } from './useConfigStatus';
interface GiteaConfigHook {
giteaConfig: GiteaConfig | null;
/**
* Advanced options from the same config payload. Exposed so callers can read
* starredCodeOnly without a second request.
*/
advancedOptions: AdvancedOptions | null;
/**
* Mirror flags in the API's reshaped form. The mirror flags are NOT present
* on giteaConfig in this payload; use mirrorOptionsToFlags() to read them.
*/
mirrorOptions: MirrorOptions | null;
isLoading: boolean;
error: string | null;
}
@@ -18,6 +28,8 @@ export function useGiteaConfig(): GiteaConfigHook {
const { user } = useAuth();
const [giteaConfigState, setGiteaConfigState] = useState<GiteaConfigHook>({
giteaConfig: null,
advancedOptions: null,
mirrorOptions: null,
isLoading: true,
error: null,
});
@@ -26,6 +38,8 @@ export function useGiteaConfig(): GiteaConfigHook {
if (!user?.id) {
setGiteaConfigState({
giteaConfig: null,
advancedOptions: null,
mirrorOptions: null,
isLoading: false,
error: 'User not authenticated',
});
@@ -37,6 +51,8 @@ export function useGiteaConfig(): GiteaConfigHook {
if (cachedConfig) {
setGiteaConfigState({
giteaConfig: cachedConfig.giteaConfig || null,
advancedOptions: cachedConfig.advancedOptions || null,
mirrorOptions: cachedConfig.mirrorOptions || null,
isLoading: false,
error: null,
});
@@ -53,12 +69,16 @@ export function useGiteaConfig(): GiteaConfigHook {
setGiteaConfigState({
giteaConfig: configResponse?.giteaConfig || null,
advancedOptions: configResponse?.advancedOptions || null,
mirrorOptions: configResponse?.mirrorOptions || null,
isLoading: false,
error: null,
});
} catch (error) {
setGiteaConfigState({
giteaConfig: null,
advancedOptions: null,
mirrorOptions: null,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch Gitea configuration',
});
+56 -1
View File
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { resolveTrustedOrigins } from "./auth";
import { resolveTrustedOrigins, extractSsoProviderOrigins } from "./auth";
// Helper to create a mock Request with specific headers
function mockRequest(headers: Record<string, string>): Request {
@@ -117,3 +117,58 @@ describe("resolveTrustedOrigins", () => {
expect(origins).toContain("http://gitea.internal");
});
});
/**
* Regression coverage for issue #366: better-auth 1.6.23's SSO plugin
* refuses OIDC discovery from untrusted origins and rejects endpoints whose
* hostnames resolve to private addresses unless the origin is in
* trustedOrigins. Operator-registered providers are trusted automatically;
* extractSsoProviderOrigins is the pure extraction step feeding that.
*/
describe("extractSsoProviderOrigins", () => {
test("extracts the issuer origin, stripping any path", () => {
const origins = extractSsoProviderOrigins([
{ issuer: "https://auth.example.dev/application/o/gitea-mirror/", oidcConfig: null },
]);
expect(origins).toContain("https://auth.example.dev");
expect(origins).not.toContain("https://auth.example.dev/application/o/gitea-mirror/");
});
test("extracts endpoint origins from oidcConfig, including hosts that differ from the issuer", () => {
const origins = extractSsoProviderOrigins([
{
issuer: "https://auth.example.dev",
oidcConfig: JSON.stringify({
discoveryEndpoint: "https://auth.example.dev/.well-known/openid-configuration",
authorizationEndpoint: "https://auth.example.dev/authorize",
tokenEndpoint: "https://token.internal.example:8443/oauth/token",
userInfoEndpoint: "https://auth.example.dev/userinfo",
jwksEndpoint: "https://keys.example.dev/jwks",
}),
},
]);
expect(origins).toContain("https://auth.example.dev");
expect(origins).toContain("https://token.internal.example:8443");
expect(origins).toContain("https://keys.example.dev");
});
test("malformed oidcConfig JSON still yields the issuer origin without throwing", () => {
const origins = extractSsoProviderOrigins([
{ issuer: "https://auth.example.dev", oidcConfig: "{not json" },
]);
expect(origins).toContain("https://auth.example.dev");
});
test("skips non-URL values such as SAML entity IDs", () => {
const origins = extractSsoProviderOrigins([
{ issuer: "urn:example:idp", oidcConfig: null },
]);
expect(origins).toEqual([]);
});
test("handles empty and null fields without throwing", () => {
expect(extractSsoProviderOrigins([])).toEqual([]);
expect(extractSsoProviderOrigins([{ issuer: null, oidcConfig: null }])).toEqual([]);
expect(extractSsoProviderOrigins([{ issuer: "", oidcConfig: "" }])).toEqual([]);
});
});
+76 -1
View File
@@ -3,12 +3,72 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { jwt } from "better-auth/plugins";
import { oauthProvider } from "@better-auth/oauth-provider";
import { sso } from "@better-auth/sso";
import { db, users } from "./db";
import { db, users, ssoProviders } from "./db";
import * as schema from "./db/schema";
import { eq } from "drizzle-orm";
import { withBase } from "./base-path";
import { headerAuthPlugin } from "./auth-header-plugin";
/**
* Extracts the origins implied by registered SSO identity providers.
*
* The SSO plugin (since better-auth 1.6.23, shipped in v3.21.0) hardens
* sign-in against SSRF: it refuses to fetch OIDC discovery documents from
* untrusted origins and rejects token/userinfo/jwks endpoints whose
* hostnames resolve to private addresses — unless the origin is listed in
* `trustedOrigins`, the documented escape hatch for internal IdPs. Homelab
* deployments with split-horizon DNS (auth.example.com resolving to a LAN
* IP from inside the container) hit this on every sign-in, which the login
* UI used to swallow silently (#366).
*
* Registering a provider is an explicit operator action — the same trust
* model behind the `domainVerified` flag we set at registration — so the
* provider's issuer and endpoint origins are trusted automatically instead
* of requiring BETTER_AUTH_TRUSTED_ORIGINS to be set by hand. Note this
* also lets those origins pass Better Auth's CSRF/redirect-target checks,
* which is acceptable for operator-registered IdP infrastructure.
*
* Exported for testing.
*/
export function extractSsoProviderOrigins(
rows: Array<{ issuer?: string | null; oidcConfig?: string | null }>
): string[] {
const origins: string[] = [];
for (const row of rows) {
const candidates: unknown[] = [row?.issuer];
if (typeof row?.oidcConfig === "string" && row.oidcConfig.trim() !== "") {
try {
const cfg = JSON.parse(row.oidcConfig);
candidates.push(
cfg?.discoveryEndpoint,
cfg?.authorizationEndpoint,
cfg?.tokenEndpoint,
cfg?.userInfoEndpoint,
cfg?.jwksEndpoint,
);
} catch {
// A malformed provider row must not break auth for everyone;
// the issuer candidate above still applies.
}
}
for (const candidate of candidates) {
if (typeof candidate !== "string" || candidate.trim() === "") continue;
try {
const url = new URL(candidate);
// Only http(s) URLs have a meaningful origin — new URL() accepts
// values like SAML entity IDs ("urn:...") without throwing, but
// their .origin is the literal string "null".
if (url.protocol === "http:" || url.protocol === "https:") {
origins.push(url.origin);
}
} catch {
// Skip values that aren't URLs at all.
}
}
}
return origins;
}
/**
* Resolves the list of trusted origins for Better Auth CSRF validation.
* Exported for testing. Called per-request with the incoming Request,
@@ -48,6 +108,21 @@ export async function resolveTrustedOrigins(request?: Request): Promise<string[]
}
}
// Trust the origins of operator-registered SSO identity providers so the
// SSO plugin's SSRF checks accept them without manual env configuration.
// See extractSsoProviderOrigins for the rationale.
try {
const providerRows = await db
.select({ issuer: ssoProviders.issuer, oidcConfig: ssoProviders.oidcConfig })
.from(ssoProviders);
if (Array.isArray(providerRows)) {
origins.push(...extractSsoProviderOrigins(providerRows));
}
} catch {
// Fresh DB before migration or DB briefly unavailable — fall back to
// env-configured origins rather than failing auth outright.
}
// Auto-detect origin from the incoming request's Host header when running
// behind a reverse proxy. Helps with Better Auth's per-request CSRF check.
if (request?.headers) {
+32
View File
@@ -44,6 +44,28 @@ export const backupStrategyEnum = z.enum([
"block-on-force-push",
]);
/**
* Per-object overrides for the global mirror options in `giteaConfigSchema`.
*
* Every flag is nullable, and `null`/absent means "inherit from the next tier
* out". Resolution order is global config -> organization -> repository, and it
* is applied per flag, so a repository can override LFS while still inheriting
* the org's issue setting. See `resolveMirrorOptions` in
* `src/lib/utils/mirror-overrides.ts`.
*/
export const mirrorOverridesSchema = z.object({
lfs: z.boolean().nullable().optional(),
wiki: z.boolean().nullable().optional(),
mirrorReleases: z.boolean().nullable().optional(),
mirrorMetadata: z.boolean().nullable().optional(),
mirrorIssues: z.boolean().nullable().optional(),
mirrorPullRequests: z.boolean().nullable().optional(),
mirrorLabels: z.boolean().nullable().optional(),
mirrorMilestones: z.boolean().nullable().optional(),
});
export type MirrorOverrides = z.infer<typeof mirrorOverridesSchema>;
export const giteaConfigSchema = z.object({
url: z.url(),
externalUrl: z.url().optional(),
@@ -224,6 +246,7 @@ export const repositorySchema = z.object({
lastMirrored: z.coerce.date().optional().nullable(),
errorMessage: z.string().optional().nullable(),
destinationOrg: z.string().optional().nullable(),
mirrorOverrides: mirrorOverridesSchema.optional().nullable(),
metadata: z.string().optional().nullable(), // JSON string for metadata sync state
importedAt: z.coerce.date(),
createdAt: z.coerce.date(),
@@ -278,6 +301,7 @@ export const organizationSchema = z.object({
membershipRole: z.enum(["member", "admin", "owner", "billing_manager"]).default("member"),
isIncluded: z.boolean().default(true),
destinationOrg: z.string().optional().nullable(),
mirrorOverrides: mirrorOverridesSchema.optional().nullable(),
status: z
.enum([
"imported",
@@ -444,6 +468,10 @@ export const repositories = sqliteTable("repositories", {
destinationOrg: text("destination_org"),
// Per-repository mirror option overrides. NULL means "inherit" (from the
// organization, then the global config). See mirrorOverridesSchema.
mirrorOverrides: text("mirror_overrides", { mode: "json" }).$type<MirrorOverrides>(),
metadata: text("metadata"), // JSON string storing metadata sync state (issues, PRs, releases, etc.)
importedAt: integer("imported_at", { mode: "timestamp" })
.notNull()
@@ -529,6 +557,10 @@ export const organizations = sqliteTable("organizations", {
destinationOrg: text("destination_org"),
// Per-organization mirror option overrides. NULL means "inherit" from the
// global config; repository-level overrides win over these.
mirrorOverrides: text("mirror_overrides", { mode: "json" }).$type<MirrorOverrides>(),
status: text("status").notNull().default("imported"),
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
errorMessage: text("error_message"),
+14 -13
View File
@@ -16,6 +16,7 @@ import { httpPost, httpGet, httpPatch, HttpError } from "./http-client";
import { db, repositories } from "./db";
import { eq } from "drizzle-orm";
import { repoStatusEnum } from "@/types/Repository";
import { resolveMirrorOptionsForRepository } from "./utils/mirror-overrides";
import {
createPreSyncBundleBackup,
shouldCreatePreSyncBackup,
@@ -733,8 +734,14 @@ export async function syncGiteaRepoEnhanced({
// metadataState + metadataUpdated are hoisted above the backup
// strategy block so force-push detection can read/write the
// acknowledged-deletions list. Don't shadow them here.
const skipMetadataForStarred =
repository.isStarred && config.githubConfig?.starredCodeOnly;
// Same resolution as the initial mirror path in gitea.ts: global config,
// then organization override, then repository override, with the
// starredCodeOnly clamp folded in. Without this, a repository's overrides
// would be honored on first mirror and then ignored on every later sync.
const mirrorOptions = await resolveMirrorOptionsForRepository({
config,
repository,
});
let metadataOctokit: Octokit | null = null;
const ensureOctokit = (): Octokit | null => {
@@ -752,19 +759,13 @@ export async function syncGiteaRepoEnhanced({
// The underlying mirror* functions are idempotent: issues/PRs are
// matched via [GH-ISSUE #N] / [GH-PR #N] markers and PATCHed in
// place, labels are deduped by name, milestones by title.
const shouldMirrorReleases =
!!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred;
const shouldMirrorIssuesThisRun =
!!config.giteaConfig?.mirrorIssues && !skipMetadataForStarred;
const shouldMirrorPullRequests =
!!config.giteaConfig?.mirrorPullRequests && !skipMetadataForStarred;
const shouldMirrorReleases = mirrorOptions.mirrorReleases;
const shouldMirrorIssuesThisRun = mirrorOptions.mirrorIssues;
const shouldMirrorPullRequests = mirrorOptions.mirrorPullRequests;
// Labels-only path; issues run already creates/reconciles labels.
const shouldMirrorLabels =
!!config.giteaConfig?.mirrorLabels &&
!skipMetadataForStarred &&
!shouldMirrorIssuesThisRun;
const shouldMirrorMilestones =
!!config.giteaConfig?.mirrorMilestones && !skipMetadataForStarred;
mirrorOptions.mirrorLabels && !shouldMirrorIssuesThisRun;
const shouldMirrorMilestones = mirrorOptions.mirrorMilestones;
if (shouldMirrorReleases) {
const octokit = ensureOctokit();
+50 -49
View File
@@ -18,6 +18,7 @@ import {
parseRepositoryMetadataState,
serializeRepositoryMetadataState,
} from "./metadata-state";
import { resolveMirrorOptionsForRepository } from "./utils/mirror-overrides";
/**
* Helper function to get organization configuration including destination override
@@ -852,20 +853,27 @@ export const mirrorGithubRepoToGitea = async ({
console.log(`Proceeding with mirror creation for ${targetRepoName}`);
}
// Resolve mirror options once for this repository: global config, then any
// organization override, then any repository override. This also folds in
// the starredCodeOnly clamp that used to be applied ad hoc below.
const mirrorOptions = await resolveMirrorOptionsForRepository({
config,
repository,
});
// Prepare migration payload
// For private repos, use separate auth fields instead of embedding credentials in URL
// This is required for Forgejo 12+ which rejects URLs with embedded credentials
// Skip wiki for starred repos if starredCodeOnly is enabled
const shouldMirrorWiki = config.giteaConfig?.wiki &&
!(repository.isStarred && config.githubConfig?.starredCodeOnly);
const migratePayload: any = {
clone_addr: cloneAddress,
repo_name: targetRepoName,
mirror: true,
mirror_interval: config.giteaConfig?.mirrorInterval || "8h",
wiki: shouldMirrorWiki || false,
lfs: config.giteaConfig?.lfs || false,
wiki: mirrorOptions.wiki,
// Gitea performs the LFS fetch inside its own migration, so a failure
// here aborts the entire migration with nothing to salvage. That is why
// a per-repository opt-out exists (issue #361).
lfs: mirrorOptions.lfs,
private: repository.isPrivate,
repo_owner: repoOwner,
description: repository.description?.trim() || "",
@@ -915,15 +923,13 @@ export const mirrorGithubRepoToGitea = async ({
const metadataState = parseRepositoryMetadataState(repository.metadata);
let metadataUpdated = false;
const skipMetadataForStarred =
repository.isStarred && config.githubConfig?.starredCodeOnly;
// Mirror releases if enabled (always allowed to rerun for updates)
const shouldMirrorReleases =
!!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred;
// Mirror releases if enabled (always allowed to rerun for updates).
// mirrorOptions already accounts for org/repo overrides and starredCodeOnly.
const shouldMirrorReleases = mirrorOptions.mirrorReleases;
console.log(
`[Metadata] Release mirroring check: mirrorReleases=${config.giteaConfig?.mirrorReleases}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorReleases=${shouldMirrorReleases}`
`[Metadata] Release mirroring check: mirrorReleases=${mirrorOptions.mirrorReleases}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorReleases=${shouldMirrorReleases}`
);
if (shouldMirrorReleases) {
@@ -954,11 +960,10 @@ export const mirrorGithubRepoToGitea = async ({
// The underlying mirror* functions are idempotent: issues/PRs are
// matched via [GH-ISSUE #N] / [GH-PR #N] markers and PATCHed in place,
// labels are deduped by name, milestones by title.
const shouldMirrorIssuesThisRun =
!!config.giteaConfig?.mirrorIssues && !skipMetadataForStarred;
const shouldMirrorIssuesThisRun = mirrorOptions.mirrorIssues;
console.log(
`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssuesThisRun}`
`[Metadata] Issue mirroring check: mirrorIssues=${mirrorOptions.mirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssuesThisRun}`
);
if (shouldMirrorIssuesThisRun) {
@@ -986,11 +991,10 @@ export const mirrorGithubRepoToGitea = async ({
}
}
const shouldMirrorPullRequests =
!!config.giteaConfig?.mirrorPullRequests && !skipMetadataForStarred;
const shouldMirrorPullRequests = mirrorOptions.mirrorPullRequests;
console.log(
`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`
`[Metadata] Pull request mirroring check: mirrorPullRequests=${mirrorOptions.mirrorPullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`
);
if (shouldMirrorPullRequests) {
@@ -1019,12 +1023,10 @@ export const mirrorGithubRepoToGitea = async ({
// Labels-only path; issues run above already creates/reconciles labels.
const shouldMirrorLabels =
!!config.giteaConfig?.mirrorLabels &&
!skipMetadataForStarred &&
!shouldMirrorIssuesThisRun;
mirrorOptions.mirrorLabels && !shouldMirrorIssuesThisRun;
console.log(
`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, issuesRunning=${shouldMirrorIssuesThisRun}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`
`[Metadata] Label mirroring check: mirrorLabels=${mirrorOptions.mirrorLabels}, issuesRunning=${shouldMirrorIssuesThisRun}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`
);
if (shouldMirrorLabels) {
@@ -1051,11 +1053,10 @@ export const mirrorGithubRepoToGitea = async ({
}
}
const shouldMirrorMilestones =
!!config.giteaConfig?.mirrorMilestones && !skipMetadataForStarred;
const shouldMirrorMilestones = mirrorOptions.mirrorMilestones;
console.log(
`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
`[Metadata] Milestone mirroring check: mirrorMilestones=${mirrorOptions.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
);
if (shouldMirrorMilestones) {
@@ -1610,21 +1611,28 @@ export async function mirrorGitHubRepoToGiteaOrg({
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
// Resolve mirror options once for this repository: global config, then any
// organization override, then any repository override. This also folds in
// the starredCodeOnly clamp that used to be applied ad hoc below.
const mirrorOptions = await resolveMirrorOptionsForRepository({
config,
repository,
});
// Prepare migration payload
// For private repos, use separate auth fields instead of embedding credentials in URL
// This is required for Forgejo 12+ which rejects URLs with embedded credentials
// Skip wiki for starred repos if starredCodeOnly is enabled
const shouldMirrorWiki = config.giteaConfig?.wiki &&
!(repository.isStarred && config.githubConfig?.starredCodeOnly);
const migratePayload: any = {
clone_addr: cloneAddress,
uid: giteaOrgId,
repo_name: targetRepoName,
mirror: true,
mirror_interval: config.giteaConfig?.mirrorInterval || "8h",
wiki: shouldMirrorWiki || false,
lfs: config.giteaConfig?.lfs || false,
wiki: mirrorOptions.wiki,
// Gitea performs the LFS fetch inside its own migration, so a failure
// here aborts the entire migration with nothing to salvage. That is why
// a per-repository opt-out exists (issue #361).
lfs: mirrorOptions.lfs,
private: repository.isPrivate,
description: repository.description?.trim() || "",
service: "git",
@@ -1673,14 +1681,12 @@ export async function mirrorGitHubRepoToGiteaOrg({
const metadataState = parseRepositoryMetadataState(repository.metadata);
let metadataUpdated = false;
const skipMetadataForStarred =
repository.isStarred && config.githubConfig?.starredCodeOnly;
const shouldMirrorReleases =
!!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred;
// mirrorOptions already accounts for org/repo overrides and starredCodeOnly.
const shouldMirrorReleases = mirrorOptions.mirrorReleases;
console.log(
`[Metadata] Release mirroring check: mirrorReleases=${config.giteaConfig?.mirrorReleases}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorReleases=${shouldMirrorReleases}`
`[Metadata] Release mirroring check: mirrorReleases=${mirrorOptions.mirrorReleases}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorReleases=${shouldMirrorReleases}`
);
if (shouldMirrorReleases) {
@@ -1709,11 +1715,10 @@ export async function mirrorGitHubRepoToGiteaOrg({
// Reconcile metadata on every sync. See note in mirrorGithubRepoToGitea
// above. The underlying mirror* functions are idempotent.
const shouldMirrorIssuesThisRun =
!!config.giteaConfig?.mirrorIssues && !skipMetadataForStarred;
const shouldMirrorIssuesThisRun = mirrorOptions.mirrorIssues;
console.log(
`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssuesThisRun}`
`[Metadata] Issue mirroring check: mirrorIssues=${mirrorOptions.mirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssuesThisRun}`
);
if (shouldMirrorIssuesThisRun) {
@@ -1741,11 +1746,10 @@ export async function mirrorGitHubRepoToGiteaOrg({
}
}
const shouldMirrorPullRequests =
!!config.giteaConfig?.mirrorPullRequests && !skipMetadataForStarred;
const shouldMirrorPullRequests = mirrorOptions.mirrorPullRequests;
console.log(
`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`
`[Metadata] Pull request mirroring check: mirrorPullRequests=${mirrorOptions.mirrorPullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`
);
if (shouldMirrorPullRequests) {
@@ -1774,12 +1778,10 @@ export async function mirrorGitHubRepoToGiteaOrg({
// Labels-only path; issues run above already creates/reconciles labels.
const shouldMirrorLabels =
!!config.giteaConfig?.mirrorLabels &&
!skipMetadataForStarred &&
!shouldMirrorIssuesThisRun;
mirrorOptions.mirrorLabels && !shouldMirrorIssuesThisRun;
console.log(
`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, issuesRunning=${shouldMirrorIssuesThisRun}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`
`[Metadata] Label mirroring check: mirrorLabels=${mirrorOptions.mirrorLabels}, issuesRunning=${shouldMirrorIssuesThisRun}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`
);
if (shouldMirrorLabels) {
@@ -1806,11 +1808,10 @@ export async function mirrorGitHubRepoToGiteaOrg({
}
}
const shouldMirrorMilestones =
!!config.giteaConfig?.mirrorMilestones && !skipMetadataForStarred;
const shouldMirrorMilestones = mirrorOptions.mirrorMilestones;
console.log(
`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
`[Metadata] Milestone mirroring check: mirrorMilestones=${mirrorOptions.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
);
if (shouldMirrorMilestones) {
+33 -1
View File
@@ -17,7 +17,7 @@
*/
import { describe, test, expect } from "bun:test";
import { resolveOrphanVerdict } from "./repository-cleanup-service";
import { resolveOrphanVerdict, planOrphanedRepoAction } from "./repository-cleanup-service";
describe("resolveOrphanVerdict", () => {
test("repo present in the bulk fetch is never orphaned, regardless of the direct check", () => {
@@ -50,3 +50,35 @@ describe("resolveOrphanVerdict", () => {
).toBe(false);
});
});
/**
* Regression coverage for issue #366: `deleteFromGitea`
* (CLEANUP_DELETE_FROM_GITEA) was documented as "Delete repositories from
* Gitea" with a `false` default, but the cleanup service never read it —
* orphaned repos were archived/deleted on the Gitea side unconditionally.
* planOrphanedRepoAction is the pure decision function that now gates the
* Gitea-side operation on that flag while the local-database bookkeeping
* always happens.
*/
describe("planOrphanedRepoAction", () => {
test("skip does nothing anywhere, regardless of deleteFromGitea", () => {
expect(planOrphanedRepoAction("skip", false)).toEqual({ gitea: "none", db: "none" });
expect(planOrphanedRepoAction("skip", true)).toEqual({ gitea: "none", db: "none" });
});
test("archive with deleteFromGitea disabled only marks the local record archived", () => {
expect(planOrphanedRepoAction("archive", false)).toEqual({ gitea: "none", db: "archive" });
});
test("archive with deleteFromGitea enabled archives on Gitea too", () => {
expect(planOrphanedRepoAction("archive", true)).toEqual({ gitea: "archive", db: "archive" });
});
test("delete with deleteFromGitea disabled removes only the local record (the #366 ask)", () => {
expect(planOrphanedRepoAction("delete", false)).toEqual({ gitea: "none", db: "delete" });
});
test("delete with deleteFromGitea enabled deletes from Gitea and the local database", () => {
expect(planOrphanedRepoAction("delete", true)).toEqual({ gitea: "delete", db: "delete" });
});
});
+128 -66
View File
@@ -46,6 +46,34 @@ export function resolveOrphanVerdict({
return directCheckConfirmsGone;
}
/**
* Decide what an orphaned-repo cleanup action actually does, split into the
* Gitea-side operation and the local-database operation.
*
* `deleteFromGitea` (CLEANUP_DELETE_FROM_GITEA) gates the Gitea side: it has
* always been documented as "Delete repositories from Gitea" with a `false`
* default, but until #366 the code never read it and archived/deleted on
* Gitea unconditionally. With it disabled (the default), cleanup only
* updates gitea-mirror's own records — orphans are marked archived or
* removed from the local database, and the Gitea/Forgejo copy is left
* untouched.
*
* Pure/exported so the decision logic is unit-testable (see
* repository-cleanup-service.test.ts).
*/
export function planOrphanedRepoAction(
action: "skip" | "archive" | "delete",
deleteFromGitea: boolean
): { gitea: "archive" | "delete" | "none"; db: "archive" | "delete" | "none" } {
if (action === "skip") {
return { gitea: "none", db: "none" };
}
if (action === "archive") {
return { gitea: deleteFromGitea ? "archive" : "none", db: "archive" };
}
return { gitea: deleteFromGitea ? "delete" : "none", db: "delete" };
}
/**
* Identify orphaned repositories for a user
* These are repositories that exist in our database (and likely in Gitea)
@@ -239,6 +267,40 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
}
}
/**
* Resolve the Gitea client and the owner/name the repo lives under on the
* Gitea side. Only called when the cleanup plan actually touches Gitea, so
* a database-only cleanup never needs a decryptable Gitea token.
*/
async function resolveGiteaTarget(config: any, repo: any): Promise<{
giteaClient: ReturnType<typeof createGiteaClient>;
giteaOwner: string;
giteaRepoName: string;
}> {
const giteaToken = getDecryptedGiteaToken(config);
const giteaClient = createGiteaClient(config.giteaConfig.url, giteaToken);
// Determine the Gitea owner and repo name more robustly
const mirroredLocation = (repo.mirroredLocation || '').trim();
let giteaOwner: string;
let giteaRepoName: string;
if (mirroredLocation && mirroredLocation.includes('/')) {
const [ownerPart, namePart] = mirroredLocation.split('/');
giteaOwner = ownerPart;
giteaRepoName = namePart;
} else {
// Fall back to expected owner based on config and repo flags (starred/org overrides)
giteaOwner = await getGiteaRepoOwnerAsync({ config, repository: repo });
giteaRepoName = repo.name;
}
// Normalize owner casing to avoid GetUserByName issues on some Gitea setups
giteaOwner = giteaOwner.trim();
return { giteaClient, giteaOwner, giteaRepoName };
}
/**
* Handle an orphaned repository based on configuration
*/
@@ -260,82 +322,74 @@ async function handleOrphanedRepository(
return;
}
const plan = planOrphanedRepoAction(action, config.cleanupConfig?.deleteFromGitea === true);
if (dryRun) {
console.log(`[Repository Cleanup] DRY RUN: Would ${action} orphaned repository ${repoFullName}`);
console.log(`[Repository Cleanup] DRY RUN: Would ${action} orphaned repository ${repoFullName}${plan.gitea === 'none' ? ' (database only; deleteFromGitea is disabled)' : ' (including the Gitea copy)'}`);
return;
}
try {
// Get Gitea client
const giteaToken = getDecryptedGiteaToken(config);
const giteaClient = createGiteaClient(config.giteaConfig.url, giteaToken);
// Determine the Gitea owner and repo name more robustly
const mirroredLocation = (repo.mirroredLocation || '').trim();
let giteaOwner: string;
let giteaRepoName: string;
if (mirroredLocation && mirroredLocation.includes('/')) {
const [ownerPart, namePart] = mirroredLocation.split('/');
giteaOwner = ownerPart;
giteaRepoName = namePart;
} else {
// Fall back to expected owner based on config and repo flags (starred/org overrides)
giteaOwner = await getGiteaRepoOwnerAsync({ config, repository: repo });
giteaRepoName = repo.name;
}
// Normalize owner casing to avoid GetUserByName issues on some Gitea setups
giteaOwner = giteaOwner.trim();
if (action === 'archive') {
console.log(`[Repository Cleanup] Archiving orphaned repository ${repoFullName} in Gitea`);
// Best-effort check to validate actual location; falls back gracefully
try {
const { present, actualOwner } = await checkRepoLocation({
config,
repository: repo,
expectedOwner: giteaOwner,
});
if (present) {
giteaOwner = actualOwner;
}
} catch {
// Non-fatal; continue with best guess
}
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',
errorMessage: plan.gitea === 'archive'
? 'Repository archived - no longer in GitHub'
: 'Repository archived in gitea-mirror - no longer in GitHub (Gitea copy untouched)',
updatedAt: new Date(),
};
if (archivedName && archivedName !== giteaRepoName) {
dbUpdate.mirroredLocation = `${giteaOwner}/${archivedName}`;
if (plan.gitea === 'archive') {
console.log(`[Repository Cleanup] Archiving orphaned repository ${repoFullName} in Gitea`);
const { giteaClient, giteaRepoName, giteaOwner: expectedOwner } = await resolveGiteaTarget(config, repo);
let giteaOwner = expectedOwner;
// Best-effort check to validate actual location; falls back gracefully
try {
const { present, actualOwner } = await checkRepoLocation({
config,
repository: repo,
expectedOwner: giteaOwner,
});
if (present) {
giteaOwner = actualOwner;
}
} catch {
// Non-fatal; continue with best guess
}
const { archivedName } = await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
// 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.
if (archivedName && archivedName !== giteaRepoName) {
dbUpdate.mirroredLocation = `${giteaOwner}/${archivedName}`;
}
} else {
console.log(`[Repository Cleanup] Marking orphaned repository ${repoFullName} archived in gitea-mirror (deleteFromGitea disabled; Gitea copy untouched)`);
}
await db.update(repositories).set(dbUpdate).where(eq(repositories.id, repo.id));
// Create event
await publishEvent({
userId: config.userId,
channel: 'repository',
payload: {
type: 'repository.archived',
message: `Repository ${repoFullName} archived (no longer in GitHub)`,
message: plan.gitea === 'archive'
? `Repository ${repoFullName} archived (no longer in GitHub)`
: `Repository ${repoFullName} marked archived in gitea-mirror (no longer in GitHub; Gitea copy untouched)`,
metadata: {
repositoryId: repo.id,
repositoryName: repo.name,
@@ -345,19 +399,26 @@ async function handleOrphanedRepository(
},
});
} else if (action === 'delete') {
console.log(`[Repository Cleanup] Deleting orphaned repository ${repoFullName} from Gitea`);
await deleteGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
if (plan.gitea === 'delete') {
console.log(`[Repository Cleanup] Deleting orphaned repository ${repoFullName} from Gitea`);
const { giteaClient, giteaOwner, giteaRepoName } = await resolveGiteaTarget(config, repo);
await deleteGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
} else {
console.log(`[Repository Cleanup] Removing orphaned repository ${repoFullName} from gitea-mirror (deleteFromGitea disabled; Gitea copy untouched)`);
}
// Delete from database
await db.delete(repositories).where(eq(repositories.id, repo.id));
// Create event
await publishEvent({
userId: config.userId,
channel: 'repository',
payload: {
type: 'repository.deleted',
message: `Repository ${repoFullName} deleted (no longer in GitHub)`,
message: plan.gitea === 'delete'
? `Repository ${repoFullName} deleted (no longer in GitHub)`
: `Repository ${repoFullName} removed from gitea-mirror (no longer in GitHub; Gitea copy untouched)`,
metadata: {
repositoryId: repo.id,
repositoryName: repo.name,
@@ -415,9 +476,10 @@ async function runRepositoryCleanup(config: any): Promise<{
return results;
}
// Warn if deleteFromGitea is explicitly disabled but deleteIfNotInGitHub is enabled
if (cleanupConfig.deleteFromGitea === false && cleanupConfig.deleteIfNotInGitHub) {
console.warn(`[Repository Cleanup] Warning: CLEANUP_DELETE_FROM_GITEA is false but CLEANUP_DELETE_IF_NOT_IN_GITHUB is true. Proceeding with cleanup.`);
// deleteFromGitea gates whether the orphaned-repo action also touches the
// Gitea/Forgejo side (see planOrphanedRepoAction).
if (cleanupConfig.deleteFromGitea !== true) {
console.log(`[Repository Cleanup] deleteFromGitea is disabled: orphaned repositories will only be updated in gitea-mirror's database; the Gitea copies stay untouched. Set CLEANUP_DELETE_FROM_GITEA=true to also apply the '${cleanupConfig.orphanedRepoAction || 'archive'}' action on Gitea.`);
}
// Identify orphaned repositories
+610
View File
@@ -0,0 +1,610 @@
import { describe, expect, test } from "bun:test";
import {
getMirrorOverrideGating,
hasMirrorOverrides,
listOverriddenKeys,
MIRROR_GATING_REASONS,
mirrorOptionsToFlags,
normalizeMirrorOverrides,
parseMirrorOverrides,
resolveMirrorOptions,
STARRED_CLAMPED_KEYS,
UI_MIRROR_OVERRIDE_KEYS,
} from "./mirror-overrides";
import type { Config } from "@/types/config";
import type { Repository } from "@/lib/db/schema";
/** Minimal config carrying just the flags the resolver reads. */
function makeConfig(
gitea: Record<string, unknown> = {},
github: Record<string, unknown> = {}
): Partial<Config> {
return {
userId: "user-1",
giteaConfig: gitea as any,
githubConfig: github as any,
};
}
function makeRepo(overrides: Partial<Repository> = {}): any {
return {
isStarred: false,
mirrorOverrides: null,
organization: "acme",
...overrides,
};
}
describe("resolveMirrorOptions precedence", () => {
test("falls back to global config when no overrides exist", () => {
const resolved = resolveMirrorOptions({
config: makeConfig({ lfs: true, mirrorIssues: true, wiki: false }),
repository: makeRepo(),
});
expect(resolved.lfs).toBe(true);
expect(resolved.mirrorIssues).toBe(true);
expect(resolved.wiki).toBe(false);
});
test("treats missing global flags as false rather than undefined", () => {
const resolved = resolveMirrorOptions({
config: makeConfig({}),
repository: makeRepo(),
});
for (const value of Object.values(resolved)) {
expect(typeof value).toBe("boolean");
}
expect(resolved.lfs).toBe(false);
});
test("organization override beats global config", () => {
const resolved = resolveMirrorOptions({
config: makeConfig({ lfs: true, mirrorIssues: true }),
repository: makeRepo(),
orgOverrides: { lfs: false },
});
expect(resolved.lfs).toBe(false);
// Untouched flags still inherit from global.
expect(resolved.mirrorIssues).toBe(true);
});
test("repository override beats global config", () => {
const resolved = resolveMirrorOptions({
config: makeConfig({ lfs: true }),
repository: makeRepo({ mirrorOverrides: { lfs: false } as any }),
});
expect(resolved.lfs).toBe(false);
});
test("repository override beats organization override", () => {
const resolved = resolveMirrorOptions({
config: makeConfig({ lfs: false }),
repository: makeRepo({ mirrorOverrides: { lfs: true } as any }),
orgOverrides: { lfs: false },
});
expect(resolved.lfs).toBe(true);
});
test("resolution is per flag, not all-or-nothing", () => {
// global: everything on. org turns issues off. repo turns lfs off.
// Each flag should land on its own most-specific tier.
const resolved = resolveMirrorOptions({
config: makeConfig({
lfs: true,
wiki: true,
mirrorIssues: true,
mirrorReleases: true,
}),
repository: makeRepo({ mirrorOverrides: { lfs: false } as any }),
orgOverrides: { mirrorIssues: false },
});
expect(resolved.lfs).toBe(false); // repo tier
expect(resolved.mirrorIssues).toBe(false); // org tier
expect(resolved.wiki).toBe(true); // global tier
expect(resolved.mirrorReleases).toBe(true); // global tier
});
test("an explicit true override re-enables a globally disabled flag", () => {
const resolved = resolveMirrorOptions({
config: makeConfig({ lfs: false }),
repository: makeRepo({ mirrorOverrides: { lfs: true } as any }),
});
expect(resolved.lfs).toBe(true);
});
test("null in an override means inherit, not false", () => {
const resolved = resolveMirrorOptions({
config: makeConfig({ lfs: true }),
repository: makeRepo({ mirrorOverrides: { lfs: null } as any }),
orgOverrides: { lfs: null },
});
expect(resolved.lfs).toBe(true);
});
});
describe("resolveMirrorOptions and starredCodeOnly", () => {
test("starredCodeOnly forces metadata off for starred repos", () => {
const resolved = resolveMirrorOptions({
config: makeConfig(
{
lfs: true,
wiki: true,
mirrorIssues: true,
mirrorPullRequests: true,
mirrorReleases: true,
mirrorLabels: true,
mirrorMilestones: true,
mirrorMetadata: true,
},
{ starredCodeOnly: true }
),
repository: makeRepo({ isStarred: true }),
});
expect(resolved.wiki).toBe(false);
expect(resolved.mirrorIssues).toBe(false);
expect(resolved.mirrorPullRequests).toBe(false);
expect(resolved.mirrorReleases).toBe(false);
expect(resolved.mirrorLabels).toBe(false);
expect(resolved.mirrorMilestones).toBe(false);
expect(resolved.mirrorMetadata).toBe(false);
// LFS is code, not metadata, so it survives the clamp.
expect(resolved.lfs).toBe(true);
});
test("starredCodeOnly clamp outranks an explicit repo override", () => {
const resolved = resolveMirrorOptions({
config: makeConfig({}, { starredCodeOnly: true }),
repository: makeRepo({
isStarred: true,
mirrorOverrides: { mirrorIssues: true } as any,
}),
});
expect(resolved.mirrorIssues).toBe(false);
});
test("starredCodeOnly does not affect non-starred repos", () => {
const resolved = resolveMirrorOptions({
config: makeConfig({ mirrorIssues: true }, { starredCodeOnly: true }),
repository: makeRepo({ isStarred: false }),
});
expect(resolved.mirrorIssues).toBe(true);
});
test("starred repo without starredCodeOnly keeps its metadata flags", () => {
const resolved = resolveMirrorOptions({
config: makeConfig({ mirrorIssues: true }, { starredCodeOnly: false }),
repository: makeRepo({ isStarred: true }),
});
expect(resolved.mirrorIssues).toBe(true);
});
test("the #361 case: repo opts out of LFS, everything else unchanged", () => {
const resolved = resolveMirrorOptions({
config: makeConfig({ lfs: true, mirrorIssues: true, mirrorReleases: true }),
repository: makeRepo({
mirrorOverrides: { lfs: false } as any,
}),
});
expect(resolved.lfs).toBe(false);
expect(resolved.mirrorIssues).toBe(true);
expect(resolved.mirrorReleases).toBe(true);
});
});
describe("parseMirrorOverrides", () => {
test("returns null for null, undefined and empty string", () => {
expect(parseMirrorOverrides(null)).toBeNull();
expect(parseMirrorOverrides(undefined)).toBeNull();
expect(parseMirrorOverrides("")).toBeNull();
expect(parseMirrorOverrides(" ")).toBeNull();
});
test("parses a JSON string payload", () => {
expect(parseMirrorOverrides('{"lfs":false}')).toEqual({ lfs: false });
});
test("accepts an already-parsed object", () => {
expect(parseMirrorOverrides({ lfs: true })).toEqual({ lfs: true });
});
test("degrades malformed input to null instead of throwing", () => {
// A bad override must never be able to break a mirror run.
expect(parseMirrorOverrides("{not json")).toBeNull();
expect(parseMirrorOverrides([1, 2, 3])).toBeNull();
expect(parseMirrorOverrides(42)).toBeNull();
expect(parseMirrorOverrides({ lfs: "yes" })).toBeNull();
});
test("a malformed override falls back to global config in the resolver", () => {
const resolved = resolveMirrorOptions({
config: makeConfig({ lfs: true }),
repository: makeRepo({ mirrorOverrides: "{corrupt" as any }),
});
expect(resolved.lfs).toBe(true);
});
});
describe("override helpers", () => {
test("hasMirrorOverrides only counts pinned flags", () => {
expect(hasMirrorOverrides(null)).toBe(false);
expect(hasMirrorOverrides({})).toBe(false);
expect(hasMirrorOverrides({ lfs: null })).toBe(false);
expect(hasMirrorOverrides({ lfs: false })).toBe(true);
expect(hasMirrorOverrides({ lfs: true })).toBe(true);
});
test("listOverriddenKeys reports which flags deviate", () => {
expect(listOverriddenKeys({ lfs: false, mirrorIssues: true })).toEqual([
"lfs",
"mirrorIssues",
]);
expect(listOverriddenKeys({ lfs: null })).toEqual([]);
});
test("normalizeMirrorOverrides strips nulls and collapses empty to null", () => {
expect(normalizeMirrorOverrides({ lfs: null, wiki: undefined })).toBeNull();
expect(normalizeMirrorOverrides({})).toBeNull();
expect(normalizeMirrorOverrides({ lfs: false, wiki: null })).toEqual({
lfs: false,
});
});
});
describe("getMirrorOverrideGating - starred clamp", () => {
const starredRepo = {
targetKind: "repository" as const,
isStarred: true,
starredCodeOnly: true,
effective: {},
};
test("disables every metadata flag with the starred reason", () => {
const gating = getMirrorOverrideGating(starredRepo);
for (const key of STARRED_CLAMPED_KEYS) {
expect(gating[key]).toBe(MIRROR_GATING_REASONS.starredCodeOnly);
}
});
test("LFS stays editable for starred repos", () => {
// Regression guard for issue #361: LFS is repository content, not
// metadata. The clamp must never sweep it up, or the whole feature breaks
// for exactly the repos it was built for.
const gating = getMirrorOverrideGating(starredRepo);
expect(gating.lfs).toBeUndefined();
});
test("STARRED_CLAMPED_KEYS never contains lfs", () => {
expect(STARRED_CLAMPED_KEYS).not.toContain("lfs");
});
test("no clamp when the repo is starred but starredCodeOnly is off", () => {
const gating = getMirrorOverrideGating({
targetKind: "repository",
isStarred: true,
starredCodeOnly: false,
effective: {},
});
expect(Object.keys(gating)).toHaveLength(0);
});
test("no clamp when starredCodeOnly is on but the repo is not starred", () => {
const gating = getMirrorOverrideGating({
targetKind: "repository",
isStarred: false,
starredCodeOnly: true,
effective: {},
});
expect(Object.keys(gating)).toHaveLength(0);
});
test("organizations never get the starred clamp", () => {
// Orgs cannot be starred, so the org dialog must not show this at all.
const gating = getMirrorOverrideGating({
targetKind: "organization",
isStarred: true,
starredCodeOnly: true,
effective: {},
});
expect(gating.mirrorIssues).toBeUndefined();
expect(gating.wiki).toBeUndefined();
});
test("the clamp set matches what the resolver actually forces off", () => {
// Guards against the UI disabling a different set of flags than the
// runtime clamps, which would put the two out of sync silently.
const resolved = resolveMirrorOptions({
config: makeConfig(
{
lfs: true,
wiki: true,
mirrorReleases: true,
mirrorMetadata: true,
mirrorIssues: true,
mirrorPullRequests: true,
mirrorLabels: true,
mirrorMilestones: true,
},
{ starredCodeOnly: true }
),
repository: makeRepo({ isStarred: true }),
});
for (const key of STARRED_CLAMPED_KEYS) {
expect(resolved[key]).toBe(false);
}
expect(resolved.lfs).toBe(true);
});
});
describe("getMirrorOverrideGating - labels follow issues", () => {
test("labels are disabled while issues resolve to on", () => {
const gating = getMirrorOverrideGating({
targetKind: "repository",
effective: { mirrorIssues: true },
});
expect(gating.mirrorLabels).toBe(MIRROR_GATING_REASONS.labelsFollowIssues);
});
test("labels are editable when issues resolve to off", () => {
const gating = getMirrorOverrideGating({
targetKind: "repository",
effective: { mirrorIssues: false },
});
expect(gating.mirrorLabels).toBeUndefined();
});
test("applies to organizations too", () => {
const gating = getMirrorOverrideGating({
targetKind: "organization",
effective: { mirrorIssues: true },
});
expect(gating.mirrorLabels).toBe(MIRROR_GATING_REASONS.labelsFollowIssues);
});
test("the starred reason wins over the labels reason", () => {
// Both rules apply; the more fundamental one should be the one explained.
const gating = getMirrorOverrideGating({
targetKind: "repository",
isStarred: true,
starredCodeOnly: true,
effective: { mirrorIssues: true },
});
expect(gating.mirrorLabels).toBe(MIRROR_GATING_REASONS.starredCodeOnly);
});
test("gating matches the runtime rule for labels", () => {
// Runtime is `mirrorLabels && !mirrorIssues` in gitea.ts and
// gitea-enhanced.ts, so labels genuinely cannot take effect while issues
// are on. Confirm the resolver agrees with what the UI claims.
const resolved = resolveMirrorOptions({
config: makeConfig({ mirrorIssues: true, mirrorLabels: true }),
repository: makeRepo(),
});
expect(resolved.mirrorIssues).toBe(true);
expect(resolved.mirrorLabels).toBe(true);
// The runtime then suppresses the labels-only pass; the UI says so up front.
const gating = getMirrorOverrideGating({
targetKind: "repository",
effective: resolved,
});
expect(gating.mirrorLabels).toBe(MIRROR_GATING_REASONS.labelsFollowIssues);
});
});
describe("UI_MIRROR_OVERRIDE_KEYS", () => {
test("exposes lfs plus the flags the runtime actually reads", () => {
expect(UI_MIRROR_OVERRIDE_KEYS).toEqual([
"lfs",
"wiki",
"mirrorIssues",
"mirrorPullRequests",
"mirrorReleases",
"mirrorLabels",
"mirrorMilestones",
]);
});
test("excludes mirrorMetadata, which no mirror path reads", () => {
// mirrorMetadata is a write-time master switch in the global settings UI
// (config-mapper ANDs it into the individual flags on save). Neither
// gitea.ts nor gitea-enhanced.ts reads it, so a per-object override would
// resolve fine and then do nothing.
expect(UI_MIRROR_OVERRIDE_KEYS).not.toContain("mirrorMetadata");
});
});
describe("mirrorOptionsToFlags - client/server shape contract", () => {
// These tests deliberately go through config-mapper rather than hand-writing
// an API payload. The original bug was that the dialog read
// `giteaConfig.lfs`, which /api/config never returns: mapDbToUiConfig
// reshapes the flags into `mirrorOptions` with different names and nesting.
// A synthetic DB-shaped config cannot expose that mismatch, so the flags are
// pushed through the real mapping in both directions here. If config-mapper
// changes shape again, these fail.
/** A DB-shaped config row, as the mirror runtime actually sees it. */
function makeDbConfig(giteaOverrides: Record<string, unknown> = {}) {
return {
githubConfig: {
owner: "acme",
includeForks: true,
starredCodeOnly: false,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "t",
defaultOwner: "acme",
lfs: true,
wiki: false,
mirrorMetadata: true,
mirrorIssues: true,
mirrorPullRequests: false,
mirrorLabels: true,
mirrorMilestones: false,
mirrorReleases: true,
releaseLimit: 10,
...giteaOverrides,
},
};
}
test("flags survive the DB -> API -> flags round trip", async () => {
const { mapDbToUiConfig } = await import("./config-mapper");
const dbConfig = makeDbConfig();
// This is exactly what /api/config sends to the browser.
const apiShape = mapDbToUiConfig(dbConfig);
const flags = mirrorOptionsToFlags(apiShape.mirrorOptions);
expect(flags.lfs).toBe(true);
expect(flags.mirrorIssues).toBe(true);
expect(flags.mirrorReleases).toBe(true);
expect(flags.mirrorLabels).toBe(true);
expect(flags.mirrorPullRequests).toBe(false);
expect(flags.mirrorMilestones).toBe(false);
expect(flags.wiki).toBe(false);
});
test("the API payload really does not carry flags on giteaConfig", async () => {
// Pins the root cause. If someone later makes /api/config also return the
// flags on giteaConfig, this fails and the client mapping can be revisited.
const { mapDbToUiConfig } = await import("./config-mapper");
const apiShape = mapDbToUiConfig(makeDbConfig());
expect((apiShape.giteaConfig as any).lfs).toBeUndefined();
expect((apiShape.giteaConfig as any).mirrorIssues).toBeUndefined();
// ...while mirrorOptions does, under different names.
expect(apiShape.mirrorOptions.mirrorLFS).toBe(true);
expect(apiShape.mirrorOptions.metadataComponents.issues).toBe(true);
});
test("reading giteaConfig directly yields the all-off bug", async () => {
// Reproduces the reported symptom: every hint read "currently off" because
// undefined coerces to false and is indistinguishable from a real off.
const { mapDbToUiConfig } = await import("./config-mapper");
const apiShape = mapDbToUiConfig(makeDbConfig());
const buggy = {
lfs: !!(apiShape.giteaConfig as any).lfs,
mirrorIssues: !!(apiShape.giteaConfig as any).mirrorIssues,
};
expect(buggy.lfs).toBe(false); // DB says true
expect(buggy.mirrorIssues).toBe(false); // DB says true
const fixed = mirrorOptionsToFlags(apiShape.mirrorOptions);
expect(fixed.lfs).toBe(true);
expect(fixed.mirrorIssues).toBe(true);
});
test("the labels gate fires on a real API payload", async () => {
// The gate itself was correct but was being fed undefined, so it never
// fired. Drive it from a genuine payload instead.
const { mapDbToUiConfig } = await import("./config-mapper");
const apiShape = mapDbToUiConfig(makeDbConfig());
const effective = mirrorOptionsToFlags(apiShape.mirrorOptions);
expect(effective.mirrorIssues).toBe(true);
const gating = getMirrorOverrideGating({
targetKind: "repository",
effective,
});
expect(gating.mirrorLabels).toBe(MIRROR_GATING_REASONS.labelsFollowIssues);
});
test("derived flags equal the stored DB flags the runtime reads", async () => {
// The property that actually matters: what the dialog shows must equal
// what the mirror paths will do. Both read the same stored fields, so
// pushing DB state out through the API mapping and back must be lossless.
//
// This replaces an earlier assertion that compared against
// mapUiToDbConfig's *write* derivation. That was the wrong reference: it
// pinned the write-path mirrorMetadata AND into the read path and so
// enshrined the bug it was meant to guard.
const { mapDbToUiConfig } = await import("./config-mapper");
const dbConfig = makeDbConfig();
const flags = mirrorOptionsToFlags(
mapDbToUiConfig(dbConfig).mirrorOptions
);
const stored = dbConfig.giteaConfig as Record<string, boolean>;
expect(flags.lfs).toBe(stored.lfs);
expect(flags.wiki).toBe(stored.wiki);
expect(flags.mirrorIssues).toBe(stored.mirrorIssues);
expect(flags.mirrorPullRequests).toBe(stored.mirrorPullRequests);
expect(flags.mirrorReleases).toBe(stored.mirrorReleases);
expect(flags.mirrorLabels).toBe(stored.mirrorLabels);
expect(flags.mirrorMilestones).toBe(stored.mirrorMilestones);
});
test("mirrorMetadata does not gate the components on read", async () => {
// Reachable via env vars: MIRROR_METADATA=false with MIRROR_ISSUES=true
// stores an inconsistent pair. The runtime reads mirrorIssues directly and
// mirrors issues, so the dialog must say so too. ANDing with
// mirrorMetadata here would double-apply a write-path rule and report off.
const { mapDbToUiConfig } = await import("./config-mapper");
const dbConfig = makeDbConfig({
mirrorMetadata: false,
mirrorIssues: true,
mirrorLabels: true,
wiki: true,
});
const flags = mirrorOptionsToFlags(
mapDbToUiConfig(dbConfig).mirrorOptions
);
expect(flags.mirrorIssues).toBe(true);
expect(flags.mirrorLabels).toBe(true);
expect(flags.wiki).toBe(true);
});
test("the dialog agrees with the resolver on an inconsistent config", async () => {
// Same state, checked against the resolver the mirror paths actually use,
// rather than against the raw DB object.
const { mapDbToUiConfig } = await import("./config-mapper");
const dbConfig = makeDbConfig({ mirrorMetadata: false, mirrorIssues: true });
const runtime = resolveMirrorOptions({
config: makeConfig(dbConfig.giteaConfig as any, {}),
repository: makeRepo(),
});
const client = mirrorOptionsToFlags(
mapDbToUiConfig(dbConfig).mirrorOptions
);
for (const key of UI_MIRROR_OVERRIDE_KEYS) {
expect(client[key]).toBe(runtime[key]);
}
});
test("handles a missing or empty payload without throwing", () => {
for (const value of [null, undefined, {}]) {
const flags = mirrorOptionsToFlags(value as any);
expect(flags.lfs).toBe(false);
expect(flags.mirrorIssues).toBe(false);
}
});
});
+375
View File
@@ -0,0 +1,375 @@
import type { Config } from "@/types/config";
import type { MirrorOverrides, Repository } from "@/lib/db/schema";
import { mirrorOverridesSchema } from "@/lib/db/schema";
/**
* The mirror option flags that can be overridden per organization and per
* repository. These mirror the flag names on `giteaConfigSchema` so the
* resolver can read both tiers with the same key.
*/
export const MIRROR_OVERRIDE_KEYS = [
"lfs",
"wiki",
"mirrorReleases",
"mirrorMetadata",
"mirrorIssues",
"mirrorPullRequests",
"mirrorLabels",
"mirrorMilestones",
] as const;
export type MirrorOverrideKey = (typeof MIRROR_OVERRIDE_KEYS)[number];
/** Fully resolved mirror options: every flag has a definite boolean value. */
export type ResolvedMirrorOptions = Record<MirrorOverrideKey, boolean>;
/**
* Flags the starred-code-only clamp forces off.
*
* Shared by the resolver and the UI gating helper so the two cannot drift.
* `lfs` is deliberately absent: LFS is repository content, not metadata, and
* turning it off per repository is the entire point of issue #361. It must
* stay editable for starred repos.
*/
export const STARRED_CLAMPED_KEYS = [
"wiki",
"mirrorReleases",
"mirrorMetadata",
"mirrorIssues",
"mirrorPullRequests",
"mirrorLabels",
"mirrorMilestones",
] as const satisfies readonly MirrorOverrideKey[];
/**
* Flags surfaced in the override UI.
*
* `mirrorMetadata` is intentionally excluded. It is a write-time master switch
* in the global settings UI (config-mapper ANDs it into the individual flags
* when the config is saved) and is never read by the mirror paths in gitea.ts
* or gitea-enhanced.ts. A per-object override for it would resolve correctly
* and then change nothing, which is exactly the silent no-op this UI is meant
* to avoid.
*/
export const UI_MIRROR_OVERRIDE_KEYS: MirrorOverrideKey[] = [
"lfs",
"wiki",
"mirrorIssues",
"mirrorPullRequests",
"mirrorReleases",
"mirrorLabels",
"mirrorMilestones",
];
/**
* Convert the API's `mirrorOptions` shape into the flag keys used everywhere
* else.
*
* `/api/config` does not return the mirror flags on `giteaConfig`. On the way
* out, `mapDbToUiConfig` reshapes them into `mirrorOptions`, which uses
* different names (`mirrorLFS`, not `lfs`) and nests the metadata flags under
* `metadataComponents` with short names (`issues`, not `mirrorIssues`). Client
* code reading `giteaConfig.lfs` therefore gets `undefined`, not `false`, which
* is indistinguishable from "off" once coerced.
*
* The mapping is 1:1 with the fields the runtime reads. `metadataComponents.*`
* carries the raw stored values (`issues` is `giteaConfig.mirrorIssues`
* untouched), so passing them straight through reproduces exactly what
* gitea.ts and gitea-enhanced.ts will do.
*
* Deliberately does NOT AND the components with `mirrorMetadata`. That switch
* is a write-path concern: `mapUiToDbConfig` already applies it when
* persisting, so a config saved through the settings UI has it baked into the
* stored flag. Applying it again on read double-applies it. That is invisible
* while the stored state is self-consistent, and wrong when it is not, which
* is reachable via env vars: MIRROR_METADATA=false with MIRROR_ISSUES=true
* stores `mirrorMetadata:false, mirrorIssues:true`, and the runtime mirrors
* issues. A second AND here would report "off" for something that is on.
*/
export function mirrorOptionsToFlags(
mirrorOptions:
| {
mirrorLFS?: boolean;
mirrorReleases?: boolean;
mirrorMetadata?: boolean;
metadataComponents?: {
issues?: boolean;
pullRequests?: boolean;
labels?: boolean;
milestones?: boolean;
wiki?: boolean;
};
}
| null
| undefined
): Partial<Record<MirrorOverrideKey, boolean>> {
const components = mirrorOptions?.metadataComponents;
// Straight through: each field is the raw stored value the runtime reads.
return {
lfs: !!mirrorOptions?.mirrorLFS,
mirrorReleases: !!mirrorOptions?.mirrorReleases,
wiki: !!components?.wiki,
mirrorIssues: !!components?.issues,
mirrorPullRequests: !!components?.pullRequests,
mirrorLabels: !!components?.labels,
mirrorMilestones: !!components?.milestones,
};
}
/** Reasons a toggle is disabled. Shown verbatim in the dialog. */
export const MIRROR_GATING_REASONS = {
starredCodeOnly:
"Starred repos mirror code only (Advanced Options > starred code only)",
labelsFollowIssues: "Issues mirroring already syncs labels",
} as const;
/** key -> reason it cannot take effect. Absent key means editable. */
export type MirrorOverrideGating = Partial<Record<MirrorOverrideKey, string>>;
/**
* Decide which toggles cannot take effect, and why.
*
* This is the single source of truth behind the rule that a toggle which
* cannot take effect is disabled with a reason rather than silently ignored.
* It mirrors the real runtime behavior:
*
* - the starred clamp in `resolveMirrorOptions` forces STARRED_CLAMPED_KEYS
* off for starred repos when starredCodeOnly is set, outranking any override
* - `shouldMirrorLabels` in gitea.ts / gitea-enhanced.ts is
* `mirrorLabels && !mirrorIssues`, so labels cannot take effect while issues
* are being mirrored (the issue path already reconciles labels)
*
* `effective` is the value each flag currently resolves to including the
* in-progress edit, so the labels gate reacts live as issues is toggled.
*/
export function getMirrorOverrideGating({
targetKind,
isStarred,
starredCodeOnly,
effective,
}: {
targetKind: "repository" | "organization";
isStarred?: boolean;
starredCodeOnly?: boolean;
effective: Partial<Record<MirrorOverrideKey, boolean>>;
}): MirrorOverrideGating {
const gating: MirrorOverrideGating = {};
// Organizations are never starred, so the clamp cannot apply to them.
const starredClampApplies =
targetKind === "repository" && !!isStarred && !!starredCodeOnly;
if (starredClampApplies) {
for (const key of STARRED_CLAMPED_KEYS) {
gating[key] = MIRROR_GATING_REASONS.starredCodeOnly;
}
}
// Labels ride along with issues. Only report this when the more fundamental
// starred clamp has not already disabled the toggle.
if (!gating.mirrorLabels && effective.mirrorIssues) {
gating.mirrorLabels = MIRROR_GATING_REASONS.labelsFollowIssues;
}
return gating;
}
export const MIRROR_OVERRIDE_LABELS: Record<MirrorOverrideKey, string> = {
lfs: "Git LFS files",
wiki: "Wiki",
mirrorReleases: "Releases",
mirrorMetadata: "Metadata",
mirrorIssues: "Issues",
mirrorPullRequests: "Pull requests",
mirrorLabels: "Labels",
mirrorMilestones: "Milestones",
};
/**
* Normalize a persisted overrides value into a plain object.
*
* The column is JSON-mode, so Drizzle hands back an object, but rows written
* before this feature (or by a raw SQL path) may hold a string or NULL. Invalid
* shapes degrade to "no overrides" rather than throwing, because a malformed
* override must never be able to break a mirror run.
*/
export function parseMirrorOverrides(
value: unknown
): MirrorOverrides | null {
if (value == null) return null;
let candidate = value;
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return null;
try {
candidate = JSON.parse(trimmed);
} catch {
return null;
}
}
if (typeof candidate !== "object" || Array.isArray(candidate)) return null;
const parsed = mirrorOverridesSchema.safeParse(candidate);
if (!parsed.success) return null;
return parsed.data;
}
/** True when the overrides object actually pins at least one flag. */
export function hasMirrorOverrides(value: unknown): boolean {
const overrides = parseMirrorOverrides(value);
if (!overrides) return false;
return MIRROR_OVERRIDE_KEYS.some((key) => overrides[key] != null);
}
/** The keys an overrides object pins, for badges and summaries. */
export function listOverriddenKeys(value: unknown): MirrorOverrideKey[] {
const overrides = parseMirrorOverrides(value);
if (!overrides) return [];
return MIRROR_OVERRIDE_KEYS.filter((key) => overrides[key] != null);
}
/**
* Strip flags that are null/undefined so only genuine pins are stored, and
* collapse an empty result to null. Keeps "no overrides" as a single canonical
* representation instead of `{}` vs `{lfs: null}` vs NULL.
*/
export function normalizeMirrorOverrides(
value: unknown
): MirrorOverrides | null {
const overrides = parseMirrorOverrides(value);
if (!overrides) return null;
const cleaned: MirrorOverrides = {};
for (const key of MIRROR_OVERRIDE_KEYS) {
const flag = overrides[key];
if (typeof flag === "boolean") cleaned[key] = flag;
}
return Object.keys(cleaned).length > 0 ? cleaned : null;
}
/**
* Resolve the effective mirror options for one repository.
*
* Precedence is per flag, most specific tier wins:
* repository override -> organization override -> global config -> false
*
* `starredCodeOnly` is applied last as a hard clamp. It is a "code only, no
* metadata" switch for starred repos, so it forces every metadata flag off
* regardless of what any tier asked for. That preserves the pre-existing
* behavior of `skipMetadataForStarred`, which this function subsumes.
*
* Pure and synchronous by design: the organization overrides are fetched by
* the caller (see `loadOrganizationMirrorOverrides`) so this stays trivially
* testable.
*/
export function resolveMirrorOptions({
config,
repository,
orgOverrides,
}: {
config: Partial<Config>;
repository: Pick<Repository, "isStarred" | "mirrorOverrides">;
orgOverrides?: unknown;
}): ResolvedMirrorOptions {
const globalConfig = config.giteaConfig;
const org = parseMirrorOverrides(orgOverrides);
const repo = parseMirrorOverrides(repository.mirrorOverrides);
const resolved = {} as ResolvedMirrorOptions;
for (const key of MIRROR_OVERRIDE_KEYS) {
const repoValue = repo?.[key];
const orgValue = org?.[key];
if (typeof repoValue === "boolean") {
resolved[key] = repoValue;
} else if (typeof orgValue === "boolean") {
resolved[key] = orgValue;
} else {
resolved[key] = !!globalConfig?.[key];
}
}
// Starred repos with starredCodeOnly mirror code and nothing else. This
// clamp intentionally outranks explicit per-repo overrides: the setting
// exists to stop starred repos from dragging in metadata wholesale.
const skipMetadataForStarred =
!!repository.isStarred && !!config.githubConfig?.starredCodeOnly;
if (skipMetadataForStarred) {
// STARRED_CLAMPED_KEYS is shared with getMirrorOverrideGating so the set of
// flags the UI disables always matches the set the runtime forces off.
// Note it excludes `lfs` by design.
for (const key of STARRED_CLAMPED_KEYS) {
resolved[key] = false;
}
}
return resolved;
}
/**
* Fetch the organization-tier overrides for a repository, if it belongs to one.
*
* Returns null for personal repos, unknown orgs, or when the org has no
* overrides set. Failures are swallowed to null: a DB hiccup reading an
* optional override must not fail the mirror.
*/
export async function loadOrganizationMirrorOverrides({
organizationName,
userId,
}: {
organizationName?: string | null;
userId?: string;
}): Promise<MirrorOverrides | null> {
if (!organizationName || !userId) return null;
try {
const { db, organizations } = await import("@/lib/db");
const { and, eq } = await import("drizzle-orm");
const [org] = await db
.select({ mirrorOverrides: organizations.mirrorOverrides })
.from(organizations)
.where(
and(
eq(organizations.userId, userId),
eq(organizations.name, organizationName)
)
)
.limit(1);
return parseMirrorOverrides(org?.mirrorOverrides);
} catch (error) {
console.error(
`[MirrorOverrides] Failed to load organization overrides for ${organizationName}: ${
error instanceof Error ? error.message : String(error)
}`
);
return null;
}
}
/**
* Convenience wrapper: load the org tier then resolve. This is what the mirror
* paths in gitea.ts call.
*/
export async function resolveMirrorOptionsForRepository({
config,
repository,
}: {
config: Partial<Config>;
repository: Repository;
}): Promise<ResolvedMirrorOptions> {
const orgOverrides = await loadOrganizationMirrorOverrides({
organizationName: repository.organization,
userId: config.userId,
});
return resolveMirrorOptions({ config, repository, orgOverrides });
}
+51 -10
View File
@@ -1,8 +1,20 @@
import type { APIRoute } from "astro";
import { db, organizations, repositories } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { z } from "zod";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
import { mirrorOverridesSchema } from "@/lib/db/schema";
import { normalizeMirrorOverrides } from "@/lib/utils/mirror-overrides";
/**
* Partial update. Each field is optional and only written when present in the
* body, so updating mirror overrides does not clobber destinationOrg.
*/
const patchBodySchema = z.object({
destinationOrg: z.string().nullable().optional(),
mirrorOverrides: mirrorOverridesSchema.nullable().optional(),
});
export const PATCH: APIRoute = async (context) => {
try {
@@ -20,8 +32,21 @@ export const PATCH: APIRoute = async (context) => {
});
}
const body = await context.request.json();
const { destinationOrg } = body;
const parsed = patchBodySchema.safeParse(await context.request.json());
if (!parsed.success) {
return new Response(
JSON.stringify({
error: "Invalid request body",
details: parsed.error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
})),
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
const body = parsed.data;
// Validate that the organization belongs to the user
const [existingOrg] = await db
@@ -37,20 +62,36 @@ export const PATCH: APIRoute = async (context) => {
});
}
// Update the organization's destination override
const updates: Record<string, unknown> = { updatedAt: new Date() };
if ("destinationOrg" in body) {
updates.destinationOrg = body.destinationOrg || null;
}
if ("mirrorOverrides" in body) {
updates.mirrorOverrides = normalizeMirrorOverrides(body.mirrorOverrides);
}
await db
.update(organizations)
.set({
destinationOrg: destinationOrg || null,
updatedAt: new Date(),
})
.set(updates)
.where(eq(organizations.id, orgId));
const [updated] = await db
.select({
destinationOrg: organizations.destinationOrg,
mirrorOverrides: organizations.mirrorOverrides,
})
.from(organizations)
.where(eq(organizations.id, orgId))
.limit(1);
return new Response(
JSON.stringify({
success: true,
message: "Organization destination updated successfully",
destinationOrg: destinationOrg || null,
message: "Organization updated successfully",
destinationOrg: updated?.destinationOrg ?? null,
mirrorOverrides: updated?.mirrorOverrides ?? null,
}),
{
status: 200,
@@ -58,7 +99,7 @@ export const PATCH: APIRoute = async (context) => {
}
);
} catch (error) {
return createSecureErrorResponse(error, "Update organization destination", 500);
return createSecureErrorResponse(error, "Update organization", 500);
}
};
@@ -0,0 +1,47 @@
import type { APIRoute } from "astro";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
import { loadOrganizationMirrorOverrides } from "@/lib/utils/mirror-overrides";
/**
* Look up one organization's mirror overrides by name.
*
* The repositories payload carries an organization *name*, not an id, so the
* repository overrides dialog needs a name-keyed lookup to show what "Inherit"
* actually resolves to (global -> org). Reuses the same loader the mirror paths
* use, which scopes the query to the calling user.
*
* GET /api/organizations/mirror-overrides?name=<orgName>
*/
export const GET: APIRoute = async (context) => {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const name = context.url.searchParams.get("name")?.trim();
if (!name) {
return new Response(
JSON.stringify({ error: "Organization name is required" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
// Returns null for unknown orgs, which the caller treats the same as
// "no overrides" — the hint then falls back to the global values.
const mirrorOverrides = await loadOrganizationMirrorOverrides({
organizationName: name,
userId: user!.id,
});
return new Response(
JSON.stringify({ success: true, name, mirrorOverrides }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
} catch (error) {
return createSecureErrorResponse(
error,
"Load organization mirror overrides",
500
);
}
};
+54 -11
View File
@@ -1,8 +1,21 @@
import type { APIRoute } from "astro";
import { db, repositories } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { z } from "zod";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
import { mirrorOverridesSchema } from "@/lib/db/schema";
import { normalizeMirrorOverrides } from "@/lib/utils/mirror-overrides";
/**
* Partial update. Each field is optional and only written when present in the
* body, so updating mirror overrides does not clobber destinationOrg (and vice
* versa).
*/
const patchBodySchema = z.object({
destinationOrg: z.string().nullable().optional(),
mirrorOverrides: mirrorOverridesSchema.nullable().optional(),
});
export const PATCH: APIRoute = async (context) => {
try {
@@ -20,8 +33,21 @@ export const PATCH: APIRoute = async (context) => {
});
}
const body = await context.request.json();
const { destinationOrg } = body;
const parsed = patchBodySchema.safeParse(await context.request.json());
if (!parsed.success) {
return new Response(
JSON.stringify({
error: "Invalid request body",
details: parsed.error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
})),
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
const body = parsed.data;
// Validate that the repository belongs to the user
const [existingRepo] = await db
@@ -37,20 +63,38 @@ export const PATCH: APIRoute = async (context) => {
});
}
// Update the repository's destination override
const updates: Record<string, unknown> = { updatedAt: new Date() };
if ("destinationOrg" in body) {
updates.destinationOrg = body.destinationOrg || null;
}
if ("mirrorOverrides" in body) {
// normalize drops null-valued flags so "no overrides" is stored as NULL
// rather than an empty object.
updates.mirrorOverrides = normalizeMirrorOverrides(body.mirrorOverrides);
}
await db
.update(repositories)
.set({
destinationOrg: destinationOrg || null,
updatedAt: new Date(),
})
.set(updates)
.where(eq(repositories.id, repoId));
const [updated] = await db
.select({
destinationOrg: repositories.destinationOrg,
mirrorOverrides: repositories.mirrorOverrides,
})
.from(repositories)
.where(eq(repositories.id, repoId))
.limit(1);
return new Response(
JSON.stringify({
success: true,
message: "Repository destination updated successfully",
destinationOrg: destinationOrg || null,
message: "Repository updated successfully",
destinationOrg: updated?.destinationOrg ?? null,
mirrorOverrides: updated?.mirrorOverrides ?? null,
}),
{
status: 200,
@@ -58,7 +102,6 @@ export const PATCH: APIRoute = async (context) => {
}
);
} catch (error) {
return createSecureErrorResponse(error, "Update repository destination", 500);
return createSecureErrorResponse(error, "Update repository", 500);
}
};
+1
View File
@@ -8,6 +8,7 @@ export interface FilterParams {
owner?: string; // owner of the repos
organization?: string; // organization of the repos
sort?: string; // repository sort order
hasOverrides?: "overridden" | "default" | ""; // repos with custom mirror options
type?: string; //types in activity log
name?: string; // name in activity log
}
+4 -1
View File
@@ -3,12 +3,15 @@ import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import react from '@astrojs/react';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
// https://astro.build/config
export default defineConfig({
site: 'https://gitea-mirror.raylabs.io',
vite: {
plugins: [tailwindcss()]
},
integrations: [react(), mdx()]
integrations: [react(), mdx(), sitemap()]
});
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@astrojs/mdx": "^7.0.3",
"@astrojs/react": "^6.0.1",
"@astrojs/sitemap": "^3.7.3",
"@radix-ui/react-slot": "^1.3.0",
"@splinetool/react-spline": "^4.1.0",
"@splinetool/runtime": "^1.12.98",
+84 -45
View File
@@ -13,10 +13,13 @@ importers:
dependencies:
'@astrojs/mdx':
specifier: ^7.0.3
version: 7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.1.0(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@24.7.1)(jiti@2.7.0)(rollup@4.62.0))
version: 7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.1.0(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(jiti@2.7.0)(rollup@4.62.0))
'@astrojs/react':
specifier: ^6.0.1
version: 6.0.1(@types/node@24.7.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(jiti@2.7.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 6.0.1(@types/node@24.13.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(jiti@2.7.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@astrojs/sitemap':
specifier: ^3.7.3
version: 3.7.3
'@radix-ui/react-slot':
specifier: ^1.3.0
version: 1.3.0(@types/react@19.2.17)(react@19.2.7)
@@ -28,7 +31,7 @@ importers:
version: 1.12.98
'@tailwindcss/vite':
specifier: ^4.3.3
version: 4.3.3(vite@8.1.5(@types/node@24.7.1)(esbuild@0.28.1)(jiti@2.7.0))
version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
@@ -40,7 +43,7 @@ importers:
version: 19.2.3(@types/react@19.2.17)
astro:
specifier: ^7.1.0
version: 7.1.0(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@24.7.1)(jiti@2.7.0)(rollup@4.62.0)
version: 7.1.0(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(jiti@2.7.0)(rollup@4.62.0)
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
@@ -169,6 +172,9 @@ packages:
react: ^17.0.2 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0
'@astrojs/sitemap@3.7.3':
resolution: {integrity: sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==}
'@astrojs/telemetry@3.3.3':
resolution: {integrity: sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==}
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
@@ -1125,8 +1131,8 @@ packages:
'@types/nlcst@2.0.3':
resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
'@types/node@24.7.1':
resolution: {integrity: sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==}
'@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
@@ -1136,6 +1142,9 @@ packages:
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -1169,6 +1178,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -1551,8 +1563,8 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
jsesc@3.1.0:
@@ -1842,8 +1854,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -1929,8 +1941,8 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
postcss@8.5.19:
resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==}
postcss@8.5.26:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
prismjs@1.30.0:
@@ -2081,6 +2093,11 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
sitemap@9.0.1:
resolution: {integrity: sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==}
engines: {node: '>=20.19.5', npm: '>=10.8.2'}
hasBin: true
smol-toml@1.7.0:
resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
engines: {node: '>= 18'}
@@ -2096,6 +2113,9 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
stream-replace-string@2.0.0:
resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -2159,8 +2179,8 @@ packages:
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
undici-types@7.14.0:
resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==}
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -2409,7 +2429,7 @@ snapshots:
dependencies:
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
js-yaml: 4.3.0
js-yaml: 4.3.1
picomatch: 4.0.5
retext-smartypants: 6.2.0
shiki: 4.3.1
@@ -2446,13 +2466,13 @@ snapshots:
hast-util-from-html: 2.0.3
satteri: 0.9.5
'@astrojs/mdx@7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.1.0(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@24.7.1)(jiti@2.7.0)(rollup@4.62.0))':
'@astrojs/mdx@7.0.3(@astrojs/markdown-satteri@0.3.4)(astro@7.1.0(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(jiti@2.7.0)(rollup@4.62.0))':
dependencies:
'@astrojs/internal-helpers': 0.10.1
'@astrojs/markdown-remark': 7.2.1
'@mdx-js/mdx': 3.1.1
acorn: 8.17.0
astro: 7.1.0(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@24.7.1)(jiti@2.7.0)(rollup@4.62.0)
astro: 7.1.0(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(jiti@2.7.0)(rollup@4.62.0)
es-module-lexer: 2.3.1
estree-util-visit: 2.0.0
hast-util-to-html: 9.0.5
@@ -2472,17 +2492,17 @@ snapshots:
dependencies:
prismjs: 1.30.0
'@astrojs/react@6.0.1(@types/node@24.7.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(jiti@2.7.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@astrojs/react@6.0.1(@types/node@24.13.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(jiti@2.7.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@astrojs/internal-helpers': 0.10.1
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@vitejs/plugin-react': 5.2.0(vite@8.1.5(@types/node@24.7.1)(esbuild@0.28.1)(jiti@2.7.0))
'@vitejs/plugin-react': 5.2.0(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))
devalue: 5.8.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
ultrahtml: 1.7.0
vite: 8.1.5(@types/node@24.7.1)(esbuild@0.28.1)(jiti@2.7.0)
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
transitivePeerDependencies:
- '@types/node'
- '@vitejs/devtools'
@@ -2498,6 +2518,12 @@ snapshots:
- tsx
- yaml
'@astrojs/sitemap@3.7.3':
dependencies:
sitemap: 9.0.1
stream-replace-string: 2.0.0
zod: 4.4.3
'@astrojs/telemetry@3.3.3':
dependencies:
ci-info: 4.4.0
@@ -3202,12 +3228,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
'@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@24.7.1)(esbuild@0.28.1)(jiti@2.7.0))':
'@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))':
dependencies:
'@tailwindcss/node': 4.3.3
'@tailwindcss/oxide': 4.3.3
tailwindcss: 4.3.3
vite: 8.1.5(@types/node@24.7.1)(esbuild@0.28.1)(jiti@2.7.0)
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
'@tybys/wasm-util@0.10.3':
dependencies:
@@ -3263,10 +3289,9 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/node@24.7.1':
'@types/node@24.13.3':
dependencies:
undici-types: 7.14.0
optional: true
undici-types: 7.18.2
'@types/react-dom@19.2.3(@types/react@19.2.17)':
dependencies:
@@ -3276,13 +3301,17 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/sax@1.2.7':
dependencies:
'@types/node': 24.13.3
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
'@ungap/structured-clone@1.3.3': {}
'@vitejs/plugin-react@5.2.0(vite@8.1.5(@types/node@24.7.1)(esbuild@0.28.1)(jiti@2.7.0))':
'@vitejs/plugin-react@5.2.0(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
@@ -3290,7 +3319,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
vite: 8.1.5(@types/node@24.7.1)(esbuild@0.28.1)(jiti@2.7.0)
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
transitivePeerDependencies:
- supports-color
@@ -3309,6 +3338,8 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.2
arg@5.0.2: {}
argparse@2.0.1: {}
aria-query@5.3.2: {}
@@ -3317,7 +3348,7 @@ snapshots:
astring@1.9.0: {}
astro@7.1.0(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@24.7.1)(jiti@2.7.0)(rollup@4.62.0):
astro@7.1.0(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(jiti@2.7.0)(rollup@4.62.0):
dependencies:
'@astrojs/compiler-rs': 0.3.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)
'@astrojs/internal-helpers': 0.10.1
@@ -3345,7 +3376,7 @@ snapshots:
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
js-yaml: 4.3.0
js-yaml: 4.3.1
jsonc-parser: 3.3.1
magic-string: 0.30.21
magicast: 0.5.3
@@ -3367,14 +3398,14 @@ snapshots:
ultrahtml: 1.7.0
unifont: 0.7.4
unstorage: 1.17.5
vite: 8.1.5(@types/node@24.7.1)(esbuild@0.28.1)(jiti@2.7.0)
vitefu: 1.1.3(vite@8.1.5(@types/node@24.7.1)(esbuild@0.28.1)(jiti@2.7.0))
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
vitefu: 1.1.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))
xxhash-wasm: 1.1.0
yargs-parser: 22.0.0
zod: 4.4.3
optionalDependencies:
'@astrojs/markdown-remark': 7.2.1
sharp: 0.35.3(@types/node@24.7.1)
sharp: 0.35.3(@types/node@24.13.3)
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
@@ -3847,7 +3878,7 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@4.3.0:
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
@@ -4375,7 +4406,7 @@ snapshots:
ms@2.1.3: {}
nanoid@3.3.16: {}
nanoid@3.3.18: {}
neotraverse@0.6.18: {}
@@ -4459,9 +4490,9 @@ snapshots:
picomatch@4.0.5: {}
postcss@8.5.19:
postcss@8.5.26:
dependencies:
nanoid: 3.3.16
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -4700,7 +4731,7 @@ snapshots:
semver@7.8.5: {}
sharp@0.35.3(@types/node@24.7.1):
sharp@0.35.3(@types/node@24.13.3):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
@@ -4731,7 +4762,7 @@ snapshots:
'@img/sharp-win32-arm64': 0.35.3
'@img/sharp-win32-ia32': 0.35.3
'@img/sharp-win32-x64': 0.35.3
'@types/node': 24.7.1
'@types/node': 24.13.3
optional: true
shiki@4.3.1:
@@ -4747,6 +4778,13 @@ snapshots:
sisteransi@1.0.5: {}
sitemap@9.0.1:
dependencies:
'@types/node': 24.13.3
'@types/sax': 1.2.7
arg: 5.0.2
sax: 1.6.0
smol-toml@1.7.0: {}
source-map-js@1.2.1: {}
@@ -4755,6 +4793,8 @@ snapshots:
space-separated-tokens@2.0.2: {}
stream-replace-string@2.0.0: {}
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -4812,8 +4852,7 @@ snapshots:
uncrypto@0.1.3: {}
undici-types@7.14.0:
optional: true
undici-types@7.18.2: {}
unified@11.0.5:
dependencies:
@@ -4909,22 +4948,22 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@8.1.5(@types/node@24.7.1)(esbuild@0.28.1)(jiti@2.7.0):
vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.5
postcss: 8.5.19
postcss: 8.5.26
rolldown: 1.1.5
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 24.7.1
'@types/node': 24.13.3
esbuild: 0.28.1
fsevents: 2.3.3
jiti: 2.7.0
vitefu@1.1.3(vite@8.1.5(@types/node@24.7.1)(esbuild@0.28.1)(jiti@2.7.0)):
vitefu@1.1.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)):
optionalDependencies:
vite: 8.1.5(@types/node@24.7.1)(esbuild@0.28.1)(jiti@2.7.0)
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
web-namespaces@2.0.1: {}
+1 -5
View File
@@ -1,8 +1,4 @@
# Robots.txt for Gitea Mirror
User-agent: *
Allow: /
Sitemap: https://gitea-mirror.raylabs.io/sitemap.xml
# Crawl-delay for responsible crawling
User-agent: *
Crawl-delay: 1
Sitemap: https://gitea-mirror.raylabs.io/sitemap-index.xml
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://gitea-mirror.raylabs.io/</loc>
<lastmod>2025-01-08</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>
+10 -4
View File
@@ -1,5 +1,5 @@
---
import { Book, MessageSquare, Bug } from 'lucide-react';
import { Book, MessageSquare, Bug, Scale } from 'lucide-react';
import { GithubIcon } from './icons/GithubIcon';
const links = [
@@ -10,9 +10,14 @@ const links = [
},
{
title: "Documentation",
href: "https://github.com/RayLabsHQ/gitea-mirror/tree/main/docs",
href: "/docs/",
icon: Book
},
{
title: "Compare Tools",
href: "/comparison/github-backup-tools/",
icon: Scale
},
{
title: "Discussions",
href: "https://github.com/RayLabsHQ/gitea-mirror/discussions",
@@ -50,11 +55,12 @@ const currentYear = new Date().getFullYear();
<nav class="grid grid-cols-2 sm:flex items-center justify-center gap-4 sm:gap-6 text-center">
{links.map((link) => {
const Icon = link.icon;
const external = link.href.startsWith('http');
return (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
class="flex items-center justify-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors py-2 sm:py-0"
>
<Icon className="w-3 h-3 sm:w-4 sm:h-4" />
+1
View File
@@ -19,6 +19,7 @@ export function Header() {
{ href: '/#use-cases', label: 'Use Cases' },
{ href: '/#screenshots', label: 'Screenshots' },
{ href: '/#installation', label: 'Installation' },
{ href: '/comparison/github-backup-tools/', label: 'Compare' },
{ href: '/docs/', label: 'Docs' }
];
+8 -1
View File
@@ -61,7 +61,7 @@ import { useCases } from '@/lib/use-cases';
))}
</div>
<div class="mt-10 text-center">
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
<a
href="/use-cases/"
class="inline-flex items-center gap-2 rounded-full border border-primary/40 bg-primary/10 px-6 py-2 text-sm font-semibold text-primary transition-colors hover:bg-primary/15"
@@ -69,6 +69,13 @@ import { useCases } from '@/lib/use-cases';
View more use cases
<ArrowRight class="h-4 w-4" />
</a>
<a
href="/comparison/github-backup-tools/"
class="inline-flex items-center gap-2 rounded-full border border-border px-6 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/50"
>
Compare backup tools
<ArrowRight class="h-4 w-4" />
</a>
</div>
</div>
</section>
+15
View File
@@ -15,6 +15,20 @@ const currentPath = Astro.url.pathname.endsWith('/')
: `${Astro.url.pathname}/`;
const canonical = `https://gitea-mirror.raylabs.io${currentPath}`;
const toc = headings.filter((h: { depth: number }) => h.depth === 2 || h.depth === 3);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: title,
description,
url: canonical,
image: 'https://gitea-mirror.raylabs.io/og-image.png',
publisher: {
'@type': 'Organization',
name: 'RayLabsHQ',
url: 'https://github.com/RayLabsHQ',
},
};
---
<html lang="en">
@@ -31,6 +45,7 @@ const toc = headings.filter((h: { depth: number }) => h.depth === 2 || h.depth =
<meta property="og:url" content={canonical} />
<meta property="og:image" content="https://gitea-mirror.raylabs.io/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
<script is:inline>
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
+7 -1
View File
@@ -15,11 +15,17 @@ const {
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} · Gitea Mirror</title>
<meta name="description" content={description} />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="canonical" href={canonical} />
<meta property="og:title" content={`${title} · Gitea Mirror`} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonical} />
<meta property="og:image" content="https://gitea-mirror.raylabs.io/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<script is:inline>
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
+23 -1
View File
@@ -10,16 +10,38 @@ const {
canonical = 'https://gitea-mirror.raylabs.io/use-cases',
}
} = Astro.props;
const ogImage = 'https://gitea-mirror.raylabs.io/og-image.png';
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: title,
description,
url: canonical,
image: ogImage,
publisher: {
'@type': 'Organization',
name: 'RayLabsHQ',
url: 'https://github.com/RayLabsHQ',
},
};
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} · Gitea Mirror</title>
<meta name="description" content={description} />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="canonical" href={canonical} />
<meta property="og:title" content={`${title} · Gitea Mirror`} />
<meta property="og:description" content={description} />
<meta property="og:type" content="article" />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
<script is:inline>
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
+40
View File
@@ -72,4 +72,44 @@ export const useCases: UseCase[] = [
outcome: 'Continuous mirrors mean you can flip DNS to Gitea, keep working locally, and evaluate alternatives without downtime.',
tags: ['Vendor Independence', 'Continuity', 'Homelab'],
},
{
slug: 'migrate-github-to-gitea',
title: 'Migrate from GitHub to Gitea',
summary: 'Move repositories, issues, PRs, releases, and wikis to self-hosted Gitea by running both platforms in parallel until the switch sticks.',
painPoint: 'One-shot migrations copy the code and lose everything else, so teams drift back to GitHub within weeks.',
outcome: 'A continuously updated mirror carries the whole project, letting you flip the default only when reads and CI already live on Gitea.',
tags: ['Migration', 'Self-Hosted', 'Metadata'],
},
{
slug: 'mirror-git-lfs-repositories',
title: 'Mirror Git LFS Repositories',
summary: 'Bring LFS objects along with your mirrors, and turn LFS off per repository when a broken LFS store blocks the whole migration.',
painPoint: 'One repository with missing LFS objects fails its entire Gitea migration, and a global LFS switch forces an all-or-nothing choice.',
outcome: 'Per-repository overrides mirror LFS content where it works and skip it where it does not, so no repository is left behind.',
tags: ['Git LFS', 'Overrides', 'Troubleshooting'],
},
{
slug: 'github-api-rate-limits',
title: 'Survive GitHub API Rate Limits',
summary: 'Understand what the 5,000 requests per hour budget means for mirroring and why conditional requests make frequent syncs nearly free.',
painPoint: 'Metadata-heavy syncs across hundreds of repositories can burn the hourly API budget and stall naive mirroring tools.',
outcome: 'ETag-based conditional requests turn unchanged answers into free 304s, so sync cost tracks what changed rather than how often you check.',
tags: ['GitHub API', 'Performance', 'Scheduling'],
},
{
slug: 'mirror-github-issues-pull-requests',
title: 'Mirror Issues and Pull Requests',
summary: 'Carry issue threads, review discussions, releases, and wikis into Gitea, converted honestly where the platforms differ.',
painPoint: 'Clones preserve commits but lose the issue and PR history where the real project knowledge lives.',
outcome: 'Issues arrive as native Gitea issues and PRs become labeled, searchable records with commits and merge status intact.',
tags: ['Metadata', 'Issues', 'Pull Requests'],
},
{
slug: 'unraid-github-backup',
title: 'GitHub Backup on Unraid',
summary: 'Install from Community Applications, point it at the Gitea container you already run, and fold GitHub backups into your appdata routine.',
painPoint: 'Unraid users have the hardware and Gitea running, but wiring GitHub backups by hand means scripts and cron jobs.',
outcome: 'The CA template pre-wires ports, paths, and secrets so scheduled, auto-discovering backups run on the NAS you already manage.',
tags: ['Unraid', 'Docker', 'Homelab'],
},
];
@@ -244,7 +244,9 @@ Yes. GitHub Enterprise Cloud uses the standard github.com API, so a personal acc
## Next steps
- **Ready to self-host?** Follow the [GitHub backup playbook](/use-cases/backup-github-repositories/)
- **Need multi-tenant?** See [Proxmox LXC deployment](/use-cases/proxmox-lxc-homelab/) or [Kubernetes Helm chart](/use-cases/deploy-with-helm-chart/)
- **Leaving GitHub entirely?** The [migration guide](/use-cases/migrate-github-to-gitea/) covers running both in parallel until the switch sticks
- **Need multi-tenant?** See [Proxmox LXC deployment](/use-cases/proxmox-lxc-homelab/), [Unraid](/use-cases/unraid-github-backup/), or the [Kubernetes Helm chart](/use-cases/deploy-with-helm-chart/)
- **Repos with large files?** Read the [Git LFS mirroring guide](/use-cases/mirror-git-lfs-repositories/) before enabling LFS everywhere
- **Questions?** [Open a GitHub discussion](https://github.com/RayLabsHQ/gitea-mirror/discussions)
## Honest assessment
+1
View File
@@ -168,3 +168,4 @@ The container image also has a built-in `HEALTHCHECK` that polls this endpoint,
- [Configuration](./configuration.md) covers the Configuration page tab by tab, plus environment-based pre-configuration.
- [Environment variables](./environment-variables.md) is the complete reference.
- [CA certificates](./ca-certificates.md) explains how to trust a self-signed or privately signed Gitea instance.
- The [use-case guides](/use-cases/) cover platform-specific installs (Proxmox, Unraid, Helm) and deeper topics like [Git LFS](/use-cases/mirror-git-lfs-repositories/), [issue and PR mirroring](/use-cases/mirror-github-issues-pull-requests/), and a [full migration to Gitea](/use-cases/migrate-github-to-gitea/).
@@ -55,9 +55,9 @@ On the dashboard:
1. Click **Mirror Repository** for a small test project.
2. Open Gitea and confirm the mirror appears with the right owner/org.
3. In **Configuration → Connections**, open the **Content & Data** section to enable **Mirror metadata** and **Git LFS** if you rely on issues, wikis, or large assets.
3. In **Configuration → Connections**, open the **Mirror Content** section to enable **Repository metadata** and **Git LFS** if you rely on issues, wikis, or large assets. Metadata brings [issues and pull requests](/use-cases/mirror-github-issues-pull-requests/) along; LFS has [its own guide](/use-cases/mirror-git-lfs-repositories/) worth reading before you enable it account-wide.
For broader coverage, switch the organization strategy to **Preserve structure** so Gitea mirrors your GitHub org layout automatically.
For broader coverage, switch the organization strategy to **Preserve Structure** so Gitea mirrors your GitHub org layout automatically.
### 4. Turn on automatic syncs and cleanup
@@ -109,7 +109,7 @@ Treat the mirror like any other DR asset:
### Does Gitea Mirror copy issues, pull requests, releases, and LFS?
Yes. Enable Mirror metadata, Mirror releases, and Git LFS from **Configuration → Connections → Content & Data**. Pull requests are mirrored as enriched issues with linked branches and metadata.
Yes. Enable Repository metadata, Releases & tags, and Git LFS objects from **Configuration → Connections → Mirror Content**. Pull requests are mirrored as enriched issues with linked branches and metadata.
### How often should I sync GitHub backups?
@@ -118,3 +118,10 @@ Most homelabs pick 30–120 minutes. Faster schedules improve RPO but use more G
### Where are backups stored and how do I restore?
The mirrored repositories live in your Gitea instance's storage—Gitea does the actual mirroring. Gitea Mirror's `data/` directory holds its SQLite database (configuration, job history) and optional pre-sync backup bundles. Restore code by cloning from Gitea; restore the mirror service by moving the `data/` volume to a fresh deployment and signing back in.
## Related guides
- [Automate the schedule](/use-cases/github-backup-automation/) so backups run without you
- [GitHub API rate limits](/use-cases/github-api-rate-limits/) and why frequent syncs stay cheap
- [Migrate to Gitea entirely](/use-cases/migrate-github-to-gitea/) when a backup grows into an exit
- [Compare backup tools](/comparison/github-backup-tools/) if you are still choosing an approach
@@ -145,3 +145,8 @@ Yes. Leave `existingSecret` empty and the chart will create a secret with the va
### How do I throttle syncs to fit my quota?
Adjust `gitea-mirror.automation.schedule_interval` in your values file (default: 3600 seconds = 1 hour). Lower values mean more frequent syncs; higher values create quieter schedules. You can also change the interval later from **Configuration → Automation** in the web UI.
## Related guides
- [Proxmox LXC](/use-cases/proxmox-lxc-homelab/) or [Unraid](/use-cases/unraid-github-backup/) for single-node homelabs
- [Automate the backup schedule](/use-cases/github-backup-automation/) once the chart is up
@@ -0,0 +1,62 @@
---
layout: ../../layouts/UseCaseLayout.astro
title: "GitHub API Rate Limits and Repository Mirroring"
description: "What GitHub's 5,000 requests per hour actually means for mirroring, how conditional requests with ETags make 304s free, and how to configure sync intervals that never hit the wall."
canonical: "https://gitea-mirror.raylabs.io/use-cases/github-api-rate-limits/"
---
# GitHub API Rate Limits and Repository Mirroring
## The budget: 5,000 requests per hour
Every authenticated GitHub token gets 5,000 REST API requests per hour. That sounds like a lot until you do mirroring math. Keeping a mirror current means repeatedly asking GitHub "what changed?": listing repositories, reading issues, fetching comments, checking releases. A sync pass over an account with a few hundred repositories and active issue trackers can burn thousands of requests, and a short sync interval multiplies that by every pass.
Hit the limit and GitHub answers `403` until the window resets. A naive mirroring tool either fails its sync or, worse, retries in a loop and keeps the token pinned at zero.
## What actually consumes requests
For a mirroring workload, the spend breaks down roughly like this:
- **Discovery**: listing your repositories, organizations, and starred repos. Cheap, paginated, a handful of requests per sync.
- **Metadata**: issues, comments, pull requests, labels, milestones, releases. This is where the volume is. An issue-heavy repository can cost dozens of requests to check thoroughly.
- **Git data**: essentially free. The actual `git fetch` traffic goes over the git protocol, which is not metered by the REST API limit at all. The same is true of [LFS transfers](/use-cases/mirror-git-lfs-repositories/).
So the rate limit problem is really a metadata problem. The repositories themselves would sync happily forever; it is the "did any of 400 issue trackers change" question that gets expensive.
## Conditional requests: how 304s become free
GitHub's API supports conditional requests. Every response carries an `ETag` header, a fingerprint of the response body. Send it back on your next request as `If-None-Match`, and if nothing changed GitHub replies `304 Not Modified` with an empty body.
The part that matters for mirroring: **for authenticated requests, a 304 does not count against your rate limit.**
Gitea Mirror uses this on every GET. Each response's ETag is cached; every subsequent sync replays it. On a quiet account, a full metadata sweep costs almost nothing, because nearly every answer is a 304. You pay full price only for the things that actually changed, which is exactly the data you wanted anyway.
The practical effect is that sync frequency mostly stops mattering. A `30m` interval on a stable account costs barely more than a `24h` one, because 47 of the 48 daily sweeps are nearly all cache hits. The requests you spend scale with how much changed, not how often you look.
## Configuration that stays inside the budget
A few settings still matter for large or busy accounts:
- **Sync interval**: `8h` is the default and fine for backup use. If you want the mirror hot, `30m` or `1h` is safe on most accounts thanks to conditional requests. See the [scheduling guide](/use-cases/github-backup-automation/) for interval syntax.
- **Concurrency**: issue and pull request mirroring concurrency are configurable. The defaults (3 issues, 5 PRs in flight) balance speed against burst spend. Raising them speeds up the first import but makes rate limiting more likely on very large accounts.
- **Metadata selection**: only enable what you need. If you [mirror starred repositories](/use-cases/starred-repos-collection/), the "starred code only" option skips their metadata entirely, which is usually right for repos you do not own.
The first import is the one place you can still feel the limit, because nothing is cached yet and every issue is new. Gitea Mirror tracks the remaining budget from GitHub's rate limit headers and throttles rather than failing, so a large first import stretches over time instead of erroring at request 5,001. This matters most during a [full migration](/use-cases/migrate-github-to-gitea/), where the first pass is by far the biggest.
## FAQ
### How do I see my current rate limit status?
`curl -H "Authorization: Bearer <token>" https://api.github.com/rate_limit` shows the remaining budget and reset time per category. The `core` bucket is the one mirroring spends from.
### Does using multiple tokens help?
Rate limits are per token for personal access tokens, so separate tokens for separate Gitea Mirror users each get their own 5,000. Within one user, one token is the honest setup; rotating tokens to dodge limits is against GitHub's terms.
### Do git clone and fetch operations count?
No. Git protocol traffic and LFS transfers are not REST API requests. Only the metadata work (issues, releases, discovery) spends from the hourly budget.
### What happens if the limit is hit anyway?
Syncs pause and resume after the window resets. Mirror jobs track their progress, so an interrupted metadata pass continues where it stopped rather than starting over.
@@ -22,7 +22,7 @@ Shell scripts and `git clone --mirror` jobs work until they don’t—usually wh
### 1. Enable automatic syncing
1. Go to **Configuration → Automation**.
2. Toggle **Automatic syncing** on and choose an interval that matches your recovery point objective (start with `30 minutes` for active teams, stretch to `12 hours` for archives).
2. Toggle **Automatic syncing** on and choose an interval that matches your recovery point objective (start with `30 minutes` for active teams, stretch to `12 hours` for archives). Frequent checks are cheap: [unchanged repositories cost almost no API budget](/use-cases/github-api-rate-limits/).
3. Confirm the scheduler is running by checking the **Last sync** and **Next sync** timestamps in the Automation card.
### 2. Keep the repository list current
@@ -91,3 +91,9 @@ Monitor `/api/health` with Healthchecks.io or Uptime Kuma and review the Activit
### Will new repositories be discovered automatically?
Yes. After importing your GitHub data once, the scheduler’s discovery step keeps the inventory updated and mirrors new repositories on the next run.
## Related guides
- [The base backup playbook](/use-cases/backup-github-repositories/) this automation builds on
- [Keep Gitea in lockstep with GitHub](/use-cases/sync-github-to-self-hosted-gitea/)
- [Per-repository mirror options](/use-cases/mirror-git-lfs-repositories/) when one repo needs different settings
@@ -0,0 +1,93 @@
---
layout: ../../layouts/UseCaseLayout.astro
title: "Migrate from GitHub to Gitea: The Complete Guide"
description: "Move your repositories, issues, pull requests, releases, and wikis from GitHub to a self-hosted Gitea instance. Covers org structure, LFS, and keeping both in sync during the transition."
canonical: "https://gitea-mirror.raylabs.io/use-cases/migrate-github-to-gitea/"
---
# Migrate from GitHub to Gitea: The Complete Guide
## Migration is not a one-day event
Most GitHub-to-Gitea migrations fail in the same way: someone clones every repo over a weekend, declares victory, and three weeks later the team is still pushing to GitHub because the issues, releases, and muscle memory never moved. The code was migrated. The project wasn't.
A migration that sticks runs both platforms in parallel for a while. Gitea Mirror is built for exactly that window: it keeps a complete, continuously updated copy of your GitHub repositories on your Gitea instance, metadata included, until you are ready to flip the default. And if you decide to keep GitHub for collaboration permanently, the same setup simply becomes your [backup strategy](/use-cases/backup-github-repositories/).
## What actually needs to move
An honest inventory is the first step. For each repository you have:
- **Git data**: branches, tags, full history. Easy, `git clone --mirror` handles it.
- **Issues**: titles, bodies, comments, labels, milestones, assignees. Not in git at all.
- **Pull requests**: discussion history and review context you will want to search later.
- **Releases**: tags are in git, but release notes and uploaded assets are not.
- **Wikis**: a separate git repository per project that most manual migrations forget.
- **LFS objects**: binary files stored outside the normal git object database.
Gitea's built-in migration tool can pull most of this once. The problem is the word "once": anything that changes on GitHub after that import is lost unless you re-import from scratch. That is workable for archived projects and unusable for active ones.
## Step 1: Stand up the mirror
Install Gitea Mirror next to your Gitea instance ([Docker](/docs/quickstart/), [Helm](/use-cases/deploy-with-helm-chart/), [Proxmox LXC](/use-cases/proxmox-lxc-homelab/), or [Unraid](/use-cases/unraid-github-backup/)), then connect both sides:
1. Create a GitHub personal access token with `repo` scope (read access to everything you are migrating).
2. Create a Gitea access token with permission to create repositories and organizations.
3. Enter both in the setup wizard. Tokens are encrypted at rest with AES-256-GCM.
Pick a mirror strategy before importing anything, because it decides your Gitea namespace layout:
- **Preserve**: GitHub orgs become Gitea orgs with the same names. The right default for a full migration.
- **Single org**: everything lands in one Gitea organization.
- **Flat user**: all repos under your user account.
- **Mixed**: personal repos in one org, organization repos keep their structure.
## Step 2: Import and mirror everything
Enable the metadata options in Configuration: issues, pull requests, labels, milestones, releases, and wiki. Then import your repositories and let the first mirror run complete.
Two things to know about how the metadata lands:
- Issues arrive as real Gitea issues with comments, labels, and original author attribution preserved in the body.
- Pull requests become Gitea issues tagged with a `pull-request` label, titled `[PR #123]` with their status. Gitea's API cannot create real pull requests from external repos, so this is the honest representation: the discussion and review history stays searchable next to the code it belongs to. The details are in [mirroring issues and pull requests](/use-cases/mirror-github-issues-pull-requests/).
If some repositories use Git LFS, read the [LFS guide](/use-cases/mirror-git-lfs-repositories/) before enabling it globally. A repository whose LFS objects cannot be fetched will fail its entire migration, and since v3.27.0 you can turn LFS off for just that repository instead of losing it.
## Step 3: Live in parallel
This is the phase that makes the migration real. Set a sync interval (`8h` works for most teams, `30m` if you want the mirror hot) and keep working on GitHub as usual. Auto-discovery picks up new repositories as they appear, so nobody has to remember to add them.
Meanwhile, move the team's read traffic to Gitea: point CI checkouts, dependency references, and documentation links at the mirror. Reads are the easy 80 percent of the switch, and every read you move builds confidence that the mirror is complete.
## Step 4: Flip the default
When you are ready to make Gitea primary:
1. Freeze pushes to GitHub (branch protection on all branches is the simple way).
2. Let one final sync run.
3. In Gitea, convert the mirrored repositories to normal repositories (Settings, then "Convert to regular repository"). Mirrors are read-only until you do this.
4. Update remotes: `git remote set-url origin https://gitea.example.com/org/repo.git`.
5. Move CI, webhooks, and deploy keys.
Keep the GitHub repos around in archived state for a quarter. They cost nothing and answer every "wait, where did X go" question.
## What about the reverse direction?
Teams that migrate off GitHub sometimes need to keep a public presence there. Gitea has built-in push mirroring for that: your Gitea repo pushes to a GitHub remote on every change. Gitea Mirror handles the pull direction, GitHub to Gitea; the push direction is native Gitea functionality and the two work fine together.
## FAQ
### Can I migrate without downtime?
Yes. That is the point of the parallel phase: GitHub stays fully usable while the mirror stays current. The only freeze is the minutes between the last sync and updating remotes.
### Do I lose pull request history?
No, but it changes shape. PR discussions are preserved as labeled, searchable Gitea issues with commit lists and merge status. Open PRs you want to keep working on should be merged or closed on GitHub before the flip.
### How long does the first import take?
Depends on repo count and metadata volume. Git data is fast; issue-heavy repositories take longer because issues are mirrored one at a time to keep their order and numbering sane. Expect hours, not days, for a typical organization. GitHub's [API rate limits](/use-cases/github-api-rate-limits/) are respected automatically, so a large first import throttles itself rather than failing.
### What if I only want some repositories?
Import selectively, or set include/exclude patterns in the configuration. You can also mirror [just your starred repositories](/use-cases/starred-repos-collection/).
@@ -0,0 +1,66 @@
---
layout: ../../layouts/UseCaseLayout.astro
title: "Mirroring Git LFS Repositories to Gitea (and Fixing LFS Failures)"
description: "How Git LFS mirroring works in Gitea, why one bad LFS repo can fail its whole migration, and how per-repository overrides let you mirror the code anyway."
canonical: "https://gitea-mirror.raylabs.io/use-cases/mirror-git-lfs-repositories/"
---
# Mirroring Git LFS Repositories to Gitea
## LFS changes what "a copy of the repo" means
Git LFS stores large files outside the normal git object database. The repository itself only holds small pointer files; the real content lives on an LFS server, which for GitHub-hosted repos is GitHub's LFS storage.
That split has a direct consequence for mirroring: a `git clone --mirror` of an LFS repository gives you every commit and every pointer, but none of the actual file content. If GitHub disappeared tomorrow, your "backup" would contain three-line text files where your datasets, binaries, and media used to be. For a [backup strategy](/use-cases/backup-github-repositories/) to be complete, the LFS objects have to come along.
## How Gitea Mirror handles LFS
When LFS is enabled, Gitea Mirror passes `lfs: true` to Gitea's migration API. Gitea then fetches the LFS objects itself during the migration and stores them in its own LFS storage. From that point on, a clone from your Gitea mirror delivers real content, not pointers.
This design has one sharp edge worth understanding: **the LFS fetch happens inside Gitea's migration, not in Gitea Mirror**. If the fetch fails partway through, Gitea abandons the entire migration. There is no partial result to keep, no "code succeeded, LFS failed" state. The repository simply fails to mirror, and it will fail the same way on every retry.
## Why LFS fetches fail
The failures are usually on the source side, not yours:
- **Missing objects**: the repository's history references LFS objects that no longer exist on GitHub's LFS server. Common in old repos and forks, where the fork's LFS storage never had the parent's objects.
- **LFS bandwidth quotas**: GitHub meters LFS bandwidth. A large repository can exhaust the source owner's quota mid-fetch.
- **Size**: multi-gigabyte LFS stores can hit timeouts on slow links.
You can verify whether a repository is affected by migrating it manually in Gitea's UI with LFS unchecked. If that succeeds where the mirrored migration fails, the LFS fetch is your problem.
## The fix: turn LFS off for just that repository
Until v3.27.0, the LFS switch was global. That forced a bad trade: disable LFS everywhere and lose LFS content from the repositories where it matters, or keep it enabled and permanently lose the repositories whose LFS fetch fails.
Now the option is per-repository. Open the three-dot menu on any repository row, choose **Mirror Options**, and set Git LFS to **Off** for the repository that keeps failing. Your global default stays on, every other repository keeps its LFS objects, and the problem repository mirrors its full git history with pointer files in place of the unfetchable content.
The same override exists at the organization level, so "everything under this org skips LFS" is one setting rather than fifty. Overrides resolve per option: repository first, then organization, then your global default. Anything left on "Inherit" falls through.
The other mirror options (issues, pull requests, releases, wiki) can be overridden the same way, which is useful beyond LFS: a repository with enormous release assets can skip releases, a repository whose [issue history matters more than its binaries](/use-cases/mirror-github-issues-pull-requests/) can mirror metadata only.
## Storage planning
LFS objects live twice in a mirrored setup: once on GitHub and once in your Gitea instance's LFS storage. Before enabling LFS across a large account:
- Check Gitea's storage backend configuration. Local disk is the default; MinIO or S3-compatible storage scales better for large LFS stores.
- Expect the first sync of an LFS-heavy repository to take significantly longer than its git-only size suggests.
- Gitea deduplicates LFS objects by content hash within a repository, but mirrored forks each store their own copies.
## FAQ
### How do I know which of my repositories use LFS?
The repository table in Gitea Mirror tracks this. Repositories detected with LFS content are flagged during discovery, so you can decide before the first mirror whether to enable the fetch.
### A repository fails to mirror with LFS enabled. What is the fastest fix?
Set Git LFS to Off in that repository's Mirror Options and retry. The migration will complete without the LFS content. If you later fix the source problem, flip it back to Inherit and re-mirror.
### Do LFS objects sync continuously like commits do?
Yes. Once the initial migration succeeds, Gitea's periodic mirror sync fetches new LFS objects along with new commits at your configured [sync interval](/use-cases/sync-github-to-self-hosted-gitea/).
### Does mirroring LFS count against my GitHub API rate limit?
No. LFS transfers use GitHub's LFS endpoints, not the REST API, so they are metered by LFS bandwidth rather than [API request limits](/use-cases/github-api-rate-limits/). The two quotas are independent.
@@ -0,0 +1,74 @@
---
layout: ../../layouts/UseCaseLayout.astro
title: "Mirror GitHub Issues and Pull Requests to Gitea"
description: "How issue and PR mirroring actually works: what transfers cleanly, how pull requests become searchable Gitea issues, and why everything is processed in order."
canonical: "https://gitea-mirror.raylabs.io/use-cases/mirror-github-issues-pull-requests/"
---
# Mirror GitHub Issues and Pull Requests to Gitea
## The code is the smallest part of the project
Clone any mature repository and you have maybe a tenth of the project's real knowledge. The rest lives in issues: why a decision was made, which bug reports turned out to be the same bug, what the maintainer said in 2021 about the exact edge case you just hit. Pull request discussions carry the review reasoning that the commit message compressed into one line.
None of that is in git. A mirror that only copies commits [preserves the code and loses the history around it](/use-cases/preserve-github-history/). This page explains what Gitea Mirror transfers and, just as importantly, the shape it arrives in.
## Issues: near-native transfer
Issues map cleanly onto Gitea's issue model, so they arrive as real, native issues:
- Title and body, with the original author and timestamps noted in the body (the mirror runs under your Gitea account, so attribution is preserved in content rather than forged).
- Every comment, in order.
- Labels, with colors, created on the Gitea side as needed.
- Milestones, matched by title.
- Open and closed state.
Mirroring is idempotent: each mirrored issue carries a marker tying it to its GitHub counterpart, so re-syncs update existing issues in place instead of duplicating them. New comments on GitHub arrive on the next sync.
### Order matters more than speed
Issues are mirrored sequentially, one at a time, rather than blasted in parallel. That choice is deliberate. Creating issues concurrently means they arrive out of order, and Gitea numbers issues by arrival. Sequential processing keeps the mirror's issue ordering coherent with the source, at the cost of a slower first import. Combined with [ETag-based conditional requests](/use-cases/github-api-rate-limits/), later syncs only touch what changed, so the slow part happens exactly once.
## Pull requests: honest conversion, not imitation
Gitea's API cannot create real pull requests that reference an external repository's branches. A PR is a live object wired to branch state, and a mirror's branches belong to GitHub. Any tool that claims to "mirror PRs" into Gitea is converting them into something else; the only question is whether the conversion is honest.
Gitea Mirror converts each pull request into a Gitea issue with a consistent, searchable shape:
- Tagged with a `pull-request` label, so they are one filter away and never mixed into your real issue list by accident.
- Title prefixed with the PR number and status: `[PR #482] [MERGED] Add retry logic to sync jobs`.
- Body carries the PR description, the commit list, file change summary, and merge status.
- The discussion thread follows as comments, same as issues.
What you get is the full review history, searchable next to the code it describes. What you do not get is a clickable diff or a mergeable object, and no mirroring tool can honestly give you that. During a [migration to Gitea](/use-cases/migrate-github-to-gitea/), merge or close active PRs on GitHub before switching; the converted record is for history, not for continuing the review.
## Releases and wikis round it out
Two more pieces of non-git project state come along when enabled:
- **Releases**: notes and uploaded assets, mirrored with a configurable limit on how many recent releases to keep (default 10, because release assets are often the bulkiest part of a repository).
- **Wikis**: each GitHub wiki is its own git repository, and it is cloned into the Gitea project's wiki.
## Choosing what to mirror, and where
All of this is opt-in per option: issues, pull requests, labels, milestones, releases, and wiki each have their own switch. Since v3.27.0 those switches also exist per repository and per organization, resolving repository first, then organization, then your global default. That enables the practical mixes: metadata everywhere except the org with 40,000 issues, releases only for the repositories whose assets you deploy from, [LFS off for the one repository whose LFS storage is broken](/use-cases/mirror-git-lfs-repositories/).
For starred repositories there is a coarser and very useful switch: "starred code only", which mirrors the code of [everything you have starred](/use-cases/starred-repos-collection/) and skips the metadata, since you rarely need a private copy of someone else's issue tracker.
## FAQ
### Are mirrored issues editable on the Gitea side?
They are normal Gitea issues, so nothing stops you, but edits will be overwritten when the next sync updates that issue from GitHub. Treat the mirror as read-only until you [convert it to a regular repository](/use-cases/migrate-github-to-gitea/).
### Do issue numbers match between GitHub and Gitea?
Sequential processing keeps ordering consistent, and each mirrored issue records its source number. On a fresh mirror of a repository whose issues were all mirrorable, numbers typically line up; the recorded marker is the reliable link either way.
### How fast do new comments appear on the mirror?
On the next scheduled sync. With conditional requests making quiet checks nearly free, short intervals like `30m` are affordable, so the mirror can stay minutes-to-hours behind at your discretion.
### Can I mirror issues but not pull requests?
Yes, they are independent switches, globally and per repository.
@@ -22,16 +22,16 @@ GitHub accounts get banned, repos go private, and owners rage-delete history. If
### 1. Set archival-friendly defaults
In **Configuration → Connections**, open **Content & Data**:
In **Configuration → Connections**, open **Mirror Content**:
- Enable **Mirror metadata** and choose the components you care about (issues, pull requests, labels, milestones, wiki).
- Enable **Repository metadata** and choose the components you care about (issues, pull requests, labels, milestones, wiki). See [how each piece transfers](/use-cases/mirror-github-issues-pull-requests/), including the shape pull requests arrive in.
- Enable **Mirror releases** and raise the **Latest releases** limit if you need a deeper history of release assets.
- Toggle **Git LFS (Large File Storage)** so binaries follow the repository, assuming LFS is enabled in your Gitea instance.
- Toggle **Git LFS objects** so binaries follow the repository, assuming LFS is enabled in your Gitea instance. Old repositories sometimes have [broken LFS stores that fail the whole migration](/use-cases/mirror-git-lfs-repositories/); turn LFS off per repository when that happens.
### 2. Create an "Archive" organization in Gitea
1. In Gitea, create an org like `github-archive` and grant read-only access to everyone who needs the history.
2. Back in Gitea Mirror under **Configuration → Connections**, pick the **Preserve structure** strategy (or set a destination organization) so repos land in that archive org.
2. Back in Gitea Mirror under **Configuration → Connections**, pick the **Preserve Structure** strategy (or set a destination organization) so repos land in that archive org.
3. Tighten permissions in Gitea—disable pushes for regular users so the archive stays immutable while the service updates it via its token.
<figure class="mt-8 flex flex-col items-center">
@@ -86,7 +86,7 @@ In **Configuration → Connections**, open **Content & Data**:
### Does this preserve issues, pull requests, and releases?
Yes—enable Mirror metadata and Mirror releases from **Configuration → Connections → Content & Data**. Pull requests copy as enriched issues, keeping discussion and labels.
Yes. Enable Repository metadata and Releases & tags from **Configuration → Connections → Mirror Content**. Pull requests copy as enriched issues, keeping discussion and labels.
### What happens if a GitHub repo is deleted or goes private?
@@ -95,3 +95,8 @@ Turn on Handle orphaned repositories automatically and use Archive to keep a rea
### How much storage will I need long-term?
Plan for repo size plus attachments and LFS. Monitor the mirror’s `data/` volume growth and consider ZFS/BTRFS snapshots or object storage for older archives.
## Related guides
- [Backup GitHub repositories](/use-cases/backup-github-repositories/) for the day-to-day version of this
- [Build a starred repo collection](/use-cases/starred-repos-collection/) to archive other people's work too
@@ -108,3 +108,9 @@ Re-run the community installer. It detects existing installs, backs up `/opt/git
### How should I back up and restore?
Snapshot the container or back up `/opt/gitea-mirror/data`. Restore by attaching the volume to a fresh container and starting the service; your configuration is preserved.
## Related guides
- [Unraid instead of Proxmox?](/use-cases/unraid-github-backup/) The CA template does the same job there
- [Kubernetes cluster?](/use-cases/deploy-with-helm-chart/) The Helm chart is the cluster-native path
- [What to configure once it boots](/use-cases/backup-github-repositories/)
@@ -24,7 +24,7 @@ Stars are a personal library—but they vanish when creators delete repos or you
1. In the onboarding wizard—or later under **Configuration → Connections**—add your GitHub username as an owner.
2. Enable **Mirror starred repositories** and set the **Starred repos organization** to a dedicated space such as `stars-jamie`.
3. Use the starred content selector to choose whether you want full metadata (issues, releases, PRs, wiki) or the lightweight code-only mode.
3. Use the starred content selector to choose whether you want full metadata (issues, releases, PRs, wiki) or the lightweight code-only mode. Code-only is usually right for stars: [you rarely need a private copy of someone else's issue tracker](/use-cases/mirror-github-issues-pull-requests/), and it saves a lot of [API budget](/use-cases/github-api-rate-limits/).
### 2. Categorize the collection
@@ -87,3 +87,7 @@ Enable Handle orphaned repositories automatically and choose Archive (recommende
### Can others browse my collection safely?
Create a read-only Gitea user or org members with no write permissions, then share the organization URL behind HTTPS.
## Related guides
- [Preserve GitHub history](/use-cases/preserve-github-history/) for the archival version of this workflow
@@ -13,7 +13,7 @@ You may still collaborate on GitHub every day, yet want a LAN Gitea copy you con
## Requirements
- Gitea Mirror deployed (Docker, Proxmox LXC, or Helm)
- Gitea Mirror deployed (Docker, [Proxmox LXC](/use-cases/proxmox-lxc-homelab/), [Unraid](/use-cases/unraid-github-backup/), or [Helm](/use-cases/deploy-with-helm-chart/))
- GitHub PAT with `repo` enabled (add the `read:org` checkbox under `admin:org` to mirror organizations; leave write/admin unchecked)
- Gitea API token for the target account/org
- Stable connectivity between the mirror host and both GitHub + Gitea
@@ -25,7 +25,7 @@ You may still collaborate on GitHub every day, yet want a LAN Gitea copy you con
1. Sign in at `http://<mirror-host>:4321`.
2. Open **Configuration → Connections**.
3. Paste the GitHub PAT and choose the owners (user + orgs) you want mirrored.
4. Add your self-hosted Gitea URL and token; pick the destination org structure (typically **Preserve structure**).
4. Add your self-hosted Gitea URL and token; pick the destination org structure (typically **Preserve Structure**).
### 2. Import the repository inventory
@@ -36,7 +36,7 @@ You may still collaborate on GitHub every day, yet want a LAN Gitea copy you con
### 3. Configure sync cadence
- Head to **Configuration → Automation** and enable **Automatic syncing**.
- Pick an interval that matches your recovery point objective; `15–30 minutes` keeps an active GitHub org nearly live, while `2–4 hours` is fine for quieter repos.
- Pick an interval that matches your recovery point objective; `15–30 minutes` keeps an active GitHub org nearly live, while `2–4 hours` is fine for quieter repos. Short intervals cost less than you would expect, because [conditional requests make unchanged checks nearly free](/use-cases/github-api-rate-limits/).
- Leave the scheduler running so auto-discovery and repository cleanup keep working in the background.
<figure class="mt-8 flex flex-col items-center">
@@ -51,11 +51,11 @@ You may still collaborate on GitHub every day, yet want a LAN Gitea copy you con
</figcaption>
</figure>
### 4. Mirror metadata and LFS
### 4. Metadata and LFS
In **Configuration → Connections → Content & Data**:
In **Configuration → Connections → Mirror Content**:
- Enable **Mirror metadata** so issues, pull requests (as enriched issues), labels, milestones, and wikis stay in sync.
- Enable **Repository metadata** so issues, pull requests (as enriched issues), labels, milestones, and wikis stay in sync.
- Enable **Mirror LFS** if your repos store binaries; confirm your Gitea instance has LFS enabled.
- If you want deleted GitHub repos archived or removed locally, enable **Handle orphaned repositories automatically** in the Automation tab and choose the action (Archive or Delete) that matches your retention policy.
@@ -90,8 +90,14 @@ Intervals of 15–30 minutes keep most orgs near real-time. You can shorten for
### Can I mirror multiple GitHub users and organizations?
Yes. Add each owner in Configuration → Connections, then choose a destination strategy (Preserve structure or a specific org) for Gitea.
Yes. Add each owner in Configuration → Connections, then choose a destination strategy (Preserve Structure or a specific org) for Gitea.
### Is it safe to store tokens in Gitea Mirror?
Tokens saved via the Configuration UI are encrypted at rest. Rotate them regularly and monitor dashboard alerts for expirations.
## Related guides
- [How issues and PRs transfer](/use-cases/mirror-github-issues-pull-requests/) when metadata is enabled
- [Git LFS mirroring](/use-cases/mirror-git-lfs-repositories/) and the one failure mode to know about
- [Make Gitea primary](/use-cases/migrate-github-to-gitea/) when lockstep becomes a migration
@@ -0,0 +1,78 @@
---
layout: ../../layouts/UseCaseLayout.astro
title: "GitHub Backup on Unraid with Gitea Mirror"
description: "Install Gitea Mirror on Unraid from Community Applications, connect it to your Gitea container, and get scheduled GitHub backups on the server you already run."
canonical: "https://gitea-mirror.raylabs.io/use-cases/unraid-github-backup/"
---
# GitHub Backup on Unraid with Gitea Mirror
## Your NAS is the right place for this
If you run Unraid, you already have the two things a GitHub backup needs: always-on hardware and storage you control. Most Unraid homelabs also already run a Gitea container. Gitea Mirror is the piece that connects them to GitHub, with scheduled syncs, auto-discovery of new repositories, and [metadata mirroring](/use-cases/mirror-github-issues-pull-requests/) so issues and releases come along with the code.
The result is the same appliance the [Proxmox LXC guide](/use-cases/proxmox-lxc-homelab/) builds, on the platform you already manage through a web UI.
## Requirements
- Unraid with Docker enabled and the Community Applications plugin installed
- A running Gitea instance (the official `gitea/gitea` container from CA works fine)
- A GitHub personal access token with `repo` scope
- A Gitea access token that can create repositories and organizations
## Step-by-step
### 1. Install from Community Applications
Search **Gitea Mirror** in the Apps tab (template maintained by RayLabsHQ) and click install. The template pulls `ghcr.io/raylabshq/gitea-mirror` and pre-wires the important settings:
- **Port**: `4321` for the web UI.
- **App Data**: `/mnt/user/appdata/gitea-mirror` mapped to `/app/data`. This holds the SQLite database and configuration, so it is the only path you need to back up.
- **Custom CA Certificates** (advanced, optional): mount a folder of `.crt` files at `/app/certs` if your Gitea runs behind a private certificate authority.
### 2. Set the secrets
The template exposes the environment variables that matter:
- `BETTER_AUTH_SECRET`: session signing secret. Set a long random value.
- `BETTER_AUTH_URL`: the URL you will actually browse to, e.g. `http://tower.local:4321`.
- `ENCRYPTION_SECRET`: encrypts your GitHub and Gitea tokens at rest (AES-256-GCM). Set it once and do not rotate it casually, because stored tokens are only readable with it.
Generate secrets with `openssl rand -hex 32` from the Unraid terminal.
### 3. First login
Browse to `http://<unraid-ip>:4321`. The first account created becomes the admin. The setup wizard asks for your GitHub token and your Gitea URL and token.
One Unraid-specific note: if Gitea runs on the same box, use the LAN address (`http://<unraid-ip>:3000`) rather than `localhost`, because `localhost` inside the Gitea Mirror container is the container itself, not your Gitea.
### 4. Import and schedule
Select the owners and organizations to mirror, run the first sync, then enable scheduling. An interval of `8h` suits backup use; [shorter intervals are cheap](/use-cases/github-api-rate-limits/) thanks to conditional API requests. Enable auto-discovery so new GitHub repositories start mirroring without you touching the UI. The full option set is covered in the [automation guide](/use-cases/github-backup-automation/).
### 5. Fold it into your Unraid backup routine
- Add `/mnt/user/appdata/gitea-mirror` to your appdata backup (CA Appdata Backup covers it by default).
- The mirrored repositories themselves live in your Gitea container's data share, which should already be in that routine.
- Watchtower-style auto-updates work normally; the container migrates its database on startup after an update.
## Troubleshooting
- **Web UI unreachable**: check for a port clash on 4321 and remap in the template if needed.
- **Sign-in loops or 403s**: `BETTER_AUTH_URL` must match the address in your browser exactly, scheme and port included.
- **Gitea connection refused**: use the LAN IP, not `localhost`, and confirm the Gitea container is on a network the Gitea Mirror container can reach (bridge mode on the same host is fine).
- **A repository fails to mirror repeatedly at the LFS stage**: turn LFS off for that repository in its Mirror Options. The [LFS guide](/use-cases/mirror-git-lfs-repositories/) explains why this happens.
## FAQ
### Does this replace backing up my Gitea container?
No, it feeds it. Gitea Mirror keeps Gitea current with GitHub; your existing appdata and share backups protect Gitea itself. Together they give you GitHub, a live local copy, and cold backups of that copy.
### How much storage do I need?
Roughly the sum of your repositories' sizes, plus LFS objects where enabled, plus release assets if you mirror them. The release limit (default: last 10 per repository) keeps asset growth bounded.
### Can multiple family members or teammates use one instance?
Yes, Gitea Mirror is multi-user. Each user connects their own GitHub and Gitea tokens, and each token is encrypted at rest.
@@ -23,8 +23,8 @@ GitHub can change pricing, authentication rules, or terms without notice. With G
### 1. Mirror everything continuously
- In **Configuration → Connections**, add every GitHub organization you care about as an owner. Auto-discovery pulls new repositories on the next scheduler run.
- Set the organization strategy to **Preserve structure** so Gitea mirrors the GitHub org layout.
- Open **Content & Data** to enable metadata, releases, and Git LFS, ensuring issues, pull requests, and binaries survive the switchover.
- Set the organization strategy to **Preserve Structure** so Gitea mirrors the GitHub org layout.
- Open **Mirror Content** to enable metadata, releases, and Git LFS, ensuring [issues, pull requests](/use-cases/mirror-github-issues-pull-requests/), and [binaries](/use-cases/mirror-git-lfs-repositories/) survive the switchover.
### 2. Simulate a cutover
@@ -75,3 +75,8 @@ Keep `Automatic syncing` at 15–30 minutes for active organizations. That keeps
### Can I script the cutover?
Yes. Use the `/api/export` configuration JSON and the repositories CSV export to generate runbooks, then pair them with infrastructure-as-code (DNS, reverse proxy, CI) so flipping endpoints becomes repeatable.
## Related guides
- [The full migration guide](/use-cases/migrate-github-to-gitea/) for when the exit hatch gets used
- [Compare backup tools](/comparison/github-backup-tools/) to sanity-check this approach against the alternatives