mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-11-03 07:42:19 +01:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f907664c | ||
|
|
be0594b28f |
No files matched your search
@@ -42,6 +42,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Comprehensive fix report documentation
|
||||
|
||||
### Fixed
|
||||
- A per-organization Mirror Destination equal to the organization's own name is kept instead of being dropped (#416)
|
||||
- The editor treated a typed value matching the organization name as "reset to default" and saved no override, which only holds under the preserve strategy; under single-org the default is the destination organization, so the repositories went there
|
||||
- The organization card now shows the default for the configured strategy in the preview, the placeholder, the helper text and the reset button, and marks any stored destination as custom
|
||||
- The repository destination column shows the organization's override as a repository's default, and pinning a repository to the value the strategy already produces is stored rather than discarded
|
||||
- Reset to Default on an organization card clears the override instead of saving the current value again
|
||||
- Fixed metadata mirroring authentication errors (#68)
|
||||
- Changed field checking from `username` to `defaultOwner` in metadata functions
|
||||
- Added proper field validation for all metadata operations
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"name": "gitea-mirror",
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^7.0.3",
|
||||
"@astrojs/node": "^11.0.2",
|
||||
"@astrojs/node": "^11.1.5",
|
||||
"@astrojs/react": "^6.0.1",
|
||||
"@better-auth/api-key": "1.7.2",
|
||||
"@better-auth/oauth-provider": "1.7.2",
|
||||
@@ -143,7 +143,7 @@
|
||||
|
||||
"@astrojs/mdx": ["@astrojs/mdx@7.0.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/markdown-remark": "7.2.1", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.16.0", "es-module-lexer": "^2.0.0", "estree-util-visit": "^2.0.0", "hast-util-to-html": "^9.0.5", "piccolore": "^0.1.3", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", "source-map": "^0.7.6", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@astrojs/markdown-satteri": "^0.3.1", "astro": "^7.0.0" }, "optionalPeers": ["@astrojs/markdown-satteri"] }, "sha512-RxyIwU0uFam5ftwqKOjpIdhnFxZ/kEikeimLyQy3eGXbHT8WgRGzzesOIHVU8+m9TY8ag5WVOyvV24/GyqPdPQ=="],
|
||||
|
||||
"@astrojs/node": ["@astrojs/node@11.0.2", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "send": "^1.2.1", "server-destroy": "^1.0.1" }, "peerDependencies": { "astro": "^7.0.0" } }, "sha512-/ijULxT+A5Cm8wSwWZ2vgqfim1b05D6B8n/a9l6MMA4FCotIH73g7fL7y76XojKXpTe75FVvQH92OxsMqea9kQ=="],
|
||||
"@astrojs/node": ["@astrojs/node@11.1.5", "", { "dependencies": { "@astrojs/internal-helpers": "0.11.0", "send": "^1.2.1", "server-destroy": "^1.0.1" }, "peerDependencies": { "astro": "^7.2.1" } }, "sha512-CgA+LmG4UWqHxg/Vdc5MdxjjJNZSjKYQQx4uEUwnbj+1ZD2CYumJI3OWg2twsVdj2FlYsB5yvmCC54Re+OK+UQ=="],
|
||||
|
||||
"@astrojs/prism": ["@astrojs/prism@4.0.2", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA=="],
|
||||
|
||||
@@ -1695,6 +1695,8 @@
|
||||
|
||||
"@astrojs/compiler-binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
|
||||
|
||||
"@astrojs/node/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.11.0", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA=="],
|
||||
|
||||
"@astrojs/react/@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
|
||||
|
||||
"@astrojs/react/vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
|
||||
|
||||
@@ -113,9 +113,9 @@
|
||||
url = "https://registry.npmjs.org/@astrojs/mdx/-/mdx-7.0.3.tgz";
|
||||
hash = "sha512-RxyIwU0uFam5ftwqKOjpIdhnFxZ/kEikeimLyQy3eGXbHT8WgRGzzesOIHVU8+m9TY8ag5WVOyvV24/GyqPdPQ==";
|
||||
};
|
||||
"@astrojs/node@11.0.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@astrojs/node/-/node-11.0.2.tgz";
|
||||
hash = "sha512-/ijULxT+A5Cm8wSwWZ2vgqfim1b05D6B8n/a9l6MMA4FCotIH73g7fL7y76XojKXpTe75FVvQH92OxsMqea9kQ==";
|
||||
"@astrojs/node@11.1.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@astrojs/node/-/node-11.1.5.tgz";
|
||||
hash = "sha512-CgA+LmG4UWqHxg/Vdc5MdxjjJNZSjKYQQx4uEUwnbj+1ZD2CYumJI3OWg2twsVdj2FlYsB5yvmCC54Re+OK+UQ==";
|
||||
};
|
||||
"@astrojs/prism@4.0.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz";
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^7.0.3",
|
||||
"@astrojs/node": "^11.0.2",
|
||||
"@astrojs/node": "^11.1.5",
|
||||
"@astrojs/react": "^6.0.1",
|
||||
"@better-auth/api-key": "1.7.2",
|
||||
"@better-auth/oauth-provider": "1.7.2",
|
||||
|
||||
@@ -20,12 +20,19 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveDestinationSaveValue } from "@/lib/utils/destination-editor";
|
||||
import type { OrganizationMoveResult } from "@/lib/destination-transfer";
|
||||
|
||||
interface MirrorDestinationEditorProps {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
currentDestination?: string;
|
||||
/**
|
||||
* Where this organization's repositories land when it has no override: what
|
||||
* the configured mirror strategy produces. Only the same-named organization
|
||||
* under `preserve`; the destination organization under `single-org` (#416).
|
||||
*/
|
||||
defaultDestination: string;
|
||||
onUpdate: (newDestination: string | null) => Promise<void>;
|
||||
/**
|
||||
* Plan (dryRun) or perform the move of the organization's mirrors on the
|
||||
@@ -75,6 +82,7 @@ export function MirrorDestinationEditor({
|
||||
organizationId,
|
||||
organizationName,
|
||||
currentDestination,
|
||||
defaultDestination,
|
||||
onUpdate,
|
||||
onMoveMirrors,
|
||||
destinationLabel = "Gitea",
|
||||
@@ -91,15 +99,16 @@ export function MirrorDestinationEditor({
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [confirmError, setConfirmError] = useState<string | null>(null);
|
||||
|
||||
const hasOverride = currentDestination && currentDestination !== organizationName;
|
||||
const effectiveDestination = currentDestination || organizationName;
|
||||
// Any stored destination is an override, including one that spells out what
|
||||
// the strategy would have picked anyway. Clearing it is the only way back.
|
||||
const hasOverride = Boolean(currentDestination);
|
||||
const effectiveDestination = currentDestination || defaultDestination;
|
||||
const canMove = typeof onMoveMirrors === "function";
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedValue = editValue.trim();
|
||||
const newDestination = trimmedValue === "" || trimmedValue === organizationName
|
||||
? null
|
||||
: trimmedValue;
|
||||
// The value is passed in rather than read from state so Reset can save the
|
||||
// cleared field right away: setEditValue does not update this closure.
|
||||
const handleSave = async (rawValue: string = editValue) => {
|
||||
const newDestination = resolveDestinationSaveValue(rawValue);
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -160,7 +169,7 @@ export function MirrorDestinationEditor({
|
||||
|
||||
const handleReset = async () => {
|
||||
setEditValue("");
|
||||
await handleSave();
|
||||
await handleSave("");
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -227,7 +236,7 @@ export function MirrorDestinationEditor({
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium text-primary">
|
||||
{editValue.trim() || organizationName}
|
||||
{editValue.trim() || defaultDestination}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,12 +251,12 @@ export function MirrorDestinationEditor({
|
||||
id="destination"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
placeholder={organizationName}
|
||||
placeholder={defaultDestination}
|
||||
className="h-8"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the source organization name.
|
||||
Leave empty to use the default for your mirror strategy ({defaultDestination}).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -280,7 +289,7 @@ export function MirrorDestinationEditor({
|
||||
className="w-full h-8 text-xs"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-2" />
|
||||
Reset to Default ({organizationName})
|
||||
Reset to Default ({defaultDestination})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -297,7 +306,7 @@ export function MirrorDestinationEditor({
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isLoading || (editValue.trim() === (currentDestination || ""))}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -272,6 +272,23 @@ export function OrganizationList({
|
||||
// GitHub and GitLab destinations are pushed to; only Gitea and Forgejo can transfer a repository.
|
||||
const destination = destinationInfo(giteaConfig);
|
||||
|
||||
// Where an organization's repositories land with no override, which is what
|
||||
// the mirror strategy produces for organization repositories (see
|
||||
// getGiteaRepoOwner in src/lib/gitea.ts). Only "preserve" and "mixed" make
|
||||
// that the organization's own name, so the editor cannot assume it (#416).
|
||||
// preserveOrgStructure is not consulted: the config API maps it from
|
||||
// preserveVisibility, so mirrorStrategy is the only reliable signal here.
|
||||
const defaultDestinationFor = (orgName: string): string => {
|
||||
const strategy = giteaConfig?.mirrorStrategy || "preserve";
|
||||
if (strategy === "single-org") {
|
||||
return giteaConfig?.organization || giteaConfig?.username || orgName;
|
||||
}
|
||||
if (strategy === "flat-user") {
|
||||
return giteaConfig?.username || orgName;
|
||||
}
|
||||
return orgName;
|
||||
};
|
||||
|
||||
const hasAnyFilter = Object.values(filter).some(
|
||||
(val) => val?.toString().trim() !== ""
|
||||
);
|
||||
@@ -453,6 +470,7 @@ export function OrganizationList({
|
||||
organizationId={org.id!}
|
||||
organizationName={org.name!}
|
||||
currentDestination={org.destinationOrg ?? undefined}
|
||||
defaultDestination={defaultDestinationFor(org.name!)}
|
||||
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
|
||||
onMoveMirrors={
|
||||
destination.isPushTarget
|
||||
@@ -546,6 +564,7 @@ export function OrganizationList({
|
||||
organizationId={org.id!}
|
||||
organizationName={org.name!}
|
||||
currentDestination={org.destinationOrg ?? undefined}
|
||||
defaultDestination={defaultDestinationFor(org.name!)}
|
||||
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
|
||||
onMoveMirrors={
|
||||
destination.isPushTarget
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { destinationInfo } from "@/components/destination/DestinationIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveDestinationSaveValue } from "@/lib/utils/destination-editor";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
|
||||
export interface DestinationUpdateOptions {
|
||||
@@ -24,6 +25,12 @@ export interface DestinationUpdateOptions {
|
||||
interface InlineDestinationEditorProps {
|
||||
repository: Repository;
|
||||
giteaConfig: any;
|
||||
/**
|
||||
* Destination the repository's organization overrides to, when it sets one.
|
||||
* It outranks the mirror strategy for the organization's repositories, so the
|
||||
* row would otherwise name the strategy's owner instead (#416).
|
||||
*/
|
||||
organizationDestination?: string | null;
|
||||
onUpdate: (
|
||||
repoId: string,
|
||||
newDestination: string | null,
|
||||
@@ -40,6 +47,7 @@ const CANNOT_MOVE_STATUSES = new Set(["imported", "deleted", "deleting", "mirror
|
||||
export function InlineDestinationEditor({
|
||||
repository,
|
||||
giteaConfig,
|
||||
organizationDestination,
|
||||
onUpdate,
|
||||
isUpdating = false,
|
||||
className,
|
||||
@@ -67,6 +75,12 @@ export function InlineDestinationEditor({
|
||||
return "starred";
|
||||
}
|
||||
|
||||
// An organization's own override applies to its repositories whatever the
|
||||
// strategy says, matching getGiteaRepoOwnerAsync's precedence (#416).
|
||||
if (repository.organization && organizationDestination) {
|
||||
return organizationDestination;
|
||||
}
|
||||
|
||||
// Check mirror strategy
|
||||
const strategy = giteaConfig?.mirrorStrategy || 'preserve';
|
||||
|
||||
@@ -93,7 +107,9 @@ export function InlineDestinationEditor({
|
||||
|
||||
const defaultDestination = getDefaultDestination();
|
||||
const currentDestination = repository.destinationOrg || defaultDestination;
|
||||
const hasOverride = repository.destinationOrg && repository.destinationOrg !== defaultDestination;
|
||||
// A stored destination is a pin even when it spells out the default, so the
|
||||
// badge follows the stored value rather than comparing against it (#416).
|
||||
const hasOverride = Boolean(repository.destinationOrg);
|
||||
const isStarredRepo = repository.isStarred;
|
||||
|
||||
const destination = destinationInfo(giteaConfig);
|
||||
@@ -122,7 +138,7 @@ export function InlineDestinationEditor({
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedValue = editValue.trim();
|
||||
const newDestination = trimmedValue === defaultDestination ? null : trimmedValue;
|
||||
const newDestination = resolveDestinationSaveValue(editValue);
|
||||
|
||||
if (trimmedValue === currentDestination) {
|
||||
setIsEditing(false);
|
||||
|
||||
@@ -55,6 +55,12 @@ import { getRepositorySource, normalizeSourceUrl } from "@/lib/source-providers/
|
||||
|
||||
export default function Repository() {
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
// Organization name -> its destination override, from the list response.
|
||||
// Kept beside the rows rather than on them: the action endpoints return the
|
||||
// repositories row alone, and replacing a row would drop the field (#416).
|
||||
const [organizationDestinations, setOrganizationDestinations] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const { user } = useAuth();
|
||||
const { registerRefreshCallback, isLiveEnabled } = useLiveRefresh();
|
||||
@@ -137,6 +143,7 @@ export default function Repository() {
|
||||
|
||||
if (response.success) {
|
||||
setRepositories(response.repositories);
|
||||
setOrganizationDestinations(response.organizationDestinations ?? {});
|
||||
return true;
|
||||
} else {
|
||||
// Only show error toast for manual refreshes to avoid spam during live updates
|
||||
@@ -1580,6 +1587,7 @@ export default function Repository() {
|
||||
) : (
|
||||
<RepositoryTable
|
||||
repositories={repositories}
|
||||
organizationDestinations={organizationDestinations}
|
||||
isLoading={isInitialLoading || !connected}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
|
||||
@@ -68,6 +68,8 @@ export const REPOSITORY_SORT_OPTIONS = [
|
||||
|
||||
interface RepositoryTableProps {
|
||||
repositories: Repository[];
|
||||
/** Organization name -> its destination override (#416). */
|
||||
organizationDestinations?: Record<string, string>;
|
||||
isLoading: boolean;
|
||||
filter: FilterParams;
|
||||
setFilter: (filter: FilterParams) => void;
|
||||
@@ -110,6 +112,7 @@ function getTableSorting(sortOrder: string | undefined): SortingState {
|
||||
|
||||
export default function RepositoryTable({
|
||||
repositories,
|
||||
organizationDestinations,
|
||||
isLoading,
|
||||
filter,
|
||||
setFilter,
|
||||
@@ -1066,6 +1069,11 @@ export default function RepositoryTable({
|
||||
<InlineDestinationEditor
|
||||
repository={repo}
|
||||
giteaConfig={giteaConfig}
|
||||
organizationDestination={
|
||||
repo.organization
|
||||
? organizationDestinations?.[repo.organization]
|
||||
: undefined
|
||||
}
|
||||
onUpdate={handleUpdateDestination}
|
||||
isUpdating={loadingRepoIds.has(repo.id ?? "")}
|
||||
/>
|
||||
|
||||
@@ -424,6 +424,41 @@ describe.skipIf(!isChild)("mirrorGitHubOrgToGitea destination routing (#343)", (
|
||||
expect(orgCreateCalls.filter((n) => n === "hub").length).toBe(1);
|
||||
});
|
||||
|
||||
test("single-org strategy keeps an organization override that equals the organization name (#416)", async () => {
|
||||
const config = makeConfig({
|
||||
githubConfig: { mirrorStrategy: "single-org" },
|
||||
giteaConfig: { organization: "mirrors" },
|
||||
});
|
||||
// The reporter's case: the override spells out the organization's own name,
|
||||
// which single-org would never produce on its own.
|
||||
const organization = makeOrg({ destinationOrg: "A" });
|
||||
orgRepoRows = [makeRepo()];
|
||||
orgConfigRows = [organization];
|
||||
|
||||
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
|
||||
|
||||
const migrates = migrateCalls();
|
||||
expect(migrates.length).toBe(1);
|
||||
expect(migrates[0].payload.uid).toBe(orgIdFor("A"));
|
||||
expect(orgCreateCalls).not.toContain("mirrors");
|
||||
});
|
||||
|
||||
test("a repository override beats an organization override under single-org (#416)", async () => {
|
||||
const config = makeConfig({
|
||||
githubConfig: { mirrorStrategy: "single-org" },
|
||||
giteaConfig: { organization: "mirrors" },
|
||||
});
|
||||
const organization = makeOrg({ destinationOrg: "A" });
|
||||
orgRepoRows = [makeRepo({ destinationOrg: "mirrors" })];
|
||||
orgConfigRows = [organization];
|
||||
|
||||
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
|
||||
|
||||
const migrates = migrateCalls();
|
||||
expect(migrates.length).toBe(1);
|
||||
expect(migrates[0].payload.uid).toBe(orgIdFor("mirrors"));
|
||||
});
|
||||
|
||||
test("regression: flat-user strategy without overrides mirrors to the user account (repo_owner, no org)", async () => {
|
||||
const config = makeConfig({ githubConfig: { mirrorStrategy: "flat-user" } });
|
||||
const organization = makeOrg();
|
||||
|
||||
@@ -594,6 +594,121 @@ describe("getGiteaRepoOwner - Organization Override Tests", () => {
|
||||
expect(result).toBe("custom-org");
|
||||
});
|
||||
|
||||
test("getGiteaRepoOwnerAsync: single-org strategy honors an organization override (#416)", async () => {
|
||||
mockDbSelectResult = [
|
||||
{
|
||||
id: "org-id",
|
||||
userId: "user-id",
|
||||
configId: "config-id",
|
||||
name: "GrapheneOS",
|
||||
membershipRole: "member",
|
||||
status: "imported",
|
||||
destinationOrg: "GrapheneOS",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
isIncluded: true,
|
||||
repositoryCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
];
|
||||
|
||||
const configWithSingleOrg: Partial<Config> = {
|
||||
...baseConfig,
|
||||
userId: "user-id",
|
||||
githubConfig: {
|
||||
...baseConfig.githubConfig!,
|
||||
mirrorStrategy: "single-org"
|
||||
},
|
||||
giteaConfig: {
|
||||
...baseConfig.giteaConfig!,
|
||||
organization: "mirrors"
|
||||
}
|
||||
};
|
||||
|
||||
const repo = { ...baseRepo, organization: "GrapheneOS", fullName: "GrapheneOS/platform_build" };
|
||||
|
||||
const result = await getGiteaRepoOwnerAsync({
|
||||
config: configWithSingleOrg,
|
||||
repository: repo
|
||||
});
|
||||
|
||||
// The override names the organization's own name, which single-org would
|
||||
// never produce: it must not be mistaken for "no override".
|
||||
expect(result).toBe("GrapheneOS");
|
||||
});
|
||||
|
||||
test("getGiteaRepoOwnerAsync: a repository override beats the organization override and single-org (#416)", async () => {
|
||||
mockDbSelectResult = [
|
||||
{
|
||||
id: "org-id",
|
||||
userId: "user-id",
|
||||
configId: "config-id",
|
||||
name: "GrapheneOS",
|
||||
membershipRole: "member",
|
||||
status: "imported",
|
||||
destinationOrg: "GrapheneOS",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
isIncluded: true,
|
||||
repositoryCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
];
|
||||
|
||||
const configWithSingleOrg: Partial<Config> = {
|
||||
...baseConfig,
|
||||
userId: "user-id",
|
||||
githubConfig: {
|
||||
...baseConfig.githubConfig!,
|
||||
mirrorStrategy: "single-org"
|
||||
},
|
||||
giteaConfig: {
|
||||
...baseConfig.giteaConfig!,
|
||||
organization: "mirrors"
|
||||
}
|
||||
};
|
||||
|
||||
const repo = {
|
||||
...baseRepo,
|
||||
organization: "GrapheneOS",
|
||||
fullName: "GrapheneOS/platform_build",
|
||||
destinationOrg: "mirrors"
|
||||
};
|
||||
|
||||
const result = await getGiteaRepoOwnerAsync({
|
||||
config: configWithSingleOrg,
|
||||
repository: repo
|
||||
});
|
||||
|
||||
expect(result).toBe("mirrors");
|
||||
});
|
||||
|
||||
test("getGiteaRepoOwnerAsync: single-org without an organization override uses the configured org (#416)", async () => {
|
||||
mockDbSelectResult = [];
|
||||
|
||||
const configWithSingleOrg: Partial<Config> = {
|
||||
...baseConfig,
|
||||
userId: "user-id",
|
||||
githubConfig: {
|
||||
...baseConfig.githubConfig!,
|
||||
mirrorStrategy: "single-org"
|
||||
},
|
||||
giteaConfig: {
|
||||
...baseConfig.giteaConfig!,
|
||||
organization: "mirrors"
|
||||
}
|
||||
};
|
||||
|
||||
const repo = { ...baseRepo, organization: "GrapheneOS", fullName: "GrapheneOS/platform_build" };
|
||||
|
||||
const result = await getGiteaRepoOwnerAsync({
|
||||
config: configWithSingleOrg,
|
||||
repository: repo
|
||||
});
|
||||
|
||||
expect(result).toBe("mirrors");
|
||||
});
|
||||
|
||||
test("getGiteaRepoOwnerAsync preserves starred owner when preserve-owner mode is enabled", async () => {
|
||||
const configWithUser: Partial<Config> = {
|
||||
...baseConfig,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { resolveDestinationSaveValue } from "./destination-editor";
|
||||
|
||||
describe("resolveDestinationSaveValue (#416)", () => {
|
||||
test("an empty input clears the override", () => {
|
||||
expect(resolveDestinationSaveValue("")).toBeNull();
|
||||
});
|
||||
|
||||
test("whitespace only also clears the override", () => {
|
||||
expect(resolveDestinationSaveValue(" ")).toBeNull();
|
||||
expect(resolveDestinationSaveValue("\t\n ")).toBeNull();
|
||||
});
|
||||
|
||||
test("the organization's own name is kept as an override", () => {
|
||||
// Under single-org the default is the configured destination org, so
|
||||
// "GrapheneOS" on the organization GrapheneOS is a real override.
|
||||
expect(resolveDestinationSaveValue("GrapheneOS")).toBe("GrapheneOS");
|
||||
});
|
||||
|
||||
test("the value the strategy would produce is kept as an override", () => {
|
||||
// Pinning a repository to "mirrors" while single-org already sends
|
||||
// everything there must survive a later strategy change.
|
||||
expect(resolveDestinationSaveValue("mirrors")).toBe("mirrors");
|
||||
});
|
||||
|
||||
test("surrounding whitespace is trimmed", () => {
|
||||
expect(resolveDestinationSaveValue(" GrapheneOS ")).toBe("GrapheneOS");
|
||||
expect(resolveDestinationSaveValue("\tmirrors\n")).toBe("mirrors");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Shared save rule for the destination editors (organizations and repositories).
|
||||
*
|
||||
* A destination override is stored exactly as typed, and only an empty input
|
||||
* clears it. The editors used to collapse a value that happened to equal the
|
||||
* default (the organization's own name, or whatever the mirror strategy would
|
||||
* produce) into `null`, which silently dropped legitimate overrides: under the
|
||||
* `single-org` strategy the default is the configured destination organization,
|
||||
* so typing the source organization's own name is a real override (issue #416).
|
||||
*
|
||||
* Keeping the typed value also makes the pin survive a later strategy change,
|
||||
* which is the point of setting one by hand.
|
||||
*/
|
||||
export function resolveDestinationSaveValue(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, repositories, configs } from "@/lib/db";
|
||||
import { db, repositories, configs, organizations } from "@/lib/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
repositoryVisibilityEnum,
|
||||
@@ -51,9 +51,28 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
.where(and(...conditions))
|
||||
.orderBy(sql`${repositories.importedAt} DESC`, sql`name COLLATE NOCASE`);
|
||||
|
||||
// Organization-level destination overrides. A repository without its own
|
||||
// destinationOrg follows its organization's, so the list ships the map and
|
||||
// the destination column names that instead of the strategy default (#416).
|
||||
const orgDestinationRows = await db
|
||||
.select({
|
||||
name: organizations.name,
|
||||
destinationOrg: organizations.destinationOrg,
|
||||
})
|
||||
.from(organizations)
|
||||
.where(eq(organizations.userId, userId));
|
||||
|
||||
const organizationDestinations: Record<string, string> = {};
|
||||
for (const row of orgDestinationRows) {
|
||||
if (row.destinationOrg) {
|
||||
organizationDestinations[row.name] = row.destinationOrg;
|
||||
}
|
||||
}
|
||||
|
||||
const response: RepositoryApiResponse = {
|
||||
success: true,
|
||||
message: "Repositories fetched successfully",
|
||||
organizationDestinations,
|
||||
repositories: rawRepositories.map((repo) => ({
|
||||
...repo,
|
||||
organization: repo.organization ?? undefined,
|
||||
|
||||
@@ -31,6 +31,13 @@ export interface RepositoryApiSuccessResponse {
|
||||
success: true;
|
||||
message: string;
|
||||
repositories: Repository[];
|
||||
/**
|
||||
* Organization name -> the destination that organization overrides to, for
|
||||
* the organizations that set one. A repository inherits it unless it has its
|
||||
* own destinationOrg, the precedence getGiteaRepoOwnerAsync applies, so the
|
||||
* list needs it to name a repository's real default (#416).
|
||||
*/
|
||||
organizationDestinations?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RepositoryApiErrorResponse {
|
||||
|
||||
Reference in new issue
Block a user