Compare commits

..
3 Commits
Author SHA1 Message Date
Arunavo Ray e82aa86723 chore: bump version to 3.34.0 2026-09-08 19:26:17 +05:30
ARUNAVO RAY c2ea2b3cb6 fix(orgs): bulk import only clears source pins it set in the same run (#408)
Follow-up to #407.

The bulk import cleared an organization's pin whenever another connected source listed the same organization name. That also undid pins a user had set on purpose through the card picker or the add dialog: pin acme to GitHub because the GitLab group of the same name is unrelated, and the next Import widened it back to every source.

Now the import only clears pins it created itself in the same run. Organizations it inserts (or re-pins on recovery) are remembered by name for the rest of the run; when a later source lists one of those names, the pin is cleared, since the organization spans several sources, the same case migration 0020 leaves unpinned. Pins that existed before the run are never touched. The bookkeeping is applied after each source's transaction commits, so a source that fails part way leaves it unchanged.

- `selectSameRunPinsToClear` in `src/lib/sources.ts` holds the rule, with unit tests.
- `src/pages/api/sync/index.ts` tracks the run's pins and applies the rule; the existing-org lookup goes back to name to status.
- Changelog line under the per-organization source selection entry.

Tests: 878 pass. Build passes.
2026-09-08 19:26:03 +05:30
TomRoyls a74b7e54ed 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.
2026-09-08 19:13:32 +05:30
28 changed files with 4097 additions and 43 deletions

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

+5
View File
@@ -8,6 +8,11 @@ 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
- The bulk import only clears a pin it created itself in the same run when a second source lists the same organization; pins set through the picker or the add dialog are kept
- 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
}
]
}
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: gitea-mirror
description: Kubernetes helm chart for gitea-mirror
type: application
version: 3.33.0
appVersion: "3.33.0"
version: 3.34.0
appVersion: "3.34.0"
icon: https://github.com/RayLabsHQ/gitea-mirror/blob/main/.github/assets/logo.png
keywords:
- git
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.33.0",
"version": "3.34.0",
"engines": {
"bun": ">=1.2.9"
},
+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,
};
+55
View File
@@ -2,7 +2,9 @@ import { beforeAll, describe, expect, test } from "bun:test";
import {
decryptSourceToken,
deriveSourceName,
findSourceForOrganization,
findSourceForRepository,
selectSameRunPinsToClear,
primarySource,
resolveGitHubApiBaseUrl,
selectRepositoriesToRelink,
@@ -93,6 +95,59 @@ 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("selectSameRunPinsToClear", () => {
const discovered = [{ normalizedName: "acme" }, { normalizedName: "tools" }];
test("clears a pin an earlier source in the same run created", () => {
const pinnedThisRun = new Map([["acme", "src-1"]]);
expect(selectSameRunPinsToClear(discovered, pinnedThisRun, "src-2")).toEqual(["acme"]);
});
test("leaves organizations the run has not pinned alone", () => {
// "tools" was pinned before the run (or never), so it is not in the map.
const pinnedThisRun = new Map([["other", "src-1"]]);
expect(selectSameRunPinsToClear(discovered, pinnedThisRun, "src-2")).toEqual([]);
});
test("keeps a pin the same source created", () => {
const pinnedThisRun = new Map([["acme", "src-2"]]);
expect(selectSameRunPinsToClear(discovered, pinnedThisRun, "src-2")).toEqual([]);
});
test("names each organization once", () => {
const pinnedThisRun = new Map([["acme", "src-1"]]);
const twice = [...discovered, { normalizedName: "acme" }];
expect(selectSameRunPinsToClear(twice, pinnedThisRun, "src-2")).toEqual(["acme"]);
});
});
describe("selectRepositoriesToRelink", () => {
const connected = new Set(["src-1"]);
const base = {
+38
View File
@@ -144,6 +144,29 @@ export type DanglingRepositoryRow = {
updatedAt: Date | string | null;
};
/**
* Normalized names among `discovered` whose pin the current bulk import run
* created itself (`pinnedThisRun`, name -> source id) from a different source
* than `sourceId`. Such an organization spans several sources, the ambiguous
* case migration 0020 leaves unpinned, so the caller clears the pin and drops
* the name from the map. Pins that existed before the run (set through the
* picker or the add dialog) are a stored choice and never appear in the map.
*/
export function selectSameRunPinsToClear(
discovered: ReadonlyArray<{ normalizedName: string }>,
pinnedThisRun: ReadonlyMap<string, string>,
sourceId: string
): string[] {
const names: string[] = [];
for (const org of discovered) {
const pinnedBy = pinnedThisRun.get(org.normalizedName);
if (pinnedBy !== undefined && pinnedBy !== sourceId && !names.includes(org.normalizedName)) {
names.push(org.normalizedName);
}
}
return names;
}
// Repositories whose sourceId no longer resolves (or was never set) but whose
// provider and host match the given source: exactly the rows a deleted and
// re-added source must take back. Without the re-link, the next import sees
@@ -419,6 +442,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)
+44 -2
View File
@@ -40,7 +40,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
// is the primary source). A source without a token still lists public
// repositories through its provider. Configs written outside the sources
// API get their first source row seeded here.
const { listSources, ensureSourcesFromConfig } = await import("@/lib/sources");
const { listSources, ensureSourcesFromConfig, selectSameRunPinsToClear } = await import("@/lib/sources");
const { createSourceProviderFromSource } = await import("@/lib/source-providers");
await ensureSourcesFromConfig(userId);
const sources = (await listSources(userId)).filter((source) => source.enabled);
@@ -71,6 +71,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
const failedOrgNames: string[] = [];
const failedSourceNames: string[] = [];
// Organization pins this run set itself (inserted, or re-pinned on
// recovery), by normalized name. A later source listing the same name
// clears only these; pins that existed before the run are a stored
// choice and stay as they are. See selectSameRunPinsToClear.
const pinnedThisRun = new Map<string, string>();
for (const source of sources) {
try {
const sourceProvider = createSourceProviderFromSource(source, { userId });
@@ -106,6 +112,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 +129,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,
@@ -133,6 +141,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
let insertedOrgs: typeof newOrgs = [];
let insertedFailedOrgs: typeof failedOrgRecords = [];
let recoveredOrgCount = 0;
// Names whose pin this source set or cleared; applied to pinnedThisRun
// only after the transaction commits, so a rolled-back source leaves
// the run's pin bookkeeping untouched.
let pinnedOrgNames: string[] = [];
let clearedOrgNames: string[] = [];
// Transaction to insert only new items
await db.transaction(async (tx) => {
@@ -163,7 +176,9 @@ 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"
);
@@ -176,6 +191,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,6 +203,24 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
recoveredOrgCount = recoveredOrgs.length;
// An organization that an earlier source in this run pinned and
// that this source lists as well spans several sources, so its pin
// is cleared, the same rule migration 0020 applies. Only pins this
// run created are touched: an org the user pinned on purpose keeps
// its pin even when another source lists the same name.
clearedOrgNames = selectSameRunPinsToClear(newOrgs, pinnedThisRun, source.id);
for (const normalizedName of clearedOrgNames) {
await tx
.update(organizations)
.set({ sourceId: null, updatedAt: new Date() })
.where(
and(
eq(organizations.userId, userId),
eq(organizations.normalizedName, 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(
@@ -232,8 +266,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
await tx.insert(organizations).values(batch);
}
}
pinnedOrgNames = [...recoveredOrgs, ...insertedOrgs].map((org) => org.normalizedName);
});
for (const normalizedName of pinnedOrgNames) {
pinnedThisRun.set(normalizedName, source.id);
}
for (const normalizedName of clearedOrgNames) {
pinnedThisRun.delete(normalizedName);
}
// Create mirror jobs only for newly inserted items
const mirrorJobPromises = [
...insertedRepos.map((repo) =>
+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.