feat(orgs): per-organization source selection (#407)

Organizations can pin the source they mirror from when more than one source is connected.

- New nullable organizations.source_id column (migration 0020_org_source), backfilled only when an organization's repositories agree on exactly one source.
- NULL means every source; a pin whose source was deleted falls back to the same. A valid pin scopes the org mirror job, the repository count breakdowns and org deletion to that source.
- POST /api/sync/organization persists the pin, PATCH /api/organizations/:id sets or clears it, the bulk import stamps discovered orgs and clears the pin when a second source lists the same org.
- UI, only with more than one source connected: a Source picker in the Add Organization dialog, a per-card inline source editor with an Every source option, and per-org View on source links.
- The org mirror repository query now filters by user.
- Shared provider icon map in src/lib/source-providers/icons.ts.
This commit is contained in:
TomRoyls authored and GitHub committed 2026-09-08 19:13:32 +05:30
1 parent 3a2f8b34ab
commit a74b7e54ed
26 files changed
+4042 -43

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

+4
View File
@@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Per-organization source selection for multi-source accounts
- Organizations remember which source they import and mirror from; migration 0020 backfills the pin from each organization's repositories when they agree on one
- Source picker in the add-organization dialog and on organization cards when more than one source is connected
- `POST /api/sync/organization` persists `sourceId`, and `PATCH /api/organizations/:id` sets or clears it
- Multi-source support: connect multiple source services per user (#375 follow-up)
- Sources list on the Configuration page with add, edit and remove per source
- Per-repository source attribution, with credentials resolved from each repository's own source
+8
View File
@@ -117,6 +117,14 @@ Starts mirroring in the background and returns straight away. Poll the list endp
`role` is the account's role in that organization (`member`, `admin`, `owner` or `billing_manager`). `409` means it is already tracked; `force: true` refreshes it. Then `POST /api/job/mirror-org` with `{ "organizationIds": ["..."] }` mirrors its repositories.
When several sources are connected, an optional `sourceId` picks which one the organization imports and mirrors from (the first connected source is the default):
```json
{ "org": "my-org", "role": "member", "sourceId": "…" }
```
`PATCH /api/organizations/:id` with `{ "sourceId": "…" }` re-pins an existing organization to another source, and `{ "sourceId": null }` clears the pin so the organization follows every source's repositories of the same name again. A pin whose source was removed falls back the same way.
### Reconcile the destination
`POST /api/cleanup/reconcile`
+15
View File
@@ -0,0 +1,15 @@
ALTER TABLE `organizations` ADD `source_id` text;--> statement-breakpoint
-- Pin each organization to the source its repositories came from, but only
-- when they agree on exactly one: organizations whose repos span several
-- sources (or have none) stay unpinned and keep following every source.
-- MIN() keeps the subquery scalar; a HAVING filter with no matching row
-- yields NULL, which leaves the column untouched.
UPDATE `organizations` SET `source_id` = (
SELECT MIN(`repositories`.`source_id`)
FROM `repositories`
WHERE `repositories`.`user_id` = `organizations`.`user_id`
AND `repositories`.`organization` = `organizations`.`name`
AND `repositories`.`source_id` IS NOT NULL
HAVING COUNT(DISTINCT `repositories`.`source_id`) = 1
)
WHERE `source_id` IS NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -141,6 +141,13 @@
"when": 1788350408053,
"tag": "0019_multi_source",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1788805724170,
"tag": "0020_org_source",
"breakpoints": true
}
]
}
+64 -1
View File
@@ -957,12 +957,12 @@ function validateStranded0016Repair() {
db.close();
}
}
const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
"0009_nervous_tyger_tiger": {
seed: seedPre0009Database,
verify: verify0009Migration,
},
"0010_mirrored_location_index": {
seed: seedPre0010Database,
verify: verify0010Migration,
@@ -1003,8 +1003,71 @@ const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
seed: seedPre0019Database,
verify: verify0019Migration,
},
"0020_org_source": {
seed: seedPre0020Database,
verify: verify0020Migration,
},
};
function seedPre0020Database(db: any) {
// Migrations 0000-0019 have run: repositories carry source_id,
// organizations have none yet. Seed two sources and three organizations:
// one whose repos all come from a single source (must be pinned), one
// whose repos span both sources (must stay unpinned), and one without
// repos (must stay unpinned).
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-org20', 'org20@example.com', 'org20', 'Org Twenty')");
db.run(
"INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " +
"VALUES ('cfg-org20', 'u-org20', 'Default', 1, '{\"provider\":\"github\",\"owner\":\"octocat\",\"token\":\"gh-token\",\"type\":\"personal\"}', '{}', '{}', '{}')",
);
db.run(
"INSERT INTO sources (id, user_id, name, provider, url, username) VALUES ('src-a20', 'u-org20', 'GitHub (octocat)', 'github', 'https://github.com', 'octocat')",
);
db.run(
"INSERT INTO sources (id, user_id, name, provider, url, username) VALUES ('src-b20', 'u-org20', 'GitLab (octocat)', 'gitlab', 'https://gitlab.com', 'octocat')",
);
const insertOrg = (id: string, name: string) =>
db.run(
"INSERT INTO organizations (id, user_id, config_id, name, normalized_name, avatar_url) " +
`VALUES ('${id}', 'u-org20', 'cfg-org20', '${name}', '${name.toLowerCase()}', 'https://example.com/${name}.png')`,
);
insertOrg("org-acme20", "acme");
insertOrg("org-mixed20", "mixed");
insertOrg("org-empty20", "empty");
const insertRepo = (id: string, org: string, sourceId: string | null) =>
db.run(
"INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, organization, source_id) " +
`VALUES ('${id}', 'u-org20', 'cfg-org20', '${id}', '${org}/${id}', '${org}/${id}', 'https://example.com/${org}/${id}', 'https://example.com/${org}/${id}.git', '${org}', 'main', '${org}', ${sourceId ? `'${sourceId}'` : "NULL"})`,
);
insertRepo("repo-acme1-20", "acme", "src-a20");
insertRepo("repo-acme2-20", "acme", "src-a20");
insertRepo("repo-mixed1-20", "mixed", "src-a20");
insertRepo("repo-mixed2-20", "mixed", "src-b20");
}
function verify0020Migration(db: any) {
const orgCols = db.query("PRAGMA table_info(organizations)").all() as TableInfoRow[];
const sourceIdCol = orgCols.find((column) => column.name === "source_id");
assert(sourceIdCol, "Expected organizations.source_id column to exist after migration");
assert(sourceIdCol.notnull === 0, "Expected organizations.source_id to be nullable");
assert(sourceIdCol.dflt_value === null, `Expected organizations.source_id to have no default, got ${sourceIdCol.dflt_value}`);
const byName = Object.fromEntries(
(db.query("SELECT name, source_id FROM organizations").all() as Array<{ name: string; source_id: string | null }>).map(
(row) => [row.name, row.source_id],
),
);
assert(byName["acme"] === "src-a20", `Expected the single-source organization to be pinned, got ${JSON.stringify(byName["acme"])}`);
assert(byName["mixed"] === null, `Expected the multi-source organization to stay unpinned, got ${JSON.stringify(byName["mixed"])}`);
assert(byName["empty"] === null, `Expected the repo-less organization to stay unpinned, got ${JSON.stringify(byName["empty"])}`);
db.run("UPDATE organizations SET source_id = 'src-b20' WHERE name = 'acme'");
const repinned = db.query("SELECT source_id FROM organizations WHERE name = 'acme'").get() as { source_id: string | null } | null;
assert(repinned?.source_id === "src-b20", `Expected a manual pin to round-trip, got ${JSON.stringify(repinned)}`);
}
function lintMigrations(selectedMigrations: Migration[]) {
const violations: string[] = [];
@@ -10,16 +10,27 @@ import {
DialogTrigger,
} from "../ui/dialog";
import { LoaderCircle, Plus } from "lucide-react";
import { SiGithub } from "react-icons/si";
import type { MembershipRole } from "@/types/organizations";
import type { SourceApiRecord } from "@/types/config";
import { RadioGroup, RadioGroupItem } from "../ui/radio";
import { Label } from "../ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { parseGitHubOwnerReference } from "@/lib/utils/github-url";
import {
SOURCE_PROVIDER_DEFAULT_URLS,
SOURCE_PROVIDER_LABELS,
SOURCE_PROVIDER_ORG_NOUNS,
normalizeSourceUrl,
type SourceProviderKind,
} from "@/lib/source-providers/kinds";
import { SOURCE_PROVIDER_ICONS } from "@/lib/source-providers/icons";
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";
@@ -31,14 +42,18 @@ interface AddOrganizationDialogProps {
org,
role,
force,
sourceId,
}: {
org: string;
role: MembershipRole;
force?: boolean;
sourceId?: string;
}) => Promise<void>;
sourceProvider?: SourceProviderKind;
/** Normalized instance URL of the configured source, for the placeholder. */
sourceUrl?: string;
/** Connected sources; with more than one, the dialog asks which one to add from. */
sources?: SourceApiRecord[];
}
export default function AddOrganizationDialog({
@@ -47,19 +62,35 @@ export default function AddOrganizationDialog({
onAddOrganization,
sourceProvider = "github",
sourceUrl,
sources,
}: AddOrganizationDialogProps) {
const [url, setUrl] = useState<string>("");
const [org, setOrg] = useState<string>("");
const [role, setRole] = useState<MembershipRole>("member");
const [sourceId, setSourceId] = useState<string>("");
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 hasMultipleSources = !!sources && sources.length > 1;
// With several connected sources, the chosen one drives every
// provider-dependent label; otherwise the global source props do.
const selectedSource = hasMultipleSources
? sources.find((source) => source.id === sourceId) ?? sources[0]
: undefined;
const effectiveProvider: SourceProviderKind =
selectedSource?.provider ?? sourceProvider;
const effectiveSourceUrl = selectedSource
? normalizeSourceUrl(selectedSource.url, selectedSource.provider)
: sourceUrl;
const providerLabel = SOURCE_PROVIDER_LABELS[effectiveProvider];
const orgNoun = SOURCE_PROVIDER_ORG_NOUNS[effectiveProvider];
const orgNounCapitalized = orgNoun.charAt(0).toUpperCase() + orgNoun.slice(1);
// "an organization" but "a group".
const orgNounWithArticle = `${/^[aeiou]/i.test(orgNoun) ? "an" : "a"} ${orgNoun}`;
const instanceUrl = (sourceUrl || SOURCE_PROVIDER_DEFAULT_URLS[sourceProvider]).replace(/\/+$/, "");
const instanceUrl = (
effectiveSourceUrl || SOURCE_PROVIDER_DEFAULT_URLS[effectiveProvider]
).replace(/\/+$/, "");
const urlPlaceholder = `${instanceUrl}/your-${orgNoun}`;
const resetForm = () => {
@@ -67,6 +98,7 @@ export default function AddOrganizationDialog({
setUrl("");
setOrg("");
setRole("member");
setSourceId("");
};
useEffect(() => {
@@ -75,6 +107,17 @@ export default function AddOrganizationDialog({
}
}, [isDialogOpen]);
// Default the picker to the primary source (oldest first) whenever the
// selection is empty or points at a removed source.
useEffect(() => {
if (!hasMultipleSources) {
return;
}
if (!sources.some((source) => source.id === sourceId)) {
setSourceId(sources[0].id);
}
}, [hasMultipleSources, sources, sourceId]);
/** Fill the name field from anything that names an account. */
const applyReference = (value: string): boolean => {
const parsed = parseGitHubOwnerReference(value);
@@ -119,7 +162,11 @@ export default function AddOrganizationDialog({
try {
setIsLoading(true);
await onAddOrganization({ org, role });
await onAddOrganization({
org,
role,
sourceId: selectedSource?.id,
});
resetForm();
setIsDialogOpen(false);
@@ -148,6 +195,36 @@ export default function AddOrganizationDialog({
<form onSubmit={handleSubmit} className="flex flex-col gap-y-6">
<div className="space-y-4">
{hasMultipleSources && (
<div>
<label
htmlFor="organizationSource"
className="block text-sm font-medium mb-1.5"
>
Source
</label>
<Select value={sourceId} onValueChange={setSourceId}>
<SelectTrigger id="organizationSource" className="w-full">
<SelectValue placeholder="Select source" />
</SelectTrigger>
<SelectContent>
{sources.map((source) => {
const SourceIcon =
SOURCE_PROVIDER_ICONS[source.provider] ?? SiGithub;
return (
<SelectItem key={source.id} value={source.id}>
<span className="flex items-center gap-2">
<SourceIcon className="h-3.5 w-3.5" />
{source.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
)}
<div>
<label
htmlFor="organizationUrl"
@@ -221,7 +298,7 @@ export default function AddOrganizationDialog({
<RadioGroupItem value="admin" id="r2" />
<Label htmlFor="r2">Admin</Label>
</div>
{sourceProvider === "github" && (
{effectiveProvider === "github" && (
<div className="flex items-center space-x-2">
<RadioGroupItem value="billing_manager" id="r3" />
<Label htmlFor="r3">Billing Manager</Label>
@@ -51,7 +51,7 @@ export function Organization() {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const { user } = useAuth();
const { isGitHubConfigured, sourceProvider, sourceUrl } = useConfigStatus();
const { isGitHubConfigured, sourceProvider, sourceUrl, sources } = useConfigStatus();
const { navigationKey } = useNavigation();
const { registerRefreshCallback } = useLiveRefresh();
const { filter, setFilter } = useFilterParams({
@@ -63,6 +63,7 @@ export function Organization() {
const [duplicateOrgCandidate, setDuplicateOrgCandidate] = useState<{
org: string;
role: MembershipRole;
sourceId?: string;
} | null>(null);
const [isDuplicateOrgDialogOpen, setIsDuplicateOrgDialogOpen] = useState(false);
const [isProcessingDuplicateOrg, setIsProcessingDuplicateOrg] = useState(false);
@@ -274,10 +275,12 @@ export function Organization() {
org,
role,
force = false,
sourceId,
}: {
org: string;
role: MembershipRole;
force?: boolean;
sourceId?: string;
}) => {
if (!user || !user.id) {
return;
@@ -298,7 +301,7 @@ export function Organization() {
if (alreadyExists) {
toast.warning("Organization already exists.");
setDuplicateOrgCandidate({ org: trimmedOrg, role });
setDuplicateOrgCandidate({ org: trimmedOrg, role, sourceId });
setIsDuplicateOrgDialogOpen(true);
throw new Error("Organization already exists");
}
@@ -312,6 +315,7 @@ export function Organization() {
org: trimmedOrg,
role,
force,
sourceId,
};
const response = await apiRequest<AddOrganizationApiResponse>(
@@ -360,6 +364,7 @@ export function Organization() {
await handleAddOrganization({
org: duplicateOrgCandidate.org,
role: duplicateOrgCandidate.role,
sourceId: duplicateOrgCandidate.sourceId,
force: true,
});
setIsDialogOpen(false);
@@ -888,6 +893,7 @@ export function Organization() {
}}
sourceProvider={sourceProvider}
sourceUrl={sourceUrl}
sources={sources}
/>
<AddOrganizationDialog
@@ -896,6 +902,7 @@ export function Organization() {
setIsDialogOpen={setIsDialogOpen}
sourceProvider={sourceProvider}
sourceUrl={sourceUrl}
sources={sources}
/>
<Dialog open={isDuplicateOrgDialogOpen} onOpenChange={(open) => {
@@ -2,10 +2,12 @@ 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, SlidersHorizontal, Trash2 } from "lucide-react";
import { SiGithub, SiGitea, SiGitlab } from "react-icons/si";
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, SlidersHorizontal, Trash2, Layers } from "lucide-react";
import { SiGitea } from "react-icons/si";
import { toast } from "sonner";
import type { MirrorOverrides, Organization } from "@/lib/db/schema";
import type { FilterParams } from "@/types/filter";
import type { SourceApiRecord } from "@/types/config";
import Fuse from "fuse.js";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
@@ -24,6 +26,14 @@ import {
normalizeSourceUrl,
type SourceProviderKind,
} from "@/lib/source-providers/kinds";
import { SOURCE_PROVIDER_ICONS } from "@/lib/source-providers/icons";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
@@ -47,6 +57,8 @@ interface OrganizationListProps {
sourceProvider?: SourceProviderKind;
/** Normalized instance URL of the configured source; falls back to the cached config. */
sourceUrl?: string;
/** Connected sources; with more than one, each card shows and edits its own source. */
sources?: SourceApiRecord[];
}
// Helper function to get status badge variant and icon
@@ -67,6 +79,67 @@ const getStatusBadge = (status: string | null) => {
}
};
// Radix reserves the empty string as a Select value, so unpinned
// organizations use this sentinel instead.
const EVERY_SOURCE_VALUE = "every-source";
function OrganizationSourceEditor({
sources,
value,
disabled = false,
onUpdateSource,
}: {
sources: SourceApiRecord[];
value?: string | null;
disabled?: boolean;
onUpdateSource: (sourceId: string | null) => Promise<void>;
}) {
const [isUpdating, setIsUpdating] = useState(false);
const handleValueChange = async (next: string) => {
setIsUpdating(true);
try {
await onUpdateSource(next === EVERY_SOURCE_VALUE ? null : next);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to update source");
} finally {
setIsUpdating(false);
}
};
return (
<div className="flex items-center gap-2 w-full">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
<Layers className="h-3 w-3" />
Source
</span>
<Select
value={value ?? EVERY_SOURCE_VALUE}
onValueChange={(next) => void handleValueChange(next)}
disabled={disabled || isUpdating}
>
<SelectTrigger className="h-8 min-w-0 flex-1 text-xs">
<SelectValue placeholder="Source" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EVERY_SOURCE_VALUE}>Every source</SelectItem>
{sources.map((source) => {
const SourceIcon = SOURCE_PROVIDER_ICONS[source.provider];
return (
<SelectItem key={source.id} value={source.id}>
<span className="flex items-center gap-2">
<SourceIcon className="h-3.5 w-3.5" />
{source.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
);
}
export function OrganizationList({
organizations,
isLoading,
@@ -80,16 +153,15 @@ export function OrganizationList({
onDelete,
sourceProvider = "github",
sourceUrl: sourceUrlProp,
sources,
}: OrganizationListProps) {
const { giteaConfig, mirrorOptions, advancedOptions } = useGiteaConfig();
const [overridesTarget, setOverridesTarget] = useState<Organization | null>(null);
const sourceLabel = SOURCE_PROVIDER_LABELS[sourceProvider];
const hasMultipleSources = !!sources && sources.length > 1;
const sourceUrl =
sourceUrlProp ??
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,
@@ -151,6 +223,27 @@ export function OrganizationList({
}
};
// Pin an organization to one source, or clear the pin with null (the org
// then follows every connected source).
const handleUpdateSource = async (orgId: string, sourceId: string | null) => {
const response = await fetch(`${withBase("/api/organizations")}/${orgId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ sourceId }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to update organization");
}
if (onRefresh) {
await onRefresh();
}
};
// Plan (dryRun) or perform the move of an organization's mirrors on the
// destination when its destination changes (issue #400).
const handleMoveMirrors = async (
@@ -252,6 +345,16 @@ export function OrganizationList({
const isLoading = loadingOrgIds.has(org.id ?? "");
const statusBadge = getStatusBadge(org.status);
const StatusIcon = statusBadge.icon;
const orgSources = sources ?? [];
const pinnedSource =
orgSources.find((source) => source.id === org.sourceId) ?? null;
const orgProvider: SourceProviderKind = pinnedSource?.provider ?? sourceProvider;
const orgSourceUrl = pinnedSource
? normalizeSourceUrl(pinnedSource.url, pinnedSource.provider)
: sourceUrl;
const orgSourceLabel = SOURCE_PROVIDER_LABELS[orgProvider];
const orgSourceShortLabel = orgProvider === "gitea" ? "Gitea" : orgSourceLabel;
const OrgSourceIcon = SOURCE_PROVIDER_ICONS[orgProvider];
return (
<Card
@@ -294,6 +397,16 @@ export function OrganizationList({
>
{org.membershipRole}
</span>
{hasMultipleSources && (
<Badge
variant="outline"
className="gap-1 px-1.5 font-normal text-[11px]"
title={`Source: ${pinnedSource?.name ?? "Every source"}`}
>
{pinnedSource ? <OrgSourceIcon className="h-3 w-3" /> : <Layers className="h-3 w-3" />}
{pinnedSource?.name ?? "Every source"}
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground">
<span className="font-semibold">{org.repositoryCount}</span>
@@ -340,6 +453,18 @@ export function OrganizationList({
isUpdating={isLoading}
/>
</div>
{/* Source picker (multi-source only) */}
{hasMultipleSources && (
<div>
<OrganizationSourceEditor
sources={orgSources}
value={pinnedSource?.id ?? null}
disabled={isLoading}
onUpdateSource={(newSourceId) => handleUpdateSource(org.id!, newSourceId)}
/>
</div>
)}
</div>
{/* Desktop Layout */}
@@ -367,6 +492,16 @@ export function OrganizationList({
{/* Status badge */}
<div className="flex items-center gap-2">
{hasMultipleSources && (
<Badge
variant="outline"
className="gap-1 px-1.5 font-normal text-[11px]"
title={`Source: ${pinnedSource?.name ?? "Every source"}`}
>
{pinnedSource ? <OrgSourceIcon className="h-3 w-3" /> : <Layers className="h-3 w-3" />}
{pinnedSource?.name ?? "Every source"}
</Badge>
)}
{hasMirrorOverrides(org.mirrorOverrides) && (
<Badge
variant="outline"
@@ -403,6 +538,18 @@ export function OrganizationList({
/>
</div>
{/* Source picker (multi-source only) */}
{hasMultipleSources && (
<div className="mb-4">
<OrganizationSourceEditor
sources={orgSources}
value={pinnedSource?.id ?? null}
disabled={isLoading}
onUpdateSource={(newSourceId) => handleUpdateSource(org.id!, newSourceId)}
/>
</div>
)}
{/* Error message for failed orgs */}
{org.status === "failed" && org.errorMessage && (
<div className="mb-4 p-3 rounded-md bg-destructive/10 border border-destructive/20">
@@ -602,14 +749,14 @@ export function OrganizationList({
})()}
<Button variant="outline" size="default" asChild className="flex-1 h-10 min-w-0">
<a
href={`${sourceUrl}/${org.name}`}
href={`${orgSourceUrl}/${org.name}`}
target="_blank"
rel="noopener noreferrer"
title={`View on ${sourceLabel}`}
title={`View on ${orgSourceLabel}`}
className="flex items-center justify-center gap-2"
>
<SourceIcon className="h-4 w-4 flex-shrink-0" />
<span className="text-xs">{sourceShortLabel}</span>
<OrgSourceIcon className="h-4 w-4 flex-shrink-0" />
<span className="text-xs">{orgSourceShortLabel}</span>
</a>
</Button>
</div>
@@ -771,20 +918,20 @@ export function OrganizationList({
</>
)}
</Button>
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
asChild
className="rounded-none rounded-r-md"
>
<a
href={`${sourceUrl}/${org.name}`}
href={`${orgSourceUrl}/${org.name}`}
target="_blank"
rel="noopener noreferrer"
title={`View on ${sourceLabel}`}
title={`View on ${orgSourceLabel}`}
>
<SourceIcon className="h-4 w-4 mr-2" />
{sourceShortLabel}
<OrgSourceIcon className="h-4 w-4 mr-2" />
{orgSourceShortLabel}
</a>
</Button>
</div>
+11
View File
@@ -360,6 +360,11 @@ export const organizationSchema = z.object({
name: z.string(),
normalizedName: z.string(),
avatarUrl: z.string(),
// The sources row this organization mirrors from, matching
// repositories.sourceId below: NULL means "not pinned" (repos of the same
// org name from every connected source), a dangling id falls back the same
// way, and no foreign key is deliberate.
sourceId: z.string().optional().nullable(),
membershipRole: z.enum(["member", "admin", "owner", "billing_manager"]).default("member"),
isIncluded: z.boolean().default(true),
destinationOrg: z.string().optional().nullable(),
@@ -673,6 +678,12 @@ export const organizations = sqliteTable("organizations", {
avatarUrl: text("avatar_url").notNull(),
// The sources row this organization mirrors from, matching
// repositories.sourceId above: NULL means "not pinned" (repos of the same
// org name from every connected source), a dangling id falls back the same
// way, and no foreign key is deliberate.
sourceId: text("source_id"),
membershipRole: text("membership_role").notNull().default("member"),
isIncluded: integer("is_included", { mode: "boolean" })
+71 -1
View File
@@ -42,6 +42,24 @@ let orgRepoRows: any[] = [];
let orgConfigRows: any[] = [];
/** Counts selects against the repositories table. */
let repoSelectCount = 0;
/** Leaf values referenced by each repositories select's where() condition. */
let repoSelectWhereValues: unknown[][] = [];
/**
* Drizzle conditions are cyclic (JSON.stringify throws) and the mocked
* tables have no column objects, so scoping assertions walk queryChunks
* and collect the primitive leaves (the compared values) they mention.
*/
function conditionLeafValues(node: any, depth = 0): unknown[] {
if (node == null || depth > 8) return [];
if (typeof node !== "object") return [node];
const leaves: unknown[] = [];
if (Array.isArray(node.value)) leaves.push(...node.value);
for (const chunk of node.queryChunks ?? []) {
leaves.push(...conditionLeafValues(chunk, depth + 1));
}
return leaves;
}
/** Every httpPost call: { url, payload }. */
let httpPostCalls: Array<{ url: string; payload: any }> = [];
/** Every org get-or-create: orgName -> deterministic id. */
@@ -80,6 +98,7 @@ mock.module("@/lib/db", () => {
where: (_cond: any) => {
if (table === repositoriesTable) {
repoSelectCount++;
repoSelectWhereValues.push(conditionLeafValues(_cond));
// First repositories select in mirrorGitHubOrgToGitea is the
// orgRepos query; everything after (idempotency checks, name
// claims) must see no rows.
@@ -141,7 +160,7 @@ mock.module("@/lib/sources", () => ({
),
findSourceForRepository: (repo: any, list: any[]) => {
if (repo.sourceId) {
const byId = list.find((source) => source.id === repo.sourceId);
const byId = list.find((source: any) => source.id === repo.sourceId);
if (byId) return byId;
}
const source = getRepositorySource(repo);
@@ -151,6 +170,8 @@ mock.module("@/lib/sources", () => ({
) ?? null
);
},
findSourceForOrganization: (org: { sourceId?: string | null }, list: any[]) =>
org.sourceId ? list.find((source: any) => source.id === org.sourceId) ?? null : null,
decryptSourceToken: (token: string | null | undefined) => token ?? "",
resolveGitHubApiBaseUrl: (url: string | null | undefined) => {
const trimmed = url?.trim().replace(/\/+$/, "") ?? "";
@@ -290,6 +311,7 @@ beforeEach(() => {
orgRepoRows = [];
orgConfigRows = [];
repoSelectCount = 0;
repoSelectWhereValues = [];
httpPostCalls = [];
orgCreateCalls = [];
});
@@ -489,3 +511,51 @@ describe.skipIf(!isChild)("mirrorGitHubOrgToGitea fork policy", () => {
expect(migrateCalls().length).toBe(0);
});
});
describe.skipIf(!isChild)("mirrorGitHubOrgToGitea source scoping", () => {
// The mock db ignores where() conditions, so the scoping is asserted on
// the compared values of the orgRepos query (the first repositories
// select) rather than on filtered rows.
function orgRepoQueryMentions(value: unknown): boolean {
return (repoSelectWhereValues[0] ?? []).includes(value);
}
test("an organization pinned to a source scopes the orgRepos query to it", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } });
const organization = makeOrg({ sourceId: "source-1" });
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
expect(orgRepoQueryMentions("source-1")).toBe(true);
expect(orgRepoQueryMentions("user-1")).toBe(true);
expect(migrateCalls().length).toBe(1);
});
test("an unpinned organization keeps the name-only orgRepos query", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } });
const organization = makeOrg();
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
expect(orgRepoQueryMentions("source-1")).toBe(false);
expect(orgRepoQueryMentions("user-1")).toBe(true);
expect(migrateCalls().length).toBe(1);
});
test("a pin whose source was deleted falls back to the name-only query", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } });
const organization = makeOrg({ sourceId: "source-deleted" });
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
expect(orgRepoQueryMentions("source-deleted")).toBe(false);
expect(orgRepoQueryMentions("user-1")).toBe(true);
expect(migrateCalls().length).toBe(1);
});
});
+29 -5
View File
@@ -22,6 +22,7 @@ import {
} from "./source-providers";
import {
decryptSourceToken,
findSourceForOrganization,
findSourceForRepository,
listSources,
} from "./sources";
@@ -2198,11 +2199,34 @@ export async function mirrorGitHubOrgToGitea({
targetOrgName = config.giteaConfig?.defaultOwner || "";
}
//query the db with the org name and get the repos
const orgRepos = await db
.select()
.from(repositories)
.where(eq(repositories.organization, organization.name));
// Query the db with the org name and get the repos. An organization
// pinned to one source only mirrors that source's repositories of the
// name; unpinned organizations (and pins whose source is gone) keep the
// pre-multi-source behavior of following every connected source.
const pinnedSource = findSourceForOrganization(
organization,
await listSources(config.userId)
);
const orgRepos = pinnedSource
? await db
.select()
.from(repositories)
.where(
and(
eq(repositories.userId, config.userId),
eq(repositories.organization, organization.name),
eq(repositories.sourceId, pinnedSource.id)
)
)
: await db
.select()
.from(repositories)
.where(
and(
eq(repositories.userId, config.userId),
eq(repositories.organization, organization.name)
)
);
// The organization's fork policy (override -> global skipForks) drops
// forked repositories from the batch so they are neither mirrored here
+13
View File
@@ -0,0 +1,13 @@
import type { ComponentType } from "react";
import { SiGitea, SiGithub, SiGitlab } from "react-icons/si";
import type { SourceProviderKind } from "./kinds";
export const SOURCE_PROVIDER_ICONS: Record<
SourceProviderKind,
ComponentType<{ className?: string }>
> = {
github: SiGithub,
gitlab: SiGitlab,
gitea: SiGitea,
};
+28
View File
@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, test } from "bun:test";
import {
decryptSourceToken,
deriveSourceName,
findSourceForOrganization,
findSourceForRepository,
primarySource,
resolveGitHubApiBaseUrl,
@@ -93,6 +94,33 @@ describe("findSourceForRepository", () => {
});
});
describe("findSourceForOrganization", () => {
const github = source();
const gitlab = source({
id: "src-2",
provider: "gitlab",
url: "https://gitlab.com",
username: "me",
});
test("matches by the stored source id", () => {
expect(findSourceForOrganization({ sourceId: "src-2" }, [github, gitlab])?.id).toBe("src-2");
});
test("returns null for an unpinned organization", () => {
expect(findSourceForOrganization({}, [github, gitlab])).toBeNull();
expect(findSourceForOrganization({ sourceId: null }, [github, gitlab])).toBeNull();
});
test("returns null for a dangling pin instead of another source", () => {
expect(findSourceForOrganization({ sourceId: "deleted-source" }, [github, gitlab])).toBeNull();
});
test("returns null when nothing is connected", () => {
expect(findSourceForOrganization({ sourceId: "src-1" }, [])).toBeNull();
});
});
describe("selectRepositoriesToRelink", () => {
const connected = new Set(["src-1"]);
const base = {
+15
View File
@@ -419,6 +419,21 @@ export function findSourceForRepository(
);
}
/**
* The source an organization is pinned to: the stored sourceId when it still
* resolves. Null for unpinned organizations and for pins whose source was
* deleted (both fall back to the pre-multi-source behavior of following
* every source's repositories of the same org name), so callers only scope
* their queries when this returns a row.
*/
export function findSourceForOrganization(
org: { sourceId?: string | null },
list: SourceRecord[]
): SourceRecord | null {
if (!org.sourceId) return null;
return list.find((source) => source.id === org.sourceId) ?? null;
}
/**
* Decrypt a source token for API use. Unlike getDecryptedGitHubToken this
* never throws: empty or missing means public-only access, which every
+12
View File
@@ -45,6 +45,11 @@ export const GET: APIRoute = async ({ request, locals }) => {
.where(eq(organizations.userId, userId))
.orderBy(sql`name COLLATE NOCASE`);
const { listSources } = await import("@/lib/sources");
const connectedSourceIds = new Set(
(await listSources(userId)).map((source) => source.id)
);
// Calculate repository breakdowns for each organization
const orgsWithBreakdown = await Promise.all(
rawOrgs.map(async (org) => {
@@ -54,6 +59,13 @@ export const GET: APIRoute = async ({ request, locals }) => {
eq(repositories.organization, org.name)
];
// A pinned organization only counts its source's repositories of
// the name; dangling pins (source deleted) fall back to every
// source, the same rule the org mirror applies.
if (org.sourceId && connectedSourceIds.has(org.sourceId)) {
baseConditions.push(eq(repositories.sourceId, org.sourceId));
}
if (!githubConfig.mirrorStarred) {
baseConditions.push(eq(repositories.isStarred, false));
}
+248
View File
@@ -0,0 +1,248 @@
/**
* Route tests for the per-organization source pin on PATCH
* /api/organizations/:id: ownership validation of the pin, clearing it with
* null, and leaving it untouched when the field is absent. Plus the DELETE
* repository-deletion scoping for pinned organizations.
*
* NOTE: these tests replace @/lib/db, @/lib/sources and
* @/lib/utils/auth-helpers with mocks. bun's mock.module is process-wide and
* leaks into other test files, so in the shared test process this file
* registers NOTHING: it re-runs itself in an isolated child process (the
* same harness as gitea-org-mirror-destination.test.ts).
*/
import { describe, test, expect, mock, beforeEach } from "bun:test";
const CHILD_FLAG = "GM_ORG_ID_ROUTE_ISOLATED";
const isChild = !!process.env[CHILD_FLAG];
if (!isChild) {
test("organization [id] route (source pin) — isolated child suite", () => {
const res = Bun.spawnSync({
cmd: [process.execPath, "test", import.meta.path],
env: { ...process.env, [CHILD_FLAG]: "1" },
stdout: "pipe",
stderr: "pipe",
});
if (res.exitCode !== 0) {
console.error(res.stdout.toString());
console.error(res.stderr.toString());
}
expect(res.exitCode).toBe(0);
}, 60_000);
}
let sourceRows: any[] = [];
let orgRows: any[] = [];
/** Leaf values of each delete's where() condition (drizzle conditions are cyclic). */
let deleteWhereValues: unknown[][] = [];
function conditionLeafValues(node: any, depth = 0): unknown[] {
if (node == null || depth > 8) return [];
if (typeof node !== "object") return [node];
const leaves: unknown[] = [];
if (Array.isArray(node.value)) leaves.push(...node.value);
for (const chunk of node.queryChunks ?? []) {
leaves.push(...conditionLeafValues(chunk, depth + 1));
}
return leaves;
}
if (isChild) {
mock.module("@/lib/utils/auth-helpers", () => ({
requireAuth: mock(async () => ({ user: { id: "user-1" }, response: null })),
}));
mock.module("@/lib/sources", () => ({
listSources: mock(async (userId: string) =>
sourceRows.filter((source) => source.userId === userId)
),
}));
mock.module("@/lib/db", () => ({
db: {
select: () => ({
from: () => ({
where: () => ({
limit: async () => orgRows.slice(0, 1),
}),
}),
}),
update: () => ({
set: (data: any) => ({
where: async () => {
if (orgRows[0]) Object.assign(orgRows[0], data);
},
}),
}),
delete: () => ({
where: async (cond: any) => {
deleteWhereValues.push(conditionLeafValues(cond));
},
}),
},
organizations: {},
repositories: {},
configs: {},
users: {},
events: {},
mirrorJobs: {},
sessions: {},
accounts: {},
verificationTokens: {},
verifications: {},
oauthClients: {},
oauthAccessTokens: {},
oauthRefreshTokens: {},
oauthConsents: {},
jwkss: {},
ssoProviders: {},
apikeys: {},
rateLimits: {},
sources: {},
}));
}
const { PATCH, DELETE } = isChild
? await import("./[id]")
: { PATCH: undefined as any, DELETE: undefined as any };
function patch(body: Record<string, unknown>) {
return PATCH({
params: { id: "org-1" },
request: new Request("http://localhost/api/organizations/org-1", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
locals: { session: { userId: "user-1" } },
} as any);
}
function remove() {
return DELETE({
params: { id: "org-1" },
request: new Request("http://localhost/api/organizations/org-1", {
method: "DELETE",
}),
locals: { session: { userId: "user-1" } },
} as any);
}
beforeEach(() => {
deleteWhereValues = [];
sourceRows = [
{
id: "source-1",
userId: "user-1",
name: "GitHub (octocat)",
provider: "github",
url: "https://github.com",
username: "octocat",
token: null,
enabled: true,
},
{
id: "source-2",
userId: "user-2",
name: "GitLab (someone)",
provider: "gitlab",
url: "https://gitlab.com",
username: "someone",
token: null,
enabled: true,
},
];
orgRows = [
{
id: "org-1",
userId: "user-1",
name: "acme",
normalizedName: "acme",
destinationOrg: null,
mirrorOverrides: null,
sourceId: null,
},
];
});
describe.skipIf(!isChild)("PATCH /api/organizations/:id source pin", () => {
test("pins the organization to a source the user owns", async () => {
const response = await patch({ sourceId: "source-1" });
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.sourceId).toBe("source-1");
expect(orgRows[0].sourceId).toBe("source-1");
});
test("rejects a source that belongs to another user", async () => {
const response = await patch({ sourceId: "source-2" });
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toContain("No source with id source-2");
expect(orgRows[0].sourceId).toBeNull();
});
test("rejects an unknown source id", async () => {
const response = await patch({ sourceId: "source-missing" });
expect(response.status).toBe(400);
expect(orgRows[0].sourceId).toBeNull();
});
test("clears the pin with null", async () => {
orgRows[0].sourceId = "source-1";
const response = await patch({ sourceId: null });
expect(response.status).toBe(200);
const data = await response.json();
expect(data.sourceId).toBeNull();
expect(orgRows[0].sourceId).toBeNull();
});
test("leaves the pin untouched when the field is absent", async () => {
orgRows[0].sourceId = "source-1";
const response = await patch({ destinationOrg: "backup" });
expect(response.status).toBe(200);
const data = await response.json();
expect(data.sourceId).toBe("source-1");
expect(data.destinationOrg).toBe("backup");
});
test("answers 404 for an organization the user does not have", async () => {
orgRows = [];
const response = await patch({ sourceId: "source-1" });
expect(response.status).toBe(404);
});
});
describe.skipIf(!isChild)("DELETE /api/organizations/:id with a source pin", () => {
// The route deletes the organization's repositories first, so the first
// recorded delete is the repositories one.
function repoDeleteLeaves(): unknown[] {
return deleteWhereValues[0] ?? [];
}
test("a pinned organization deletes only its source's repositories", async () => {
orgRows[0].sourceId = "source-1";
const response = await remove();
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
expect(repoDeleteLeaves()).toContain("source-1");
expect(repoDeleteLeaves()).toContain("acme");
});
test("an unpinned organization keeps the name-wide repository delete", async () => {
const response = await remove();
expect(response.status).toBe(200);
expect(repoDeleteLeaves()).toContain("acme");
expect(repoDeleteLeaves()).not.toContain("source-1");
});
test("a pin whose source was deleted keeps the name-wide repository delete", async () => {
orgRows[0].sourceId = "source-gone";
const response = await remove();
expect(response.status).toBe(200);
expect(repoDeleteLeaves()).toContain("acme");
expect(repoDeleteLeaves()).not.toContain("source-gone");
});
});
+39 -6
View File
@@ -14,6 +14,7 @@ import { normalizeMirrorOverrides } from "@/lib/utils/mirror-overrides";
const patchBodySchema = z.object({
destinationOrg: z.string().nullable().optional(),
mirrorOverrides: mirrorOverridesSchema.nullable().optional(),
sourceId: z.string().nullable().optional(),
});
export const PATCH: APIRoute = async (context) => {
@@ -72,6 +73,25 @@ export const PATCH: APIRoute = async (context) => {
updates.mirrorOverrides = normalizeMirrorOverrides(body.mirrorOverrides);
}
if ("sourceId" in body) {
const nextSourceId = body.sourceId?.trim() || null;
if (nextSourceId) {
const { listSources } = await import("@/lib/sources");
const owned = (await listSources(userId)).some(
(source) => source.id === nextSourceId
);
if (!owned) {
return new Response(
JSON.stringify({
error: `No source with id ${nextSourceId} belongs to this user`,
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
}
updates.sourceId = nextSourceId;
}
await db
.update(organizations)
.set(updates)
@@ -81,6 +101,7 @@ export const PATCH: APIRoute = async (context) => {
.select({
destinationOrg: organizations.destinationOrg,
mirrorOverrides: organizations.mirrorOverrides,
sourceId: organizations.sourceId,
})
.from(organizations)
.where(eq(organizations.id, orgId))
@@ -92,6 +113,7 @@ export const PATCH: APIRoute = async (context) => {
message: "Organization updated successfully",
destinationOrg: updated?.destinationOrg ?? null,
mirrorOverrides: updated?.mirrorOverrides ?? null,
sourceId: updated?.sourceId ?? null,
}),
{
status: 200,
@@ -137,12 +159,23 @@ export const DELETE: APIRoute = async (context) => {
);
}
await db.delete(repositories).where(
and(
eq(repositories.userId, userId),
eq(repositories.organization, existingOrg.name)
)
);
// A pinned organization only owns its source's repositories of the name;
// unpinned ones keep deleting every repository with that org name.
const deleteRepoConditions = [
eq(repositories.userId, userId),
eq(repositories.organization, existingOrg.name),
];
if (existingOrg.sourceId) {
const { listSources } = await import("@/lib/sources");
const pinned = (await listSources(userId)).some(
(source) => source.id === existingOrg.sourceId
);
if (pinned) {
deleteRepoConditions.push(eq(repositories.sourceId, existingOrg.sourceId));
}
}
await db.delete(repositories).where(and(...deleteRepoConditions));
await db
.delete(organizations)
+43 -5
View File
@@ -106,6 +106,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
avatarUrl: org.avatarUrl,
membershipRole: org.membershipRole,
isIncluded: false,
sourceId: source.id,
status: org.status,
repositoryCount: org.repositoryCount,
createdAt: new Date(),
@@ -122,6 +123,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
avatarUrl: org.avatarUrl,
membershipRole: "member" as const,
isIncluded: false,
sourceId: source.id,
status: "failed" as const,
errorMessage: org.reason,
repositoryCount: 0,
@@ -145,7 +147,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
.from(repositories)
.where(eq(repositories.userId, userId)),
tx
.select({ normalizedName: organizations.normalizedName, status: organizations.status })
.select({
normalizedName: organizations.normalizedName,
status: organizations.status,
sourceId: organizations.sourceId,
})
.from(organizations)
.where(eq(organizations.userId, userId)),
]);
@@ -154,7 +160,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
const existingRepoKeys = new Set(
existingRepos.map((r) => `${r.sourceId ?? ""}|${r.normalizedFullName}`)
);
const existingOrgMap = new Map(existingOrgs.map((o) => [o.normalizedName, o.status]));
const existingOrgMap = new Map(
existingOrgs.map((o) => [o.normalizedName, { status: o.status, sourceId: o.sourceId }])
);
insertedRepos = newRepos.filter(
(r) =>
@@ -163,9 +171,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
insertedOrgs = newOrgs.filter((o) => !existingOrgMap.has(o.normalizedName));
// Update previously failed orgs that now succeeded
// Update previously failed orgs that now succeeded. The pin must
// follow the recovering source, or the org mirror keeps scoping
// to the source the failure was recorded under.
const recoveredOrgs = newOrgs.filter(
(o) => existingOrgMap.get(o.normalizedName) === "failed"
(o) => existingOrgMap.get(o.normalizedName)?.status === "failed"
);
for (const org of recoveredOrgs) {
await tx
@@ -176,6 +186,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
repositoryCount: org.repositoryCount,
avatarUrl: org.avatarUrl,
membershipRole: org.membershipRole,
sourceId: org.sourceId,
updatedAt: new Date(),
})
.where(
@@ -187,10 +198,37 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
recoveredOrgCount = recoveredOrgs.length;
// An org that a second source also lists must not stay pinned to
// the first one: the mirror and the card counts would drop the
// other source's repositories. Clearing the pin matches migration
// 0020, which leaves orgs spanning multiple sources unpinned.
// Failed orgs are excluded — their pin follows the recovering
// source in the update above.
const crossSourceOrgs = newOrgs.filter((o) => {
const existing = existingOrgMap.get(o.normalizedName);
return (
existing !== undefined &&
existing.status !== "failed" &&
existing.sourceId != null &&
existing.sourceId !== source.id
);
});
for (const org of crossSourceOrgs) {
await tx
.update(organizations)
.set({ sourceId: null, updatedAt: new Date() })
.where(
and(
eq(organizations.userId, userId),
eq(organizations.normalizedName, org.normalizedName),
)
);
}
// Insert or update failed orgs (only update orgs already in "failed" state — don't overwrite good state)
insertedFailedOrgs = failedOrgRecords.filter((o) => !existingOrgMap.has(o.normalizedName));
const stillFailedOrgs = failedOrgRecords.filter(
(o) => existingOrgMap.get(o.normalizedName) === "failed"
(o) => existingOrgMap.get(o.normalizedName)?.status === "failed"
);
for (const org of stillFailedOrgs) {
await tx
+26 -2
View File
@@ -21,8 +21,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
const body: AddOrganizationApiRequest = await request.json();
const { role, org, force = false } = body;
// Optional body field: which connected source to import from; defaults to the primary source.
const sourceId = (body as AddOrganizationApiRequest & { sourceId?: string }).sourceId?.trim() || undefined;
if (body.sourceId !== undefined && typeof body.sourceId !== "string") {
return jsonResponse({
data: { success: false, error: "sourceId must be a string" },
status: 400,
});
}
const sourceId = body.sourceId?.trim() || undefined;
if (!org || !role) {
return jsonResponse({
@@ -34,6 +39,23 @@ export const POST: APIRoute = async ({ request, locals }) => {
const trimmedOrg = org.trim();
const normalizedOrg = trimmedOrg.toLowerCase();
// The force re-add branch returns before the source resolution further
// down, so a pin has to be ownership-checked up front or it would skip
// validation entirely.
if (sourceId) {
const { listSources } = await import("@/lib/sources");
const owned = (await listSources(userId)).some((s) => s.id === sourceId);
if (!owned) {
return jsonResponse({
data: {
success: false,
error: `No source with id ${sourceId} belongs to this user`,
},
status: 400,
});
}
}
if (!isValidSourceOrgName(trimmedOrg)) {
return jsonResponse({
data: {
@@ -73,6 +95,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
.set({
membershipRole: role,
normalizedName: normalizedOrg,
...(sourceId ? { sourceId } : {}),
updatedAt: new Date(),
})
.where(eq(organizations.id, existingOrg.id))
@@ -204,6 +227,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
avatarUrl: orgData.avatarUrl,
membershipRole: role,
isIncluded: false,
sourceId: source.id,
status: "imported" as RepoStatus,
repositoryCount: orgRepos.length,
createdAt: orgData.createdAt,
+2
View File
@@ -46,6 +46,8 @@ export interface AddOrganizationApiRequest {
org: string;
role: MembershipRole;
force?: boolean;
/** Which connected source to import from; defaults to the primary source. */
sourceId?: string;
}
export interface AddOrganizationApiResponse {
+8
View File
@@ -121,6 +121,14 @@ Starts mirroring in the background and returns straight away. Poll the list endp
`role` is the account's role in that organization (`member`, `admin`, `owner` or `billing_manager`). `409` means it is already tracked; `force: true` refreshes it. Then `POST /api/job/mirror-org` with `{ "organizationIds": ["..."] }` mirrors its repositories.
When several sources are connected, an optional `sourceId` picks which one the organization imports and mirrors from (the first connected source is the default):
```json
{ "org": "my-org", "role": "member", "sourceId": "…" }
```
`PATCH /api/organizations/:id` with `{ "sourceId": "…" }` re-pins an existing organization to another source, and `{ "sourceId": null }` clears the pin so the organization follows every source's repositories of the same name again. A pin whose source was removed falls back the same way.
### Run cleanup
`POST /api/cleanup/trigger` runs the [repository cleanup](./cleanup-and-reconcile.md) now, with the account's saved cleanup settings, and returns how many orphaned repositories were found and processed and any errors. `GET /api/cleanup/trigger` returns the cleanup configuration and the last and next run times.