mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-30 22:21:58 +01:00
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:
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 |
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -141,6 +141,13 @@
|
||||
"when": 1788350408053,
|
||||
"tag": "0019_multi_source",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "6",
|
||||
"when": 1788805724170,
|
||||
"tag": "0020_org_source",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user