mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-30 22:21:58 +01:00
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.
This commit is contained in:
1 parent
a295a73066
commit
0f700c7197
19 files changed
+4284
-95
No files matched your search
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,6 +311,16 @@ export function OrganizationList({
|
||||
</div>
|
||||
|
||||
{/* Status 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",
|
||||
@@ -289,6 +329,7 @@ export function OrganizationList({
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination override section */}
|
||||
<div className="mb-4">
|
||||
@@ -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 })}
|
||||
@@ -685,6 +730,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>
|
||||
);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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, 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";
|
||||
@@ -27,6 +27,12 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
||||
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),
|
||||
@@ -778,6 +872,21 @@ export default function RepositoryTable({
|
||||
Fork
|
||||
</span>
|
||||
)}
|
||||
{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]">
|
||||
@@ -848,6 +957,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 +1057,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 +1095,7 @@ function RepoActionButton({
|
||||
onDelete,
|
||||
onApproveSync,
|
||||
onDismissSync,
|
||||
onEditMirrorOptions,
|
||||
}: {
|
||||
repo: { id: string; status: string };
|
||||
isLoading: boolean;
|
||||
@@ -971,6 +1106,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 +1223,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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user