feat(ui): organizations page follows the configured source (#403)

The organizations page and the add dialog now follow the configured source: provider label, instance URL, icon and the organization noun (group on GitLab). The billing manager role only appears for GitHub. Adding an organization whose name contains a slash returns 400 with a hint to use the top level group. New tests cover getOrganization and listOrganizationRepositories on the GitLab and Gitea adapters.
This commit is contained in:
TomRoyls authored and GitHub committed 2026-09-07 06:23:03 +05:30
1 parent 34babd4d8c
commit 4478c32db1
10 files changed
+174 -26

No files matched your search

+1
View File
@@ -36,6 +36,7 @@ The mirror itself is done by Gitea's pull mirror, which treats every source as a
| Cleanup of repositories deleted upstream | yes | yes | yes |
| Starred repositories | yes | yes | yes |
| Private repositories | yes | yes | yes |
| Entire organizations (GitLab: top level groups) | yes | yes | yes |
| Add a single repository by URL | yes | yes | yes |
| Issues, pull requests, labels, milestones | yes | no | no |
| Releases with assets | yes | no | no |
@@ -14,6 +14,12 @@ import type { MembershipRole } from "@/types/organizations";
import { RadioGroup, RadioGroupItem } from "../ui/radio";
import { Label } from "../ui/label";
import { parseGitHubOwnerReference } from "@/lib/utils/github-url";
import {
SOURCE_PROVIDER_DEFAULT_URLS,
SOURCE_PROVIDER_LABELS,
SOURCE_PROVIDER_ORG_NOUNS,
type SourceProviderKind,
} from "@/lib/source-providers/kinds";
const inputClassName =
"w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
@@ -30,12 +36,14 @@ interface AddOrganizationDialogProps {
role: MembershipRole;
force?: boolean;
}) => Promise<void>;
sourceProvider?: SourceProviderKind;
}
export default function AddOrganizationDialog({
isDialogOpen,
setIsDialogOpen,
onAddOrganization,
sourceProvider = "github",
}: AddOrganizationDialogProps) {
const [url, setUrl] = useState<string>("");
const [org, setOrg] = useState<string>("");
@@ -43,6 +51,11 @@ export default function AddOrganizationDialog({
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const providerLabel = SOURCE_PROVIDER_LABELS[sourceProvider];
const orgNoun = SOURCE_PROVIDER_ORG_NOUNS[sourceProvider];
const orgNounCapitalized = orgNoun.charAt(0).toUpperCase() + orgNoun.slice(1);
const urlPlaceholder = `${SOURCE_PROVIDER_DEFAULT_URLS[sourceProvider]}/your-${orgNoun}`;
const resetForm = () => {
setError("");
setUrl("");
@@ -91,8 +104,8 @@ export default function AddOrganizationDialog({
if (!org || org.trim() === "") {
setError(
urlIsUnparsed
? "That does not look like a GitHub organization URL."
: "Please enter a valid organization name."
? `That does not look like a ${providerLabel} ${orgNoun} URL.`
: `Please enter a valid ${orgNoun} name.`
);
return;
}
@@ -121,9 +134,9 @@ export default function AddOrganizationDialog({
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-[425px] gap-0 gap-y-6 mx-4 sm:mx-0">
<DialogHeader>
<DialogTitle>Add Organization</DialogTitle>
<DialogTitle>Add {orgNounCapitalized}</DialogTitle>
<DialogDescription>
You can add public organizations
{`You can add public ${orgNoun}s`}
</DialogDescription>
</DialogHeader>
@@ -134,7 +147,7 @@ export default function AddOrganizationDialog({
htmlFor="organizationUrl"
className="block text-sm font-medium mb-1.5"
>
GitHub URL
{providerLabel} URL
</label>
<input
id="organizationUrl"
@@ -142,14 +155,14 @@ export default function AddOrganizationDialog({
value={url}
onChange={(e) => handleUrlChange(e.target.value)}
className={inputClassName}
placeholder="https://github.com/microsoft"
placeholder={urlPlaceholder}
autoComplete="off"
autoFocus
/>
<p className="mt-1.5 text-xs text-muted-foreground">
{urlIsUnparsed
? "Could not read an organization from that."
: "Paste an organization URL and the name below fills in."}
: `Paste a ${orgNoun} URL and the name below fills in.`}
</p>
</div>
@@ -169,7 +182,7 @@ export default function AddOrganizationDialog({
htmlFor="organizationName"
className="block text-sm font-medium mb-1.5"
>
Organization Name
{orgNounCapitalized} Name
</label>
<input
id="organizationName"
@@ -178,7 +191,7 @@ export default function AddOrganizationDialog({
onChange={(e) => setOrg(e.target.value)}
onPaste={handleReferencePaste}
className={inputClassName}
placeholder="e.g., microsoft"
placeholder={`e.g., your-${orgNoun}`}
autoComplete="off"
required
/>
@@ -202,10 +215,12 @@ export default function AddOrganizationDialog({
<RadioGroupItem value="admin" id="r2" />
<Label htmlFor="r2">Admin</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="billing_manager" id="r3" />
<Label htmlFor="r3">Billing Manager</Label>
</div>
{sourceProvider === "github" && (
<div className="flex items-center space-x-2">
<RadioGroupItem value="billing_manager" id="r3" />
<Label htmlFor="r3">Billing Manager</Label>
</div>
)}
</RadioGroup>
</div>
@@ -225,7 +240,7 @@ export default function AddOrganizationDialog({
{isLoading ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
"Add Organization"
`Add ${orgNounCapitalized}`
)}
</Button>
</div>
@@ -51,7 +51,7 @@ export function Organization() {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const { user } = useAuth();
const { isGitHubConfigured } = useConfigStatus();
const { isGitHubConfigured, sourceProvider } = useConfigStatus();
const { navigationKey } = useNavigation();
const { registerRefreshCallback } = useLiveRefresh();
const { filter, setFilter } = useFilterParams({
@@ -886,12 +886,14 @@ export function Organization() {
onRefresh={async () => {
await fetchOrganizations(false);
}}
sourceProvider={sourceProvider}
/>
<AddOrganizationDialog
onAddOrganization={handleAddOrganization}
isDialogOpen={isDialogOpen}
setIsDialogOpen={setIsDialogOpen}
sourceProvider={sourceProvider}
/>
<Dialog open={isDuplicateOrgDialogOpen} onOpenChange={(open) => {
@@ -3,7 +3,7 @@ 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, SlidersHorizontal, Trash2 } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si";
import { SiGithub, SiGitea, SiGitlab } from "react-icons/si";
import type { MirrorOverrides, Organization } from "@/lib/db/schema";
import type { FilterParams } from "@/types/filter";
import Fuse from "fuse.js";
@@ -16,7 +16,14 @@ import type { OrganizationMoveResult } from "@/lib/destination-transfer";
import { MirrorOverridesDialog } from "@/components/config/MirrorOverridesDialog";
import { hasMirrorOverrides, mirrorOptionsToFlags } from "@/lib/utils/mirror-overrides";
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { getCachedConfig } from "@/hooks/useConfigStatus";
import { withBase } from "@/lib/base-path";
import {
SOURCE_PROVIDER_LABELS,
SOURCE_PROVIDER_ORG_NOUNS,
normalizeSourceUrl,
type SourceProviderKind,
} from "@/lib/source-providers/kinds";
import {
DropdownMenu,
DropdownMenuContent,
@@ -37,6 +44,7 @@ interface OrganizationListProps {
onAddOrganization?: () => void;
onRefresh?: () => Promise<void>;
onDelete?: (orgId: string) => void;
sourceProvider?: SourceProviderKind;
}
// Helper function to get status badge variant and icon
@@ -68,10 +76,19 @@ export function OrganizationList({
onAddOrganization,
onRefresh,
onDelete,
sourceProvider = "github",
}: OrganizationListProps) {
const { giteaConfig, mirrorOptions, advancedOptions } = useGiteaConfig();
const [overridesTarget, setOverridesTarget] = useState<Organization | null>(null);
const sourceLabel = SOURCE_PROVIDER_LABELS[sourceProvider];
const sourceUrl = normalizeSourceUrl(
getCachedConfig()?.githubConfig?.url,
sourceProvider
);
const sourceShortLabel = sourceProvider === "gitea" ? "Gitea" : sourceLabel;
const SourceIcon = { github: SiGithub, gitlab: SiGitlab, gitea: SiGitea }[sourceProvider];
const handleUpdateMirrorOverrides = async (
orgId: string,
overrides: MirrorOverrides | null
@@ -206,7 +223,7 @@ export function OrganizationList({
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
{hasAnyFilter
? "Try adjusting your search or filter criteria."
: "Add GitHub organizations to mirror their repositories."}
: `Add ${SOURCE_PROVIDER_LABELS[sourceProvider]} ${SOURCE_PROVIDER_ORG_NOUNS[sourceProvider]}s to mirror their repositories.`}
</p>
{hasAnyFilter ? (
<Button
@@ -583,14 +600,14 @@ export function OrganizationList({
})()}
<Button variant="outline" size="default" asChild className="flex-1 h-10 min-w-0">
<a
href={`https://github.com/${org.name}`}
href={`${sourceUrl}/${org.name}`}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
title={`View on ${sourceLabel}`}
className="flex items-center justify-center gap-2"
>
<SiGithub className="h-4 w-4 flex-shrink-0" />
<span className="text-xs">GitHub</span>
<SourceIcon className="h-4 w-4 flex-shrink-0" />
<span className="text-xs">{sourceShortLabel}</span>
</a>
</Button>
</div>
@@ -759,13 +776,13 @@ export function OrganizationList({
className="rounded-none rounded-r-md"
>
<a
href={`https://github.com/${org.name}`}
href={`${sourceUrl}/${org.name}`}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
title={`View on ${sourceLabel}`}
>
<SiGithub className="h-4 w-4 mr-2" />
GitHub
<SourceIcon className="h-4 w-4 mr-2" />
{sourceShortLabel}
</a>
</Button>
</div>
+11
View File
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useState, useRef } from 'react';
import { useAuth } from './useAuth';
import { apiRequest } from '@/lib/utils';
import type { ConfigApiResponse } from '@/types/config';
import {
DEFAULT_SOURCE_PROVIDER,
normalizeSourceProviderKind,
type SourceProviderKind,
} from '@/lib/source-providers/kinds';
interface ConfigStatus {
isGitHubConfigured: boolean;
@@ -11,6 +16,7 @@ interface ConfigStatus {
error: string | null;
autoMirrorStarred: boolean;
githubOwner: string;
sourceProvider: SourceProviderKind;
}
// Cache to prevent duplicate API calls across components
@@ -37,6 +43,7 @@ export function useConfigStatus(): ConfigStatus {
error: null,
autoMirrorStarred: false,
githubOwner: '',
sourceProvider: DEFAULT_SOURCE_PROVIDER,
});
// Track if this hook has already checked config to prevent multiple calls
@@ -52,6 +59,7 @@ export function useConfigStatus(): ConfigStatus {
error: 'No user found',
autoMirrorStarred: false,
githubOwner: '',
sourceProvider: DEFAULT_SOURCE_PROVIDER,
});
return;
}
@@ -88,6 +96,7 @@ export function useConfigStatus(): ConfigStatus {
error: null,
autoMirrorStarred: configResponse?.advancedOptions?.autoMirrorStarred ?? false,
githubOwner: configResponse?.githubConfig?.username ?? '',
sourceProvider: normalizeSourceProviderKind(configResponse?.githubConfig?.provider),
});
return;
}
@@ -133,6 +142,7 @@ export function useConfigStatus(): ConfigStatus {
error: null,
autoMirrorStarred: configResponse?.advancedOptions?.autoMirrorStarred ?? false,
githubOwner: configResponse?.githubConfig?.username ?? '',
sourceProvider: normalizeSourceProviderKind(configResponse?.githubConfig?.provider),
});
hasCheckedRef.current = true;
@@ -145,6 +155,7 @@ export function useConfigStatus(): ConfigStatus {
error: error instanceof Error ? error.message : 'Failed to check configuration',
autoMirrorStarred: false,
githubOwner: '',
sourceProvider: DEFAULT_SOURCE_PROVIDER,
});
hasCheckedRef.current = true;
}
@@ -191,6 +191,34 @@ describe("GiteaSourceProvider", () => {
]);
});
test("getOrganization reads an org with its repo count and returns null on 404", async () => {
installFetch((url) => {
if (url.endsWith("/api/v1/orgs/acme")) return json({ username: "acme", avatar_url: "https://a/acme.png" });
if (url.includes("/api/v1/orgs/acme/repos?limit=1")) return json([{}], { headers: { "x-total-count": "7" } });
if (url.endsWith("/api/v1/orgs/missing")) return json({ message: "404" }, { status: 404 });
return json({ message: "unexpected" }, { status: 500 });
});
const provider = new GiteaSourceProvider(connection);
const org = await provider.getOrganization("acme");
expect(org).toEqual(expect.objectContaining({ name: "acme", repositoryCount: 7 }));
expect(await provider.getOrganization("missing")).toBeNull();
});
test("listOrganizationRepositories pages through the organization's repositories", async () => {
installFetch((url) => {
if (url.endsWith("&page=1")) return json([repo({ name: "tool", owner: "acme" })], { headers: { "x-total-count": "1" } });
return json({ message: "unexpected" }, { status: 500 });
});
const repos = await new GiteaSourceProvider(connection).listOrganizationRepositories("acme");
expect(calls[0].url).toBe("https://codeberg.org/api/v1/orgs/acme/repos?limit=50&page=1");
expect(repos.map((r) => r.fullName)).toEqual(["acme/tool"]);
expect(repos[0].organization).toBe("acme");
expect(repos[0].sourceProvider).toBe("gitea");
});
test("testConnection reads /user", async () => {
installFetch(() => json({ login: "me", full_name: "Me", avatar_url: "https://a/me.png" }));
const account = await new GiteaSourceProvider(connection).testConnection();
+37
View File
@@ -217,6 +217,43 @@ describe("GitLabSourceProvider", () => {
expect(failedOrgs).toEqual([]);
});
test("getOrganization reads a group by path and counts its projects, null on 404", async () => {
installFetch((url) => {
if (url.endsWith("/api/v4/groups/acme")) return json({ id: 7, full_path: "acme" });
if (url.includes("/api/v4/groups/7/projects?")) return json([{}], { headers: { "x-total": "12" } });
if (url.endsWith("/api/v4/groups/missing")) return json({ message: "404 Not Found" }, { status: 404 });
return json({ message: "unexpected" }, { status: 500 });
});
const provider = new GitLabSourceProvider(connection);
const org = await provider.getOrganization("acme");
expect(org?.name).toBe("acme");
expect(org?.repositoryCount).toBe(12);
expect(await provider.getOrganization("missing")).toBeNull();
});
test("listOrganizationRepositories includes subgroups and flattens them onto the group", async () => {
installFetch((url) => {
if (url.includes("/api/v4/groups/acme/projects?")) {
return json([
project({
path: "tool",
path_with_namespace: "acme/tools/tool",
namespace: { kind: "group", path: "tools", full_path: "acme/tools" },
}),
]);
}
return json({ message: "unexpected" }, { status: 500 });
});
const repos = await new GitLabSourceProvider(connection).listOrganizationRepositories("acme");
expect(calls[0].url).toContain("include_subgroups=true");
expect(repos.map((r) => r.fullName)).toEqual(["acme/tools/tool"]);
expect(repos[0].organization).toBe("acme");
expect(repos[0].sourceProvider).toBe("gitlab");
});
test("isRepositoryStarred matches the full path among starred search results", async () => {
installFetch(() =>
json([project({ path_with_namespace: "other/widget" }), project({ path_with_namespace: "Acme/Widget" })])
+16
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import {
isBetaSourceProvider,
isValidSourceOrgName,
SOURCE_PROVIDER_DEFAULT_URLS,
getRepositorySource,
isRepositoryFromConfiguredSource,
@@ -19,6 +20,21 @@ describe("isBetaSourceProvider", () => {
});
});
describe("isValidSourceOrgName", () => {
test("accepts plain org and group names", () => {
expect(isValidSourceOrgName("acme")).toBe(true);
expect(isValidSourceOrgName(" acme ")).toBe(true);
expect(isValidSourceOrgName("my-org")).toBe(true);
});
test("rejects nested paths and empty names", () => {
expect(isValidSourceOrgName("acme/tools")).toBe(false);
expect(isValidSourceOrgName("group/subgroup/deep")).toBe(false);
expect(isValidSourceOrgName("/acme")).toBe(false);
expect(isValidSourceOrgName(" ")).toBe(false);
});
});
describe("normalizeSourceProviderKind", () => {
test("accepts the three kinds and defaults everything else to GitHub", () => {
expect(normalizeSourceProviderKind("github")).toBe("github");
+10
View File
@@ -43,6 +43,16 @@ export function isSourceProviderKind(value: unknown): value is SourceProviderKin
);
}
/**
* Org names are a single path segment on every source. GitLab nests groups,
* but projects flatten onto the top level group, so a nested name would
* import an organization whose repositories never match it.
*/
export function isValidSourceOrgName(name: string): boolean {
const trimmed = name.trim();
return trimmed.length > 0 && !trimmed.includes("/");
}
/**
* Coerce an untrusted value to a provider kind, defaulting to GitHub.
* "forgejo" and "codeberg" are accepted as spellings of the Gitea kind.
+12 -1
View File
@@ -8,7 +8,7 @@ import type {
} from "@/types/organizations";
import type { RepoStatus } from "@/types/Repository";
import { v4 as uuidv4 } from "uuid";
import { createSourceProviderFromConfig } from "@/lib/source-providers";
import { createSourceProviderFromConfig, isValidSourceOrgName } from "@/lib/source-providers";
import { normalizeGitRepoToInsert, calcBatchSizeForInsert } from "@/lib/repo-utils";
import { resolveOrganizationSkipForks } from "@/lib/utils/mirror-overrides";
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
@@ -32,6 +32,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
const trimmedOrg = org.trim();
const normalizedOrg = trimmedOrg.toLowerCase();
if (!isValidSourceOrgName(trimmedOrg)) {
return jsonResponse({
data: {
success: false,
error:
"Organization names cannot contain '/'. Add the top level group; nested groups flatten onto it.",
},
status: 400,
});
}
// Check if org already exists (case-insensitive)
const [existingOrg] = await db
.select()