From 4478c32db1d0979d06bb390c1d558eaa647622a9 Mon Sep 17 00:00:00 2001 From: TomRoyls Date: Mon, 7 Sep 2026 02:53:03 +0200 Subject: [PATCH] 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. --- docs/SOURCE_PROVIDERS.md | 1 + .../organizations/AddOrganizationDialog.tsx | 43 +++++++++++++------ src/components/organizations/Organization.tsx | 4 +- .../organizations/OrganizationsList.tsx | 37 +++++++++++----- src/hooks/useConfigStatus.ts | 11 +++++ src/lib/source-providers/gitea-source.test.ts | 28 ++++++++++++ src/lib/source-providers/gitlab.test.ts | 37 ++++++++++++++++ src/lib/source-providers/kinds.test.ts | 16 +++++++ src/lib/source-providers/kinds.ts | 10 +++++ src/pages/api/sync/organization.ts | 13 +++++- 10 files changed, 174 insertions(+), 26 deletions(-) diff --git a/docs/SOURCE_PROVIDERS.md b/docs/SOURCE_PROVIDERS.md index 5746715..e8934dd 100644 --- a/docs/SOURCE_PROVIDERS.md +++ b/docs/SOURCE_PROVIDERS.md @@ -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 | diff --git a/src/components/organizations/AddOrganizationDialog.tsx b/src/components/organizations/AddOrganizationDialog.tsx index 45fd3b0..b2266d5 100644 --- a/src/components/organizations/AddOrganizationDialog.tsx +++ b/src/components/organizations/AddOrganizationDialog.tsx @@ -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; + sourceProvider?: SourceProviderKind; } export default function AddOrganizationDialog({ isDialogOpen, setIsDialogOpen, onAddOrganization, + sourceProvider = "github", }: AddOrganizationDialogProps) { const [url, setUrl] = useState(""); const [org, setOrg] = useState(""); @@ -43,6 +51,11 @@ export default function AddOrganizationDialog({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); + 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({ - Add Organization + Add {orgNounCapitalized} - You can add public organizations + {`You can add public ${orgNoun}s`} @@ -134,7 +147,7 @@ export default function AddOrganizationDialog({ htmlFor="organizationUrl" className="block text-sm font-medium mb-1.5" > - GitHub URL + {providerLabel} URL handleUrlChange(e.target.value)} className={inputClassName} - placeholder="https://github.com/microsoft" + placeholder={urlPlaceholder} autoComplete="off" autoFocus />

{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.`}

@@ -169,7 +182,7 @@ export default function AddOrganizationDialog({ htmlFor="organizationName" className="block text-sm font-medium mb-1.5" > - Organization Name + {orgNounCapitalized} Name 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({ -
- - -
+ {sourceProvider === "github" && ( +
+ + +
+ )} @@ -225,7 +240,7 @@ export default function AddOrganizationDialog({ {isLoading ? ( ) : ( - "Add Organization" + `Add ${orgNounCapitalized}` )} diff --git a/src/components/organizations/Organization.tsx b/src/components/organizations/Organization.tsx index ce23411..c226dfa 100644 --- a/src/components/organizations/Organization.tsx +++ b/src/components/organizations/Organization.tsx @@ -51,7 +51,7 @@ export function Organization() { const [isLoading, setIsLoading] = useState(true); const [isDialogOpen, setIsDialogOpen] = useState(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} /> { diff --git a/src/components/organizations/OrganizationsList.tsx b/src/components/organizations/OrganizationsList.tsx index 6ec9b79..3197937 100644 --- a/src/components/organizations/OrganizationsList.tsx +++ b/src/components/organizations/OrganizationsList.tsx @@ -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; 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(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({

{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.`}

{hasAnyFilter ? ( @@ -759,13 +776,13 @@ export function OrganizationList({ className="rounded-none rounded-r-md" > - - GitHub + + {sourceShortLabel} diff --git a/src/hooks/useConfigStatus.ts b/src/hooks/useConfigStatus.ts index 56f2af3..04425a7 100644 --- a/src/hooks/useConfigStatus.ts +++ b/src/hooks/useConfigStatus.ts @@ -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; } diff --git a/src/lib/source-providers/gitea-source.test.ts b/src/lib/source-providers/gitea-source.test.ts index 14ea3fa..a129f4b 100644 --- a/src/lib/source-providers/gitea-source.test.ts +++ b/src/lib/source-providers/gitea-source.test.ts @@ -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(); diff --git a/src/lib/source-providers/gitlab.test.ts b/src/lib/source-providers/gitlab.test.ts index 7da3ef0..2942e4e 100644 --- a/src/lib/source-providers/gitlab.test.ts +++ b/src/lib/source-providers/gitlab.test.ts @@ -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" })]) diff --git a/src/lib/source-providers/kinds.test.ts b/src/lib/source-providers/kinds.test.ts index 94ab4b8..5a34c10 100644 --- a/src/lib/source-providers/kinds.test.ts +++ b/src/lib/source-providers/kinds.test.ts @@ -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"); diff --git a/src/lib/source-providers/kinds.ts b/src/lib/source-providers/kinds.ts index 4a3b44f..a6be2a4 100644 --- a/src/lib/source-providers/kinds.ts +++ b/src/lib/source-providers/kinds.ts @@ -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. diff --git a/src/pages/api/sync/organization.ts b/src/pages/api/sync/organization.ts index f1c4b7a..0b2282b 100644 --- a/src/pages/api/sync/organization.ts +++ b/src/pages/api/sync/organization.ts @@ -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()