mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-11-03 15:52:23 +01:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb394942b | ||
|
|
1d0afe2d2e | ||
|
|
aaef5d6e5c | ||
|
|
604ff3b6b3 | ||
|
|
935bc89e52 | ||
|
|
133268e63a |
No files matched your search
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- No request leaves the process while GitHub is rate limited (#437, second report)
|
||||
- The v3.37.0 change stopped the scheduler run from holding its lock through a rate limit, but every other request already queued in the run still went to GitHub and came back 403, and the retry helper sent each of them three more times; GitHub counts requests made while limited toward abuse detection and one account was suspended that way
|
||||
- Every GitHub client now holds its requests while the source is paused: a limit that resets within two minutes is waited out inside the request, a longer one fails the request at once without contacting GitHub, and the rate limit probe endpoint stays available so the pause can end early
|
||||
- The retry helper no longer retries a rate limit refusal and stops starting the remaining items of the batch
|
||||
- A rate limit refusal inside the issues, pull requests, releases, labels or milestones pass now fails the repository (which the scheduler already puts back to its previous status and retries after the reset) instead of moving on to the next component, and a pull request whose detail call was refused is no longer written as a stripped down issue
|
||||
- A secondary rate limit pauses the whole client for its retry-after window, not only the request that was told to slow down
|
||||
|
||||
### Security
|
||||
- Source URLs go through the outbound guard too (GHSA-p7w3-46pg-mv6h): `POST /api/sources`, `PUT /api/sources/:id` and the configuration save refuse link local and metadata addresses for source and destination URLs, the source fetch helper pins and never follows redirects like the other user supplied URLs, source errors no longer carry the upstream body in their message, and errors raised by a request to another host are never forwarded to API clients whatever words they contain
|
||||
- Fixed four privately reported vulnerabilities (GHSA-9m33-xfrc-5jxw, GHSA-6m23-28hh-gjh2, GHSA-2hpx-83vg-gm45, GHSA-5pp8-r7f5-6q8p)
|
||||
@@ -19,6 +27,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Raised the dependency floors for the advisories published on 2026-09-08: Astro 7.2.8 (remote code execution through AVIF image optimization, and an authorization bypass when stripping the configured base), @xmldom/xmldom 0.8.15 (eight parser and serializer issues), sharp 0.35.4 (libheif), svgo 4.1.0 (removeScripts sanitization) and js-yaml 4.3.2 (merge-key CPU use). Applied to both the application and the documentation site.
|
||||
|
||||
### Added
|
||||
- List view for the Organizations page (#428)
|
||||
- A cards/list switch in the toolbar; the list shows one row per organization with its role, destination, repository counts, last mirrored time, status, the same actions as the cards and the source and destination links, so a few dozen organizations fit on one screen
|
||||
- The choice is remembered per browser, next to the theme and time format preferences
|
||||
- Incremental issue and pull request sync (#449)
|
||||
- After a complete pass, the issues and pull request passes ask GitHub only for items updated since the last one (with a 10 minute margin), so comments, pull request details, commits and files are fetched only for what changed instead of for every item on every sync
|
||||
- The watermark is stored per repository in the metadata state and only moves when a pass finishes with no failed item
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: gitea-mirror
|
||||
description: Kubernetes helm chart for gitea-mirror
|
||||
type: application
|
||||
version: 3.38.2
|
||||
appVersion: "3.38.2"
|
||||
version: 3.40.1
|
||||
appVersion: "3.40.1"
|
||||
icon: https://github.com/RayLabsHQ/gitea-mirror/blob/main/.github/assets/logo.png
|
||||
keywords:
|
||||
- git
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.38.2",
|
||||
"version": "3.40.1",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
|
||||
@@ -44,6 +44,11 @@ interface MirrorDestinationEditorProps {
|
||||
destinationLabel?: string;
|
||||
isUpdating?: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* Show only the destination and the edit button. The list view (#428)
|
||||
* already names the organization in its first column.
|
||||
*/
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const MAX_LISTED = 8;
|
||||
@@ -88,6 +93,7 @@ export function MirrorDestinationEditor({
|
||||
destinationLabel = "Gitea",
|
||||
isUpdating = false,
|
||||
className,
|
||||
compact = false,
|
||||
}: MirrorDestinationEditorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [editValue, setEditValue] = useState(currentDestination || "");
|
||||
@@ -185,14 +191,25 @@ export function MirrorDestinationEditor({
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 w-full", className)}>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0 flex-1">
|
||||
<Building2 className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="font-medium truncate">{organizationName}</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0" />
|
||||
<span className={cn(
|
||||
"font-medium truncate",
|
||||
hasOverride && "text-orange-600 dark:text-orange-400"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"flex items-center gap-1.5 text-muted-foreground min-w-0 flex-1",
|
||||
compact ? "text-sm" : "text-xs"
|
||||
)}>
|
||||
{!compact && (
|
||||
<>
|
||||
<Building2 className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="font-medium truncate">{organizationName}</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium truncate",
|
||||
compact && "text-foreground",
|
||||
hasOverride && "text-orange-600 dark:text-orange-400"
|
||||
)}
|
||||
title={effectiveDestination}
|
||||
>
|
||||
{effectiveDestination}
|
||||
</span>
|
||||
{hasOverride && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, RefreshCw, FlipHorizontal, Filter, LoaderCircle, Trash2, Download } from "lucide-react";
|
||||
import { Search, RefreshCw, FlipHorizontal, Filter, LoaderCircle, Trash2, Download, LayoutGrid, List } from "lucide-react";
|
||||
import type { MirrorJob, Organization } from "@/lib/db/schema";
|
||||
import { OrganizationList } from "./OrganizationsList";
|
||||
import AddOrganizationDialog from "./AddOrganizationDialog";
|
||||
@@ -30,6 +30,9 @@ import { invalidateConfigCache, useConfigStatus } from "@/hooks/useConfigStatus"
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import { useOrganizationsView } from "@/hooks/useOrganizationsView";
|
||||
import type { OrganizationsViewMode } from "@/lib/utils/organizations-view";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -53,6 +56,8 @@ export function Organization() {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
// Cards or the compact list (#428); remembered per browser.
|
||||
const { view, setView } = useOrganizationsView();
|
||||
const { user } = useAuth();
|
||||
const { sourceProvider, sourceUrl, sources } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
@@ -778,6 +783,8 @@ export function Organization() {
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<OrganizationsViewToggle view={view} onChange={setView} compact />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -938,6 +945,8 @@ export function Organization() {
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<OrganizationsViewToggle view={view} onChange={setView} />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -991,6 +1000,7 @@ export function Organization() {
|
||||
sourceProvider={sourceProvider}
|
||||
sourceUrl={sourceUrl}
|
||||
sources={sources}
|
||||
view={view}
|
||||
/>
|
||||
|
||||
<AddOrganizationDialog
|
||||
@@ -1066,3 +1076,73 @@ export function Organization() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards or list switch for the Organizations page (#428). A segmented
|
||||
* pair of icon buttons so it sits next to the other toolbar icons. The
|
||||
* compact form is one button that flips to the other view, for the
|
||||
* narrow toolbar where every icon costs search box width.
|
||||
*/
|
||||
function OrganizationsViewToggle({
|
||||
view,
|
||||
onChange,
|
||||
compact = false,
|
||||
}: {
|
||||
view: OrganizationsViewMode;
|
||||
onChange: (next: OrganizationsViewMode) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const options: { value: OrganizationsViewMode; label: string; Icon: typeof LayoutGrid }[] = [
|
||||
{ value: "cards", label: "Cards", Icon: LayoutGrid },
|
||||
{ value: "list", label: "List", Icon: List },
|
||||
];
|
||||
if (compact) {
|
||||
const next = options.find((option) => option.value !== view) ?? options[0];
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onChange(next.value)}
|
||||
title={`Switch to ${next.label.toLowerCase()} view`}
|
||||
aria-label={`Switch to ${next.label.toLowerCase()} view`}
|
||||
className="h-10 w-10 shrink-0"
|
||||
data-testid="organizations-view-toggle"
|
||||
>
|
||||
<next.Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Organizations view"
|
||||
className="flex items-center h-10 rounded-md border border-input bg-background shrink-0"
|
||||
data-testid="organizations-view-toggle"
|
||||
>
|
||||
{options.map(({ value, label, Icon }, index) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
role="radio"
|
||||
aria-checked={view === value}
|
||||
aria-label={`${label} view`}
|
||||
title={`${label} view`}
|
||||
data-state={view === value ? "on" : "off"}
|
||||
onClick={() => onChange(value)}
|
||||
className={cn(
|
||||
"h-full w-10 rounded-none",
|
||||
index === 0 ? "rounded-l-md" : "rounded-r-md border-l border-input",
|
||||
view === value
|
||||
? "bg-muted text-foreground hover:bg-muted"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Ban, MoreVertical, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
|
||||
import type { Organization } from "@/lib/db/schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface OrganizationActionsMenuProps {
|
||||
org: Organization;
|
||||
disabled?: boolean;
|
||||
onSync?: ({ orgId }: { orgId: string }) => Promise<void>;
|
||||
onIgnore?: ({ orgId, ignore }: { orgId: string; ignore: boolean }) => Promise<void>;
|
||||
onDelete?: (orgId: string) => void;
|
||||
onEditMirrorOptions: (org: Organization) => void;
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The secondary actions of an organization, shared by the cards and the
|
||||
* list (#428). Hidden while the organization is being mirrored, like the
|
||||
* primary action.
|
||||
*/
|
||||
export function OrganizationActionsMenu({
|
||||
org,
|
||||
disabled = false,
|
||||
onSync,
|
||||
onIgnore,
|
||||
onDelete,
|
||||
onEditMirrorOptions,
|
||||
triggerClassName,
|
||||
}: OrganizationActionsMenuProps) {
|
||||
if (org.status === "mirroring") return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className={cn(triggerClassName)}
|
||||
title="More actions"
|
||||
aria-label={`More actions for ${org.name}`}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onSync && (org.status === "mirrored" || org.status === "failed") && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => org.id && onSync({ orgId: org.id })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onEditMirrorOptions(org)}>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror Options
|
||||
</DropdownMenuItem>
|
||||
{org.status !== "ignored" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
{org.status !== "ignored" && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => org.id && onDelete(org.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete from Mirror
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Layers } from "lucide-react";
|
||||
import type { SourceApiRecord } from "@/types/config";
|
||||
import { SOURCE_PROVIDER_ICONS } from "@/lib/source-providers/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
// Radix reserves the empty string as a Select value, so unpinned
|
||||
// organizations use this sentinel instead.
|
||||
const EVERY_SOURCE_VALUE = "every-source";
|
||||
|
||||
export interface OrganizationSourceEditorProps {
|
||||
sources: SourceApiRecord[];
|
||||
value?: string | null;
|
||||
disabled?: boolean;
|
||||
onUpdateSource: (sourceId: string | null) => Promise<void>;
|
||||
/** Drop the "Source" label; the list view puts it in the column header. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin an organization to one connected source, or let it follow every
|
||||
* source. Shown by the cards and the list only when more than one source
|
||||
* is connected.
|
||||
*/
|
||||
export function OrganizationSourceEditor({
|
||||
sources,
|
||||
value,
|
||||
disabled = false,
|
||||
onUpdateSource,
|
||||
compact = false,
|
||||
className,
|
||||
}: OrganizationSourceEditorProps) {
|
||||
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={cn("flex items-center gap-2 w-full", className)}>
|
||||
{!compact && (
|
||||
<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" aria-label="Source">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,7 @@ 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, Layers } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, Ban, Layers } from "lucide-react";
|
||||
import type { MirrorOverrides, Organization } from "@/lib/db/schema";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import type { SourceApiRecord } from "@/types/config";
|
||||
@@ -26,21 +25,11 @@ import {
|
||||
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,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { OrganizationsViewMode } from "@/lib/utils/organizations-view";
|
||||
import { OrganizationSourceEditor } from "./OrganizationSourceEditor";
|
||||
import { OrganizationActionsMenu } from "./OrganizationActionsMenu";
|
||||
import { OrganizationsTable } from "./OrganizationsTable";
|
||||
import { getOrganizationStatusBadge, resolveOrganizationSource } from "./organization-presentation";
|
||||
|
||||
interface OrganizationListProps {
|
||||
organizations: Organization[];
|
||||
@@ -64,87 +53,18 @@ interface OrganizationListProps {
|
||||
sourceUrl?: string;
|
||||
/** Connected sources; with more than one, each card shows and edits its own source. */
|
||||
sources?: SourceApiRecord[];
|
||||
/** Cards (default) or the compact list (#428). */
|
||||
view?: OrganizationsViewMode;
|
||||
}
|
||||
|
||||
const STATUS_ICONS = { check: Check, alert: AlertCircle, clock: Clock, ban: Ban } as const;
|
||||
|
||||
// Helper function to get status badge variant and icon
|
||||
const getStatusBadge = (status: string | null) => {
|
||||
switch (status) {
|
||||
case "imported":
|
||||
return { variant: "secondary" as const, label: "Not Mirrored", icon: null };
|
||||
case "mirroring":
|
||||
return { variant: "outline" as const, label: "Mirroring", icon: Clock };
|
||||
case "mirrored":
|
||||
return { variant: "default" as const, label: "Mirrored", icon: Check };
|
||||
case "failed":
|
||||
return { variant: "destructive" as const, label: "Failed", icon: AlertCircle };
|
||||
case "ignored":
|
||||
return { variant: "outline" as const, label: "Ignored", icon: Ban };
|
||||
default:
|
||||
return { variant: "secondary" as const, label: "Unknown", icon: null };
|
||||
}
|
||||
const badge = getOrganizationStatusBadge(status);
|
||||
return { ...badge, icon: badge.icon ? STATUS_ICONS[badge.icon] : 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,
|
||||
@@ -160,6 +80,7 @@ export function OrganizationList({
|
||||
sourceProvider = "github",
|
||||
sourceUrl: sourceUrlProp,
|
||||
sources,
|
||||
view = "cards",
|
||||
}: OrganizationListProps) {
|
||||
const { giteaConfig, mirrorOptions, advancedOptions } = useGiteaConfig();
|
||||
const [overridesTarget, setOverridesTarget] = useState<Organization | null>(null);
|
||||
@@ -339,12 +260,43 @@ export function OrganizationList({
|
||||
return result;
|
||||
}, [organizations, filter]);
|
||||
|
||||
const overridesDialog = (
|
||||
<MirrorOverridesDialog
|
||||
open={!!overridesTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setOverridesTarget(null);
|
||||
}}
|
||||
targetKind="organization"
|
||||
targetName={overridesTarget?.name ?? ""}
|
||||
value={overridesTarget?.mirrorOverrides ?? null}
|
||||
destinationProvider={giteaConfig?.provider}
|
||||
inheritedFrom={{
|
||||
...mirrorOptionsToFlags(mirrorOptions),
|
||||
skipForks: !!advancedOptions?.skipForks,
|
||||
}}
|
||||
inheritedLabel="global settings"
|
||||
onSave={async (overrides) => {
|
||||
if (overridesTarget?.id) {
|
||||
await handleUpdateMirrorOverrides(overridesTarget.id, overrides);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(27rem,1fr))] gap-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[11.25rem] w-full" />
|
||||
))}
|
||||
</div>
|
||||
view === "list" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(27rem,1fr))] gap-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[11.25rem] w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : filteredOrganizations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
@@ -373,6 +325,29 @@ export function OrganizationList({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : view === "list" ? (
|
||||
<>
|
||||
<OrganizationsTable
|
||||
organizations={filteredOrganizations}
|
||||
loadingOrgIds={loadingOrgIds}
|
||||
sources={sources}
|
||||
sourceProvider={sourceProvider}
|
||||
sourceUrl={sourceUrl}
|
||||
destination={destination}
|
||||
getDestinationUrl={getGiteaOrgUrl}
|
||||
destinationLinkTooltip={destinationLinkTooltip}
|
||||
defaultDestinationFor={defaultDestinationFor}
|
||||
onUpdateDestination={handleUpdateDestination}
|
||||
onMoveMirrors={destination.isPushTarget ? undefined : handleMoveMirrors}
|
||||
onUpdateSource={handleUpdateSource}
|
||||
onMirror={onMirror}
|
||||
onSync={onSync}
|
||||
onIgnore={onIgnore}
|
||||
onDelete={onDelete}
|
||||
onEditMirrorOptions={setOverridesTarget}
|
||||
/>
|
||||
{overridesDialog}
|
||||
</>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(27rem,1fr))] gap-4 pb-20 sm:pb-0">
|
||||
{filteredOrganizations.map((org, index) => {
|
||||
@@ -380,16 +355,12 @@ export function OrganizationList({
|
||||
const statusBadge = getStatusBadge(org.status);
|
||||
const StatusIcon = statusBadge.icon;
|
||||
const orgSources = sources ?? [];
|
||||
const pinnedSource =
|
||||
orgSources.find((source) => source.id === org.sourceId) ?? null;
|
||||
const isPublicOnlySource = pinnedSource?.token === "";
|
||||
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];
|
||||
const orgSource = resolveOrganizationSource(org, sources, sourceProvider, sourceUrl);
|
||||
const { pinnedSource, isPublicOnlySource } = orgSource;
|
||||
const orgSourceUrl = orgSource.sourceUrl;
|
||||
const orgSourceLabel = orgSource.label;
|
||||
const orgSourceShortLabel = orgSource.shortLabel;
|
||||
const OrgSourceIcon = SOURCE_PROVIDER_ICONS[orgSource.provider];
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -724,51 +695,15 @@ export function OrganizationList({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dropdown menu for additional actions */}
|
||||
{org.status !== "mirroring" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={isLoading} className="h-10 w-10">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onSync && (org.status === "mirrored" || org.status === "failed") && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => org.id && onSync({ orgId: org.id })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setOverridesTarget(org)}>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror Options
|
||||
</DropdownMenuItem>
|
||||
{org.status !== "ignored" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
{org.status !== "ignored" && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => org.id && onDelete(org.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete from Mirror
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<OrganizationActionsMenu
|
||||
org={org}
|
||||
disabled={isLoading}
|
||||
onSync={onSync}
|
||||
onIgnore={onIgnore}
|
||||
onDelete={onDelete}
|
||||
onEditMirrorOptions={setOverridesTarget}
|
||||
triggerClassName="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
@@ -884,51 +819,14 @@ export function OrganizationList({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dropdown menu for additional actions */}
|
||||
{org.status !== "mirroring" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={isLoading}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onSync && (org.status === "mirrored" || org.status === "failed") && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => org.id && onSync({ orgId: org.id })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setOverridesTarget(org)}>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror Options
|
||||
</DropdownMenuItem>
|
||||
{org.status !== "ignored" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => org.id && onDelete(org.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete from Mirror
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<OrganizationActionsMenu
|
||||
org={org}
|
||||
disabled={isLoading}
|
||||
onSync={onSync}
|
||||
onIgnore={onIgnore}
|
||||
onDelete={onDelete}
|
||||
onEditMirrorOptions={setOverridesTarget}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -988,26 +886,7 @@ export function OrganizationList({
|
||||
);
|
||||
})}
|
||||
|
||||
<MirrorOverridesDialog
|
||||
open={!!overridesTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setOverridesTarget(null);
|
||||
}}
|
||||
targetKind="organization"
|
||||
targetName={overridesTarget?.name ?? ""}
|
||||
value={overridesTarget?.mirrorOverrides ?? null}
|
||||
destinationProvider={giteaConfig?.provider}
|
||||
inheritedFrom={{
|
||||
...mirrorOptionsToFlags(mirrorOptions),
|
||||
skipForks: !!advancedOptions?.skipForks,
|
||||
}}
|
||||
inheritedLabel="global settings"
|
||||
onSave={async (overrides) => {
|
||||
if (overridesTarget?.id) {
|
||||
await handleUpdateMirrorOverrides(overridesTarget.id, overrides);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{overridesDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { AlertCircle, Ban, Check, Clock, RefreshCw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { Organization } from "@/lib/db/schema";
|
||||
import type { SourceApiRecord } from "@/types/config";
|
||||
import type { OrganizationMoveResult } from "@/lib/destination-transfer";
|
||||
import type { SourceProviderKind } from "@/lib/source-providers/kinds";
|
||||
import { SOURCE_PROVIDER_ICONS } from "@/lib/source-providers/icons";
|
||||
import { DestinationIcon, type destinationInfo } from "@/components/destination/DestinationIcon";
|
||||
import { cn, formatLastSyncTime } from "@/lib/utils";
|
||||
import { hasMirrorOverrides } from "@/lib/utils/mirror-overrides";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
|
||||
import { OrganizationSourceEditor } from "./OrganizationSourceEditor";
|
||||
import { OrganizationActionsMenu } from "./OrganizationActionsMenu";
|
||||
import {
|
||||
describeRepositoryBreakdown,
|
||||
getOrganizationStatusBadge,
|
||||
resolveOrganizationSource,
|
||||
} from "./organization-presentation";
|
||||
|
||||
const STATUS_ICONS = {
|
||||
check: Check,
|
||||
alert: AlertCircle,
|
||||
clock: Clock,
|
||||
ban: Ban,
|
||||
} as const;
|
||||
|
||||
export interface OrganizationsTableProps {
|
||||
organizations: Organization[];
|
||||
loadingOrgIds: Set<string>;
|
||||
sources?: SourceApiRecord[];
|
||||
sourceProvider: SourceProviderKind;
|
||||
sourceUrl: string;
|
||||
destination: ReturnType<typeof destinationInfo>;
|
||||
getDestinationUrl: (org: Organization) => string | null;
|
||||
destinationLinkTooltip: (org: Organization, url: string | null) => string;
|
||||
defaultDestinationFor: (orgName: string) => string;
|
||||
onUpdateDestination: (orgId: string, destination: string | null) => Promise<void>;
|
||||
onMoveMirrors?: (
|
||||
orgId: string,
|
||||
destination: string | null,
|
||||
dryRun: boolean
|
||||
) => Promise<OrganizationMoveResult>;
|
||||
onUpdateSource: (orgId: string, sourceId: string | null) => Promise<void>;
|
||||
onMirror: ({ orgId }: { orgId: string }) => Promise<void>;
|
||||
onSync?: ({ orgId }: { orgId: string }) => Promise<void>;
|
||||
onIgnore?: ({ orgId, ignore }: { orgId: string; ignore: boolean }) => Promise<void>;
|
||||
onDelete?: (orgId: string) => void;
|
||||
onEditMirrorOptions: (org: Organization) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact list view of the Organizations page (#428): one row per
|
||||
* organization so a few dozen fit on one screen. Same actions as the cards,
|
||||
* with the secondary ones behind the menu.
|
||||
*/
|
||||
export function OrganizationsTable({
|
||||
organizations,
|
||||
loadingOrgIds,
|
||||
sources,
|
||||
sourceProvider,
|
||||
sourceUrl,
|
||||
destination,
|
||||
getDestinationUrl,
|
||||
destinationLinkTooltip,
|
||||
defaultDestinationFor,
|
||||
onUpdateDestination,
|
||||
onMoveMirrors,
|
||||
onUpdateSource,
|
||||
onMirror,
|
||||
onSync,
|
||||
onIgnore,
|
||||
onDelete,
|
||||
onEditMirrorOptions,
|
||||
}: OrganizationsTableProps) {
|
||||
const hasMultipleSources = !!sources && sources.length > 1;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto pb-20 sm:pb-0" data-testid="organizations-table">
|
||||
<div className="min-w-[64rem] border rounded-md">
|
||||
{/* Header */}
|
||||
<div className="h-[45px] flex items-center border-b bg-muted/50 text-sm font-medium">
|
||||
<div className="h-full p-3 flex items-center flex-[2.2] sticky left-0 z-10 bg-background before:absolute before:inset-0 before:-z-10 before:bg-muted/50">
|
||||
Organization
|
||||
</div>
|
||||
{hasMultipleSources && (
|
||||
<div className="h-full p-3 flex items-center flex-[1.1]">Source</div>
|
||||
)}
|
||||
<div className="h-full p-3 flex items-center flex-[1.6]">Destination</div>
|
||||
<div className="h-full p-3 flex items-center flex-[1]">Repositories</div>
|
||||
<div className="h-full p-3 flex items-center flex-[1]">Last Mirrored</div>
|
||||
<div className="h-full p-3 flex items-center flex-[0.9]">Status</div>
|
||||
<div className="h-full p-3 flex items-center flex-[1.3]">Actions</div>
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.6]">Links</div>
|
||||
</div>
|
||||
|
||||
{organizations.map((org) => {
|
||||
const orgId = org.id ?? "";
|
||||
const isLoading = loadingOrgIds.has(orgId);
|
||||
const statusBadge = getOrganizationStatusBadge(org.status);
|
||||
const StatusIcon = statusBadge.icon ? STATUS_ICONS[statusBadge.icon] : null;
|
||||
const source = resolveOrganizationSource(org, sources, sourceProvider, sourceUrl);
|
||||
const SourceIcon = SOURCE_PROVIDER_ICONS[source.provider];
|
||||
const breakdown = describeRepositoryBreakdown(org, "long");
|
||||
const destinationUrl = getDestinationUrl(org);
|
||||
const destinationTooltip = destinationLinkTooltip(org, destinationUrl);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={org.id ?? org.name}
|
||||
data-testid="organization-row"
|
||||
className={cn(
|
||||
"group min-h-[48px] flex items-center border-b last:border-b-0 hover:bg-muted/50",
|
||||
isLoading && "opacity-75"
|
||||
)}
|
||||
>
|
||||
{/* Organization. Pinned to the left edge so the row stays
|
||||
identifiable while a narrow screen scrolls the rest. */}
|
||||
<div className="h-full px-3 py-2 flex items-center gap-2 flex-[2.2] min-w-0 sticky left-0 z-10 bg-background before:absolute before:inset-0 before:-z-10 group-hover:before:bg-muted/50">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<a
|
||||
href={`${withBase("/repositories")}?organization=${encodeURIComponent(org.name || "")}`}
|
||||
className="font-medium hover:underline truncate"
|
||||
title={`Show the repositories of ${org.name}`}
|
||||
>
|
||||
{org.name}
|
||||
</a>
|
||||
<Badge
|
||||
variant={org.membershipRole === "member" ? "secondary" : "default"}
|
||||
className="capitalize shrink-0 px-1.5 text-[11px] font-normal"
|
||||
>
|
||||
{org.membershipRole.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
{source.isPublicOnlySource && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 font-normal text-[11px]"
|
||||
title="Mirrors public repositories only; its source has no account"
|
||||
>
|
||||
Public only
|
||||
</Badge>
|
||||
)}
|
||||
{hasMirrorOverrides(org.mirrorOverrides) && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 px-1.5 text-[11px] font-normal text-amber-600 dark:text-amber-400 border-amber-500/40"
|
||||
title="This organization overrides the global mirror options"
|
||||
>
|
||||
Custom
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{org.status === "failed" && org.errorMessage && (
|
||||
<p
|
||||
className="text-xs text-destructive truncate"
|
||||
title={org.errorMessage}
|
||||
>
|
||||
{org.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source (multi-source only) */}
|
||||
{hasMultipleSources && (
|
||||
<div className="h-full px-3 py-2 flex items-center flex-[1.1] min-w-0">
|
||||
<OrganizationSourceEditor
|
||||
sources={sources ?? []}
|
||||
value={source.pinnedSource?.id ?? null}
|
||||
disabled={isLoading}
|
||||
onUpdateSource={(sourceId) => onUpdateSource(orgId, sourceId)}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Destination */}
|
||||
<div className="h-full px-3 py-2 flex items-center flex-[1.6] min-w-0">
|
||||
<MirrorDestinationEditor
|
||||
organizationId={orgId}
|
||||
organizationName={org.name!}
|
||||
currentDestination={org.destinationOrg ?? undefined}
|
||||
defaultDestination={defaultDestinationFor(org.name!)}
|
||||
onUpdate={(next) => onUpdateDestination(orgId, next)}
|
||||
onMoveMirrors={
|
||||
onMoveMirrors
|
||||
? (next, dryRun) => onMoveMirrors(orgId, next, dryRun)
|
||||
: undefined
|
||||
}
|
||||
destinationLabel={destination.label}
|
||||
isUpdating={isLoading}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Repositories */}
|
||||
<div className="h-full px-3 py-2 flex items-center flex-[1] min-w-0">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold tabular-nums">{org.repositoryCount}</span>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
{org.repositoryCount === 1 ? "repository" : "repositories"}
|
||||
</span>
|
||||
</p>
|
||||
{breakdown.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground truncate" title={breakdown.join(", ")}>
|
||||
{breakdown.join(" | ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Mirrored */}
|
||||
<div className="h-full px-3 py-2 flex items-center flex-[1]">
|
||||
<p className="text-sm">{formatLastSyncTime(org.lastMirrored ?? null)}</p>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="h-full px-3 py-2 flex items-center flex-[0.9]">
|
||||
{org.status === "failed" && org.errorMessage ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="destructive" className="cursor-help flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-sm">{org.errorMessage}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Badge variant={statusBadge.variant} className="flex items-center gap-1">
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
className={cn("h-3 w-3", org.status === "mirroring" && "animate-pulse")}
|
||||
/>
|
||||
)}
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="h-full px-3 py-2 flex items-center gap-1 flex-[1.3]">
|
||||
{org.status === "ignored" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => orgId && onIgnore && onIgnore({ orgId, ignore: false })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1.5" />
|
||||
Include
|
||||
</Button>
|
||||
) : org.status === "imported" ? (
|
||||
<Button size="sm" onClick={() => orgId && onMirror({ orgId })} disabled={isLoading}>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-1.5", isLoading && "animate-spin")} />
|
||||
{isLoading ? "Starting" : "Mirror"}
|
||||
</Button>
|
||||
) : org.status === "mirroring" ? (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<RefreshCw className="h-4 w-4 mr-1.5 animate-spin" />
|
||||
Mirroring
|
||||
</Button>
|
||||
) : org.status === "failed" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => orgId && onMirror({ orgId })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCw className="h-4 w-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 mr-1.5" />
|
||||
)}
|
||||
{isLoading ? "Retrying" : "Retry"}
|
||||
</Button>
|
||||
) : org.status === "mirrored" && onSync ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => orgId && onSync({ orgId })}
|
||||
disabled={isLoading}
|
||||
title="Re-sync this organization now"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-1.5", isLoading && "animate-spin")} />
|
||||
Sync
|
||||
</Button>
|
||||
) : null}
|
||||
<OrganizationActionsMenu
|
||||
org={org}
|
||||
disabled={isLoading}
|
||||
onSync={onSync}
|
||||
onIgnore={onIgnore}
|
||||
onDelete={onDelete}
|
||||
onEditMirrorOptions={onEditMirrorOptions}
|
||||
triggerClassName="h-8 w-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="h-full px-3 py-2 flex items-center justify-center gap-x-1 flex-[0.6]">
|
||||
{destinationUrl ? (
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<a
|
||||
href={destinationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={destinationTooltip}
|
||||
>
|
||||
<DestinationIcon provider={destination.provider} className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" disabled title={destinationTooltip} className="h-8 w-8">
|
||||
<DestinationIcon provider={destination.provider} className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<a
|
||||
href={`${source.sourceUrl}/${org.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`View on ${source.label}`}
|
||||
>
|
||||
<SourceIcon className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Organization } from "@/lib/db/schema";
|
||||
import type { SourceApiRecord } from "@/types/config";
|
||||
import {
|
||||
SOURCE_PROVIDER_LABELS,
|
||||
normalizeSourceUrl,
|
||||
type SourceProviderKind,
|
||||
} from "@/lib/source-providers/kinds";
|
||||
|
||||
export interface OrganizationSourceInfo {
|
||||
/** The source this organization is pinned to, if any. */
|
||||
pinnedSource: SourceApiRecord | null;
|
||||
/** A pinned source with no account mirrors public repositories only. */
|
||||
isPublicOnlySource: boolean;
|
||||
provider: SourceProviderKind;
|
||||
/** Normalized instance URL of the source the organization lives on. */
|
||||
sourceUrl: string;
|
||||
/** Full provider label, for tooltips. */
|
||||
label: string;
|
||||
/** Shorter label for buttons: Gitea and Forgejo sources both read "Gitea". */
|
||||
shortLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which source an organization belongs to, for the cards and the list.
|
||||
* Unpinned organizations follow the configured (or first) source.
|
||||
*/
|
||||
export function resolveOrganizationSource(
|
||||
org: Pick<Organization, "sourceId">,
|
||||
sources: SourceApiRecord[] | undefined,
|
||||
fallbackProvider: SourceProviderKind,
|
||||
fallbackUrl: string
|
||||
): OrganizationSourceInfo {
|
||||
const pinnedSource = (sources ?? []).find((source) => source.id === org.sourceId) ?? null;
|
||||
const provider: SourceProviderKind = pinnedSource?.provider ?? fallbackProvider;
|
||||
const label = SOURCE_PROVIDER_LABELS[provider];
|
||||
return {
|
||||
pinnedSource,
|
||||
isPublicOnlySource: pinnedSource?.token === "",
|
||||
provider,
|
||||
sourceUrl: pinnedSource
|
||||
? normalizeSourceUrl(pinnedSource.url, pinnedSource.provider)
|
||||
: fallbackUrl,
|
||||
label,
|
||||
shortLabel: provider === "gitea" ? "Gitea" : label,
|
||||
};
|
||||
}
|
||||
|
||||
export type OrganizationStatusBadge = {
|
||||
variant: "default" | "secondary" | "outline" | "destructive";
|
||||
label: string;
|
||||
icon: "check" | "alert" | "clock" | "ban" | null;
|
||||
};
|
||||
|
||||
/** Badge variant, label and icon for an organization status. */
|
||||
export function getOrganizationStatusBadge(status: string | null | undefined): OrganizationStatusBadge {
|
||||
switch (status) {
|
||||
case "imported":
|
||||
return { variant: "secondary", label: "Not Mirrored", icon: null };
|
||||
case "mirroring":
|
||||
return { variant: "outline", label: "Mirroring", icon: "clock" };
|
||||
case "mirrored":
|
||||
return { variant: "default", label: "Mirrored", icon: "check" };
|
||||
case "failed":
|
||||
return { variant: "destructive", label: "Failed", icon: "alert" };
|
||||
case "ignored":
|
||||
return { variant: "outline", label: "Ignored", icon: "ban" };
|
||||
default:
|
||||
return { variant: "secondary", label: "Unknown", icon: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** "3 public | 2 private | 1 fork" style breakdown, only the non-zero parts. */
|
||||
export function describeRepositoryBreakdown(
|
||||
org: Pick<Organization, "publicRepositoryCount" | "privateRepositoryCount" | "forkRepositoryCount">,
|
||||
style: "long" | "short" = "long"
|
||||
): string[] {
|
||||
const parts: string[] = [];
|
||||
const pub = org.publicRepositoryCount ?? 0;
|
||||
const priv = org.privateRepositoryCount ?? 0;
|
||||
const forks = org.forkRepositoryCount ?? 0;
|
||||
if (pub > 0) parts.push(style === "long" ? `${pub} public` : `${pub} pub`);
|
||||
if (priv > 0) parts.push(style === "long" ? `${priv} private` : `${priv} priv`);
|
||||
if (forks > 0) {
|
||||
parts.push(
|
||||
style === "long" ? `${forks} ${forks === 1 ? "fork" : "forks"}` : `${forks} fork`
|
||||
);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import {
|
||||
DEFAULT_ORGANIZATIONS_VIEW,
|
||||
getOrganizationsView,
|
||||
setOrganizationsView,
|
||||
subscribeToOrganizationsViewChange,
|
||||
type OrganizationsViewMode,
|
||||
} from "@/lib/utils/organizations-view";
|
||||
|
||||
const getServerSnapshot = (): OrganizationsViewMode => DEFAULT_ORGANIZATIONS_VIEW;
|
||||
|
||||
/**
|
||||
* Cards or list on the Organizations page (#428). The choice is kept in
|
||||
* localStorage and shared across tabs.
|
||||
*/
|
||||
export function useOrganizationsView() {
|
||||
const view = useSyncExternalStore(
|
||||
subscribeToOrganizationsViewChange,
|
||||
getOrganizationsView,
|
||||
getServerSnapshot
|
||||
);
|
||||
|
||||
const setView = useCallback((next: OrganizationsViewMode) => {
|
||||
setOrganizationsView(next);
|
||||
}, []);
|
||||
|
||||
return { view, setView };
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { Config } from "@/types/config";
|
||||
import { isRateLimitError } from "@/lib/rate-limit-gate";
|
||||
import {
|
||||
getRepositorySource,
|
||||
SOURCE_PROVIDER_LABELS,
|
||||
@@ -1072,6 +1073,9 @@ export async function syncGiteaRepoEnhanced({
|
||||
);
|
||||
}
|
||||
} catch (releaseError) {
|
||||
// Rate limited: fail the sync so the scheduler restores the row and
|
||||
// retries after the reset instead of running the next component (#437).
|
||||
if (isRateLimitError(releaseError)) throw releaseError;
|
||||
console.error(
|
||||
`[Sync] Failed to mirror releases for ${repository.name}: ${
|
||||
releaseError instanceof Error
|
||||
@@ -1106,6 +1110,9 @@ export async function syncGiteaRepoEnhanced({
|
||||
`[Sync] Mirrored issues for ${repository.name} after sync`
|
||||
);
|
||||
} catch (issueError) {
|
||||
// Rate limited: fail the sync so the scheduler restores the row and
|
||||
// retries after the reset instead of running the next component (#437).
|
||||
if (isRateLimitError(issueError)) throw issueError;
|
||||
console.error(
|
||||
`[Sync] Failed to mirror issues for ${repository.name}: ${
|
||||
issueError instanceof Error
|
||||
@@ -1140,6 +1147,9 @@ export async function syncGiteaRepoEnhanced({
|
||||
`[Sync] Mirrored pull requests for ${repository.name} after sync`
|
||||
);
|
||||
} catch (prError) {
|
||||
// Rate limited: fail the sync so the scheduler restores the row and
|
||||
// retries after the reset instead of running the next component (#437).
|
||||
if (isRateLimitError(prError)) throw prError;
|
||||
console.error(
|
||||
`[Sync] Failed to mirror pull requests for ${repository.name}: ${
|
||||
prError instanceof Error ? prError.message : String(prError)
|
||||
@@ -1170,6 +1180,9 @@ export async function syncGiteaRepoEnhanced({
|
||||
`[Sync] Mirrored labels for ${repository.name} after sync`
|
||||
);
|
||||
} catch (labelError) {
|
||||
// Rate limited: fail the sync so the scheduler restores the row and
|
||||
// retries after the reset instead of running the next component (#437).
|
||||
if (isRateLimitError(labelError)) throw labelError;
|
||||
console.error(
|
||||
`[Sync] Failed to mirror labels for ${repository.name}: ${
|
||||
labelError instanceof Error
|
||||
@@ -1202,6 +1215,9 @@ export async function syncGiteaRepoEnhanced({
|
||||
`[Sync] Mirrored milestones for ${repository.name} after sync`
|
||||
);
|
||||
} catch (milestoneError) {
|
||||
// Rate limited: fail the sync so the scheduler restores the row and
|
||||
// retries after the reset instead of running the next component (#437).
|
||||
if (isRateLimitError(milestoneError)) throw milestoneError;
|
||||
console.error(
|
||||
`[Sync] Failed to mirror milestones for ${repository.name}: ${
|
||||
milestoneError instanceof Error
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
findSourceForRepository,
|
||||
listSources,
|
||||
} from "./sources";
|
||||
import { isRateLimitError } from "./rate-limit-gate";
|
||||
import {
|
||||
advanceMetadataSyncCursor,
|
||||
parseRepositoryMetadataState,
|
||||
@@ -1160,6 +1161,10 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A rate limit is not this repository's fault and the other
|
||||
// components would run into the same wall: fail the mirror so the
|
||||
// scheduler puts the row back and retries after the reset (#437).
|
||||
if (isRateLimitError(error)) throw error;
|
||||
console.error(
|
||||
`[Metadata] Failed to mirror releases for ${repository.name}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -1197,6 +1202,10 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
`[Metadata] Successfully mirrored issues for ${repository.name}`
|
||||
);
|
||||
} catch (error) {
|
||||
// A rate limit is not this repository's fault and the other
|
||||
// components would run into the same wall: fail the mirror so the
|
||||
// scheduler puts the row back and retries after the reset (#437).
|
||||
if (isRateLimitError(error)) throw error;
|
||||
console.error(
|
||||
`[Metadata] Failed to mirror issues for ${repository.name}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -1229,6 +1238,10 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
`[Metadata] Successfully mirrored pull requests for ${repository.name}`
|
||||
);
|
||||
} catch (error) {
|
||||
// A rate limit is not this repository's fault and the other
|
||||
// components would run into the same wall: fail the mirror so the
|
||||
// scheduler puts the row back and retries after the reset (#437).
|
||||
if (isRateLimitError(error)) throw error;
|
||||
console.error(
|
||||
`[Metadata] Failed to mirror pull requests for ${repository.name}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -1261,6 +1274,10 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
`[Metadata] Successfully mirrored labels for ${repository.name}`
|
||||
);
|
||||
} catch (error) {
|
||||
// A rate limit is not this repository's fault and the other
|
||||
// components would run into the same wall: fail the mirror so the
|
||||
// scheduler puts the row back and retries after the reset (#437).
|
||||
if (isRateLimitError(error)) throw error;
|
||||
console.error(
|
||||
`[Metadata] Failed to mirror labels for ${repository.name}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -1291,6 +1308,10 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
`[Metadata] Successfully mirrored milestones for ${repository.name}`
|
||||
);
|
||||
} catch (error) {
|
||||
// A rate limit is not this repository's fault and the other
|
||||
// components would run into the same wall: fail the mirror so the
|
||||
// scheduler puts the row back and retries after the reset (#437).
|
||||
if (isRateLimitError(error)) throw error;
|
||||
console.error(
|
||||
`[Metadata] Failed to mirror milestones for ${repository.name}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -1938,6 +1959,10 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A rate limit is not this repository's fault and the other
|
||||
// components would run into the same wall: fail the mirror so the
|
||||
// scheduler puts the row back and retries after the reset (#437).
|
||||
if (isRateLimitError(error)) throw error;
|
||||
console.error(
|
||||
`[Metadata] Failed to mirror releases for ${repository.name}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -1973,6 +1998,10 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
`[Metadata] Successfully mirrored issues for ${repository.name} to org ${orgName}/${targetRepoName}`
|
||||
);
|
||||
} catch (error) {
|
||||
// A rate limit is not this repository's fault and the other
|
||||
// components would run into the same wall: fail the mirror so the
|
||||
// scheduler puts the row back and retries after the reset (#437).
|
||||
if (isRateLimitError(error)) throw error;
|
||||
console.error(
|
||||
`[Metadata] Failed to mirror issues for ${repository.name} to org ${orgName}/${targetRepoName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -2005,6 +2034,10 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
`[Metadata] Successfully mirrored pull requests for ${repository.name} to org ${orgName}/${targetRepoName}`
|
||||
);
|
||||
} catch (error) {
|
||||
// A rate limit is not this repository's fault and the other
|
||||
// components would run into the same wall: fail the mirror so the
|
||||
// scheduler puts the row back and retries after the reset (#437).
|
||||
if (isRateLimitError(error)) throw error;
|
||||
console.error(
|
||||
`[Metadata] Failed to mirror pull requests for ${repository.name} to org ${orgName}/${targetRepoName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -2037,6 +2070,10 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
`[Metadata] Successfully mirrored labels for ${repository.name} to org ${orgName}/${targetRepoName}`
|
||||
);
|
||||
} catch (error) {
|
||||
// A rate limit is not this repository's fault and the other
|
||||
// components would run into the same wall: fail the mirror so the
|
||||
// scheduler puts the row back and retries after the reset (#437).
|
||||
if (isRateLimitError(error)) throw error;
|
||||
console.error(
|
||||
`[Metadata] Failed to mirror labels for ${repository.name} to org ${orgName}/${targetRepoName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -2067,6 +2104,10 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
`[Metadata] Successfully mirrored milestones for ${repository.name} to org ${orgName}/${targetRepoName}`
|
||||
);
|
||||
} catch (error) {
|
||||
// A rate limit is not this repository's fault and the other
|
||||
// components would run into the same wall: fail the mirror so the
|
||||
// scheduler puts the row back and retries after the reset (#437).
|
||||
if (isRateLimitError(error)) throw error;
|
||||
console.error(
|
||||
`[Metadata] Failed to mirror milestones for ${repository.name} to org ${orgName}/${targetRepoName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -4487,6 +4528,9 @@ export async function mirrorGitRepoPullRequestsToGitea({
|
||||
successCount++;
|
||||
console.log(`[Pull Requests] ✅ Successfully created issue for PR #${pr.number}`);
|
||||
} catch (apiError) {
|
||||
// A rate limit refusal is not a reason to write a stripped down
|
||||
// issue; let it end the pass so the next run redoes this PR (#437).
|
||||
if (isRateLimitError(apiError)) throw apiError;
|
||||
// If the detailed fetch fails, fall back to basic PR info
|
||||
console.log(`[Pull Requests] Falling back to basic info for PR #${pr.number} due to error: ${apiError}`);
|
||||
const basicIssueData = {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The GitHub clients built by github.ts hold their requests while the gate
|
||||
* is closed, and the secondary limit handler closes the gate for everyone
|
||||
* (issue #437, second report). Uses a fake global fetch so nothing leaves
|
||||
* the process; the point of these tests is that nothing does.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createGitHubClient,
|
||||
createPublicGitHubClient,
|
||||
githubThrottleOptions,
|
||||
} from "@/lib/github";
|
||||
import {
|
||||
RateLimitPausedError,
|
||||
clearAllRateLimits,
|
||||
isRateLimitError,
|
||||
markRateLimited,
|
||||
rateLimitGateKey,
|
||||
rateLimitedUntil,
|
||||
} from "@/lib/rate-limit-gate";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
describe("GitHub clients while the gate is closed", () => {
|
||||
let sent: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllRateLimits();
|
||||
sent = [];
|
||||
globalThis.fetch = (async (input: any) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
sent.push(url);
|
||||
return new Response(JSON.stringify({ resources: {}, rate: { remaining: 1 } }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-ratelimit-remaining": "4999",
|
||||
"x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + 3600),
|
||||
},
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
clearAllRateLimits();
|
||||
});
|
||||
|
||||
test("an authenticated client sends nothing during a long pause and fails with the paused error", async () => {
|
||||
const octokit = createGitHubClient("ghp_test", "user-1");
|
||||
markRateLimited(rateLimitGateKey("user-1"), Date.now() + 3_263_000);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await octokit.rest.repos.get({ owner: "a", repo: "b" });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(RateLimitPausedError);
|
||||
expect(isRateLimitError(caught)).toBe(true);
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
test("paginate stops at the gate too, before the first page", async () => {
|
||||
const octokit = createGitHubClient("ghp_test", "user-2");
|
||||
markRateLimited(rateLimitGateKey("user-2"), Date.now() + 3_600_000);
|
||||
|
||||
await expect(
|
||||
octokit.paginate(octokit.rest.issues.listForRepo, { owner: "a", repo: "b", per_page: 100 })
|
||||
).rejects.toBeInstanceOf(RateLimitPausedError);
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
test("the rate limit probe still goes out, so the pause can be confirmed or lifted", async () => {
|
||||
const octokit = createGitHubClient("ghp_test", "user-3");
|
||||
markRateLimited(rateLimitGateKey("user-3"), Date.now() + 3_600_000);
|
||||
|
||||
await octokit.rest.rateLimit.get();
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sent[0]).toContain("/rate_limit");
|
||||
// The probe answered with budget left, which opens the gate again.
|
||||
expect(rateLimitedUntil(rateLimitGateKey("user-3"))).toBeNull();
|
||||
await octokit.rest.repos.get({ owner: "a", repo: "b" });
|
||||
expect(sent).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("another user's pause does not hold this user's client", async () => {
|
||||
const octokit = createGitHubClient("ghp_test", "user-4");
|
||||
markRateLimited(rateLimitGateKey("user-5"), Date.now() + 3_600_000);
|
||||
|
||||
await octokit.rest.repos.get({ owner: "a", repo: "b" });
|
||||
expect(sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("tokenless clients share the anonymous gate", async () => {
|
||||
const octokit = createPublicGitHubClient();
|
||||
markRateLimited(rateLimitGateKey(undefined), Date.now() + 3_600_000);
|
||||
|
||||
await expect(octokit.rest.repos.get({ owner: "a", repo: "b" })).rejects.toBeInstanceOf(
|
||||
RateLimitPausedError
|
||||
);
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("secondary rate limit handler", () => {
|
||||
beforeEach(() => {
|
||||
clearAllRateLimits();
|
||||
});
|
||||
|
||||
test("closes the gate for the retry-after window and still retries the request itself", async () => {
|
||||
const before = Date.now();
|
||||
const options = githubThrottleOptions("user-secondary");
|
||||
const retry = await options.onSecondaryRateLimit(
|
||||
60,
|
||||
{ method: "GET", url: "/repos/a/b/issues" },
|
||||
{},
|
||||
0
|
||||
);
|
||||
expect(retry).toBe(true);
|
||||
|
||||
const until = rateLimitedUntil(rateLimitGateKey("user-secondary"));
|
||||
expect(until).not.toBeNull();
|
||||
const pausedFor = until!.getTime() - before;
|
||||
expect(pausedFor).toBeGreaterThanOrEqual(59_000);
|
||||
expect(pausedFor).toBeLessThanOrEqual(61_000);
|
||||
});
|
||||
|
||||
test("gives up after two retries but the gate stays closed for the window", async () => {
|
||||
const options = githubThrottleOptions("user-secondary-2");
|
||||
expect(await options.onSecondaryRateLimit(30, { method: "GET", url: "/x" }, {}, 2)).toBe(false);
|
||||
expect(rateLimitedUntil(rateLimitGateKey("user-secondary-2"))).not.toBeNull();
|
||||
});
|
||||
|
||||
test("a tokenless client records the pause under the anonymous key", async () => {
|
||||
const options = githubThrottleOptions();
|
||||
await options.onSecondaryRateLimit(10, { method: "GET", url: "/x" }, {}, 0);
|
||||
expect(rateLimitedUntil(rateLimitGateKey(undefined))).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+24
-2
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/lib/github-conditional-requests";
|
||||
import {
|
||||
clearRateLimit,
|
||||
installRateLimitGate,
|
||||
markRateLimited,
|
||||
rateLimitGateKey,
|
||||
} from "@/lib/rate-limit-gate";
|
||||
@@ -80,7 +81,7 @@ export function decideRateLimitRetry({
|
||||
* handlers only log and schedule the retry — there is no per-user
|
||||
* rate-limit state or UI event to update for a tokenless source.
|
||||
*/
|
||||
function githubThrottleOptions(userId?: string) {
|
||||
export function githubThrottleOptions(userId?: string) {
|
||||
return {
|
||||
onRateLimit: async (
|
||||
retryAfter: number,
|
||||
@@ -161,6 +162,11 @@ function githubThrottleOptions(userId?: string) {
|
||||
`[GitHub] Secondary rate limit hit for ${options.method} ${options.url}`,
|
||||
);
|
||||
|
||||
// A secondary limit is GitHub asking the whole client to slow down,
|
||||
// not only this request. Close the gate for the retry-after window so
|
||||
// the other queued calls wait instead of piling on (issue #437).
|
||||
markRateLimited(rateLimitGateKey(userId), Date.now() + retryAfter * 1000);
|
||||
|
||||
// Update status and notify UI (if available)
|
||||
if (userId && publishEvent) {
|
||||
await publishEvent({
|
||||
@@ -306,6 +312,13 @@ export function createGitHubClient(
|
||||
scope: userId ?? username ?? conditionalRequestTokenScope(token),
|
||||
});
|
||||
|
||||
// Registered last so it runs first: a closed gate is answered before the
|
||||
// conditional request cache and before anything reaches the network.
|
||||
installRateLimitGate(octokit, {
|
||||
key: rateLimitGateKey(userId),
|
||||
maxWaitMs: RATE_LIMIT_FAST_FAIL_THRESHOLD_SECONDS * 1000,
|
||||
});
|
||||
|
||||
return octokit;
|
||||
}
|
||||
|
||||
@@ -328,7 +341,7 @@ export function createPublicGitHubClient(apiBaseUrl?: string): Octokit {
|
||||
// with GitHub Actions, which sets it to https://api.github.com by default).
|
||||
const baseUrl = apiBaseUrl || process.env.GH_API_URL || process.env.GITHUB_API_URL || "https://api.github.com";
|
||||
|
||||
return new MyOctokit({
|
||||
const octokit = new MyOctokit({
|
||||
userAgent: "gitea-mirror/3.5.4",
|
||||
baseUrl, // Configurable for E2E testing
|
||||
log: {
|
||||
@@ -345,6 +358,15 @@ export function createPublicGitHubClient(apiBaseUrl?: string): Octokit {
|
||||
},
|
||||
throttle: githubThrottleOptions(),
|
||||
});
|
||||
|
||||
// Anonymous clients share one gate key, so one exhausted 60 req/hr budget
|
||||
// pauses every tokenless source together.
|
||||
installRateLimitGate(octokit, {
|
||||
key: rateLimitGateKey(undefined),
|
||||
maxWaitMs: RATE_LIMIT_FAST_FAIL_THRESHOLD_SECONDS * 1000,
|
||||
});
|
||||
|
||||
return octokit;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* The request wrap that holds every GitHub call while the gate is closed
|
||||
* (issue #437, second report). Tested against a hand made hook so the
|
||||
* clock and the sleep are under control.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
RateLimitPausedError,
|
||||
clearAllRateLimits,
|
||||
installRateLimitGate,
|
||||
isRateLimitError,
|
||||
markRateLimited,
|
||||
} from "@/lib/rate-limit-gate";
|
||||
|
||||
type Wrapper = (request: (o: any) => Promise<any>, options: any) => Promise<any>;
|
||||
|
||||
function fakeOctokit() {
|
||||
const holder: { wrapper?: Wrapper } = {};
|
||||
return {
|
||||
holder,
|
||||
octokit: {
|
||||
hook: {
|
||||
wrap: (_name: string, wrapper: Wrapper) => {
|
||||
holder.wrapper = wrapper;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("installRateLimitGate", () => {
|
||||
const key = "user-gate";
|
||||
const base = new Date("2026-09-24T10:00:00.000Z");
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllRateLimits();
|
||||
});
|
||||
|
||||
test("an open gate passes the request straight through", async () => {
|
||||
const { octokit, holder } = fakeOctokit();
|
||||
const sleeps: number[] = [];
|
||||
installRateLimitGate(octokit, { key, maxWaitMs: 120_000, sleep: async (ms) => void sleeps.push(ms), now: () => base });
|
||||
|
||||
const sent: any[] = [];
|
||||
const result = await holder.wrapper!(async (o) => { sent.push(o); return "ok"; }, { method: "GET", url: "/repos/a/b" });
|
||||
expect(result).toBe("ok");
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sleeps).toEqual([]);
|
||||
});
|
||||
|
||||
test("a long pause fails the request without calling the inner request", async () => {
|
||||
const { octokit, holder } = fakeOctokit();
|
||||
installRateLimitGate(octokit, { key, maxWaitMs: 120_000, sleep: async () => {}, now: () => base });
|
||||
markRateLimited(key, base.getTime() + 3_263_000);
|
||||
|
||||
let inner = 0;
|
||||
let caught: unknown;
|
||||
try {
|
||||
await holder.wrapper!(async () => { inner += 1; return "no"; }, { method: "GET", url: "/repos/a/b/issues" });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(inner).toBe(0);
|
||||
expect(caught).toBeInstanceOf(RateLimitPausedError);
|
||||
expect((caught as RateLimitPausedError).status).toBe(403);
|
||||
expect((caught as RateLimitPausedError).message).toContain("paused until 2026-09-24T10:54:23.000Z");
|
||||
expect((caught as RateLimitPausedError).message).toContain("GET /repos/a/b/issues");
|
||||
// The rest of the code base classifies it like GitHub's own refusal.
|
||||
expect(isRateLimitError(caught)).toBe(true);
|
||||
});
|
||||
|
||||
test("a short pause is slept off with a one second cushion, then the request goes out", async () => {
|
||||
const { octokit, holder } = fakeOctokit();
|
||||
const sleeps: number[] = [];
|
||||
installRateLimitGate(octokit, { key, maxWaitMs: 120_000, sleep: async (ms) => void sleeps.push(ms), now: () => base });
|
||||
markRateLimited(key, base.getTime() + 45_000);
|
||||
|
||||
let inner = 0;
|
||||
const result = await holder.wrapper!(async () => { inner += 1; return "sent"; }, { method: "GET", url: "/repos/a/b" });
|
||||
expect(result).toBe("sent");
|
||||
expect(inner).toBe(1);
|
||||
expect(sleeps).toEqual([46_000]);
|
||||
});
|
||||
|
||||
test("exactly the threshold still waits; one millisecond more fails", async () => {
|
||||
const { octokit, holder } = fakeOctokit();
|
||||
const sleeps: number[] = [];
|
||||
installRateLimitGate(octokit, { key, maxWaitMs: 120_000, sleep: async (ms) => void sleeps.push(ms), now: () => base });
|
||||
|
||||
markRateLimited(key, base.getTime() + 120_000);
|
||||
await holder.wrapper!(async () => "a", { method: "GET", url: "/x" });
|
||||
expect(sleeps).toEqual([121_000]);
|
||||
|
||||
clearAllRateLimits();
|
||||
markRateLimited(key, base.getTime() + 120_001);
|
||||
await expect(holder.wrapper!(async () => "b", { method: "GET", url: "/x" })).rejects.toBeInstanceOf(RateLimitPausedError);
|
||||
});
|
||||
|
||||
test("the rate limit probe is exempt so the pause can be checked", async () => {
|
||||
const { octokit, holder } = fakeOctokit();
|
||||
installRateLimitGate(octokit, { key, maxWaitMs: 120_000, sleep: async () => {}, now: () => base });
|
||||
markRateLimited(key, base.getTime() + 3_600_000);
|
||||
|
||||
let inner = 0;
|
||||
await holder.wrapper!(async () => { inner += 1; return "probe"; }, { method: "GET", url: "/rate_limit" });
|
||||
expect(inner).toBe(1);
|
||||
await expect(holder.wrapper!(async () => "no", { method: "GET", url: "/rate_limited_looking/but/not" })).rejects.toBeInstanceOf(RateLimitPausedError);
|
||||
});
|
||||
|
||||
test("keys are independent: another user's pause does not hold this client", async () => {
|
||||
const { octokit, holder } = fakeOctokit();
|
||||
installRateLimitGate(octokit, { key, maxWaitMs: 120_000, sleep: async () => {}, now: () => base });
|
||||
markRateLimited("someone-else", base.getTime() + 3_600_000);
|
||||
|
||||
let inner = 0;
|
||||
await holder.wrapper!(async () => { inner += 1; return "ok"; }, { method: "GET", url: "/repos/a/b" });
|
||||
expect(inner).toBe(1);
|
||||
});
|
||||
|
||||
test("a client without a hook system is left alone", () => {
|
||||
expect(() => installRateLimitGate({}, { key, maxWaitMs: 1 })).not.toThrow();
|
||||
expect(() => installRateLimitGate(undefined, { key, maxWaitMs: 1 })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -112,3 +112,84 @@ export function isRateLimitError(error: unknown): boolean {
|
||||
// all that is left to go on.
|
||||
return status === undefined || status === 403;
|
||||
}
|
||||
|
||||
/**
|
||||
* The error a gated request fails with before anything is sent. Shaped like
|
||||
* Octokit's own primary rate limit refusal (status 403, "rate limit" in the
|
||||
* message) so every existing `isRateLimitError` check treats it the same.
|
||||
*/
|
||||
export class RateLimitPausedError extends Error {
|
||||
readonly status = 403;
|
||||
readonly resetAt: Date;
|
||||
|
||||
constructor(resetAt: Date, endpoint?: string) {
|
||||
super(
|
||||
`GitHub API rate limit exceeded; requests are paused until ${resetAt.toISOString()}` +
|
||||
(endpoint ? ` (not sent: ${endpoint})` : "")
|
||||
);
|
||||
this.name = "RateLimitPausedError";
|
||||
this.resetAt = resetAt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoints that never count against the limit and are needed to learn when
|
||||
* it opens again. Everything else is held while the gate is closed.
|
||||
*/
|
||||
const GATE_EXEMPT_ROUTES = [/\/rate_limit(\?|$)/];
|
||||
|
||||
export interface RateLimitGateOptions {
|
||||
/** Gate key of this client, from rateLimitGateKey(). */
|
||||
key: string;
|
||||
/**
|
||||
* A closed gate that opens again within this many milliseconds is waited
|
||||
* out inside the request instead of failing it, so a short primary or
|
||||
* secondary limit stays invisible to the caller. Longer waits fail at once.
|
||||
*/
|
||||
maxWaitMs: number;
|
||||
/** Injectable for tests. */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold every request of an Octokit client while its gate is closed
|
||||
* (issue #437, second report).
|
||||
*
|
||||
* The throttle handler records a reset time when a request is refused, but
|
||||
* on its own that only affects the one request: every other call queued in
|
||||
* the same run still went to GitHub and came back 403, and the retry helper
|
||||
* sent each of them three more times. GitHub counts requests made while
|
||||
* limited toward abuse detection, and one reporter's account was suspended
|
||||
* that way. This wrap runs before the network: a closed gate either waits
|
||||
* (short reset) or throws (long reset), and nothing leaves the process.
|
||||
*/
|
||||
export function installRateLimitGate(octokit: unknown, options: RateLimitGateOptions): void {
|
||||
const hook = (octokit as { hook?: { wrap?: unknown } })?.hook;
|
||||
if (typeof hook?.wrap !== "function") return;
|
||||
|
||||
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
||||
const now = options.now ?? (() => new Date());
|
||||
|
||||
(hook.wrap as (name: string, wrapper: (request: any, requestOptions: any) => Promise<any>) => void)(
|
||||
"request",
|
||||
async (request, requestOptions) => {
|
||||
const url = String(requestOptions?.url ?? "");
|
||||
if (GATE_EXEMPT_ROUTES.some((route) => route.test(url))) {
|
||||
return request(requestOptions);
|
||||
}
|
||||
|
||||
const until = rateLimitedUntil(options.key, now());
|
||||
if (until) {
|
||||
const waitMs = until.getTime() - now().getTime();
|
||||
if (waitMs > options.maxWaitMs) {
|
||||
throw new RateLimitPausedError(until, `${requestOptions?.method ?? "GET"} ${url}`);
|
||||
}
|
||||
// Small cushion: GitHub's reset is second precision.
|
||||
await sleep(waitMs + 1000);
|
||||
}
|
||||
|
||||
return request(requestOptions);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* processWithRetry and a rate limited source (issue #437, second report).
|
||||
* A refusal used to be retried three times with backoff like any other
|
||||
* error, and the items behind it were started anyway, so one exhausted
|
||||
* budget turned into thousands of 403 responses.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { RateLimitedBatchError, processWithRetry } from "./concurrency";
|
||||
import { isRateLimitError } from "@/lib/rate-limit-gate";
|
||||
|
||||
function rateLimitRefusal() {
|
||||
const error = new Error("API rate limit exceeded for user ID 1.") as Error & { status: number };
|
||||
error.status = 403;
|
||||
return error;
|
||||
}
|
||||
|
||||
describe("processWithRetry under a rate limit", () => {
|
||||
test("a rate limit refusal is not retried and the remaining items are not started", async () => {
|
||||
const attempts: number[] = [];
|
||||
let caught: unknown;
|
||||
try {
|
||||
await processWithRetry(
|
||||
[1, 2, 3, 4, 5, 6],
|
||||
async (n) => {
|
||||
attempts.push(n);
|
||||
if (n === 2) throw rateLimitRefusal();
|
||||
return n * 10;
|
||||
},
|
||||
{ concurrencyLimit: 2, maxRetries: 3, retryDelay: 1 }
|
||||
);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(RateLimitedBatchError);
|
||||
const failure = caught as RateLimitedBatchError<number>;
|
||||
// Item 2 was attempted exactly once; items 3 to 6 never started. The two
|
||||
// items of the first batch run concurrently, so their order is not fixed.
|
||||
expect([...attempts].sort()).toEqual([1, 2]);
|
||||
expect(failure.results).toEqual([10]);
|
||||
expect(failure.message).toContain("5 item(s) left");
|
||||
expect(failure.status).toBe(403);
|
||||
// Callers up the stack classify it like the refusal that caused it.
|
||||
expect(isRateLimitError(failure)).toBe(true);
|
||||
expect(isRateLimitError(failure.cause)).toBe(true);
|
||||
});
|
||||
|
||||
test("a 429 from a paginated call counts as a rate limit too", async () => {
|
||||
const attempts: number[] = [];
|
||||
const tooMany = Object.assign(new Error("Too many requests"), { status: 429 });
|
||||
await expect(
|
||||
processWithRetry(
|
||||
[1, 2, 3],
|
||||
async (n) => {
|
||||
attempts.push(n);
|
||||
if (n === 1) throw tooMany;
|
||||
return n;
|
||||
},
|
||||
{ concurrencyLimit: 1, maxRetries: 3, retryDelay: 1 }
|
||||
)
|
||||
).rejects.toBeInstanceOf(RateLimitedBatchError);
|
||||
expect(attempts).toEqual([1]);
|
||||
});
|
||||
|
||||
test("other errors keep their retries and do not stop the batch", async () => {
|
||||
const attempts: number[] = [];
|
||||
const results = await processWithRetry(
|
||||
[1, 2, 3],
|
||||
async (n) => {
|
||||
attempts.push(n);
|
||||
if (n === 2 && attempts.filter((a) => a === 2).length < 3) {
|
||||
throw new Error("Gitea hiccup");
|
||||
}
|
||||
return n;
|
||||
},
|
||||
{ concurrencyLimit: 1, maxRetries: 3, retryDelay: 1 }
|
||||
);
|
||||
expect(results).toEqual([1, 2, 3]);
|
||||
expect(attempts).toEqual([1, 2, 2, 2, 3]);
|
||||
});
|
||||
|
||||
test("a batch that finishes without a refusal returns every result as before", async () => {
|
||||
const results = await processWithRetry([1, 2, 3], async (n) => n + 1, {
|
||||
concurrencyLimit: 3,
|
||||
maxRetries: 0,
|
||||
retryDelay: 1,
|
||||
});
|
||||
expect(results).toEqual([2, 3, 4]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,26 @@
|
||||
import { JOB_HEARTBEAT_INTERVAL_MS } from "@/lib/interrupted-job-detection";
|
||||
import { isRateLimitError } from "@/lib/rate-limit-gate";
|
||||
|
||||
/**
|
||||
* Thrown by processWithRetry when a batch stopped because the source is rate
|
||||
* limited. Carries the results gathered so far so a caller can still count
|
||||
* what was done. Shaped like a GitHub refusal so isRateLimitError matches it.
|
||||
*/
|
||||
export class RateLimitedBatchError<R = unknown> extends Error {
|
||||
readonly status = 403;
|
||||
readonly results: R[];
|
||||
readonly cause: unknown;
|
||||
|
||||
constructor(cause: unknown, results: R[], remaining: number) {
|
||||
super(
|
||||
`Rate limit hit while processing items; stopped with ${remaining} item(s) left ` +
|
||||
`(${cause instanceof Error ? cause.message : String(cause)})`
|
||||
);
|
||||
this.name = "RateLimitedBatchError";
|
||||
this.cause = cause;
|
||||
this.results = results;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility for processing items in parallel with concurrency control
|
||||
@@ -13,7 +35,12 @@ export async function processInParallel<T, R>(
|
||||
items: T[],
|
||||
processItem: (item: T) => Promise<R>,
|
||||
concurrencyLimit: number = 5, // Safe default for GitHub API (max 100 concurrent, but 5-10 recommended)
|
||||
onProgress?: (completed: number, total: number, result?: R) => void
|
||||
onProgress?: (completed: number, total: number, result?: R) => void,
|
||||
/**
|
||||
* Asked before each batch. Returning true ends the loop with the results
|
||||
* gathered so far; the remaining items are not started.
|
||||
*/
|
||||
shouldStop?: () => boolean
|
||||
): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
let completed = 0;
|
||||
@@ -21,6 +48,11 @@ export async function processInParallel<T, R>(
|
||||
|
||||
// Process items in batches to control concurrency
|
||||
for (let i = 0; i < total; i += concurrencyLimit) {
|
||||
if (shouldStop?.()) {
|
||||
console.warn(`[Batch] Stopping before item ${i} of ${total}: source is rate limited`);
|
||||
break;
|
||||
}
|
||||
|
||||
const batch = items.slice(i, i + concurrencyLimit);
|
||||
|
||||
const batchPromises = batch.map(async (item) => {
|
||||
@@ -119,6 +151,11 @@ export async function processWithRetry<T, R>(
|
||||
// Track checkpoint counter
|
||||
let itemsProcessedSinceLastCheckpoint = 0;
|
||||
|
||||
// The first rate limit refusal ends the run: retrying it, or starting the
|
||||
// items behind it, would only send more requests into the same wall
|
||||
// (issue #437). The caller gets a RateLimitedBatchError with what finished.
|
||||
let rateLimitError: unknown = null;
|
||||
|
||||
// Wrap the process function with retry logic
|
||||
const processWithRetryLogic = async (item: T): Promise<R> => {
|
||||
let lastError: Error | null = null;
|
||||
@@ -153,6 +190,16 @@ export async function processWithRetry<T, R>(
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (isRateLimitError(error)) {
|
||||
if (!rateLimitError) {
|
||||
rateLimitError = error;
|
||||
console.warn(
|
||||
`[Batch] Rate limit hit on ${getItemId ? getItemId(item) : "an item"}; not retrying and not starting the remaining items`
|
||||
);
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries) {
|
||||
if (onRetry) {
|
||||
onRetry(item, lastError, attempt);
|
||||
@@ -190,9 +237,14 @@ export async function processWithRetry<T, R>(
|
||||
items,
|
||||
processWithRetryLogic,
|
||||
concurrencyLimit,
|
||||
onProgress
|
||||
onProgress,
|
||||
() => rateLimitError !== null
|
||||
);
|
||||
|
||||
if (rateLimitError) {
|
||||
throw new RateLimitedBatchError(rateLimitError, results, items.length - results.length);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
DEFAULT_ORGANIZATIONS_VIEW,
|
||||
ORGANIZATIONS_VIEW_CHANGE_EVENT,
|
||||
ORGANIZATIONS_VIEW_STORAGE_KEY,
|
||||
getOrganizationsView,
|
||||
isOrganizationsViewMode,
|
||||
setOrganizationsView,
|
||||
subscribeToOrganizationsViewChange,
|
||||
} from "./organizations-view";
|
||||
|
||||
// A minimal window and localStorage so the helper can be exercised
|
||||
// without a DOM library. Both are removed again afterwards.
|
||||
function installBrowserGlobals() {
|
||||
const store = new Map<string, string>();
|
||||
const listeners = new Map<string, Set<(event: any) => void>>();
|
||||
(globalThis as any).localStorage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void store.set(key, value),
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
};
|
||||
(globalThis as any).window = {
|
||||
addEventListener: (type: string, cb: (event: any) => void) => {
|
||||
if (!listeners.has(type)) listeners.set(type, new Set());
|
||||
listeners.get(type)!.add(cb);
|
||||
},
|
||||
removeEventListener: (type: string, cb: (event: any) => void) => {
|
||||
listeners.get(type)?.delete(cb);
|
||||
},
|
||||
dispatchEvent: (event: any) => {
|
||||
listeners.get(event.type)?.forEach((cb) => cb(event));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
if (typeof (globalThis as any).CustomEvent === "undefined") {
|
||||
(globalThis as any).CustomEvent = class CustomEvent {
|
||||
type: string;
|
||||
detail: unknown;
|
||||
constructor(type: string, init?: { detail?: unknown }) {
|
||||
this.type = type;
|
||||
this.detail = init?.detail;
|
||||
}
|
||||
};
|
||||
}
|
||||
return { store, listeners };
|
||||
}
|
||||
|
||||
describe("organizations view preference", () => {
|
||||
const hadWindow = typeof (globalThis as any).window !== "undefined";
|
||||
const hadStorage = typeof (globalThis as any).localStorage !== "undefined";
|
||||
const savedWindow = (globalThis as any).window;
|
||||
const savedStorage = (globalThis as any).localStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
delete (globalThis as any).window;
|
||||
delete (globalThis as any).localStorage;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (hadWindow) (globalThis as any).window = savedWindow;
|
||||
else delete (globalThis as any).window;
|
||||
if (hadStorage) (globalThis as any).localStorage = savedStorage;
|
||||
else delete (globalThis as any).localStorage;
|
||||
});
|
||||
|
||||
it("falls back to cards without a browser", () => {
|
||||
expect(getOrganizationsView()).toBe(DEFAULT_ORGANIZATIONS_VIEW);
|
||||
expect(DEFAULT_ORGANIZATIONS_VIEW).toBe("cards");
|
||||
// Setting without a window must not throw either.
|
||||
expect(() => setOrganizationsView("list")).not.toThrow();
|
||||
});
|
||||
|
||||
it("only accepts the two known modes", () => {
|
||||
expect(isOrganizationsViewMode("cards")).toBe(true);
|
||||
expect(isOrganizationsViewMode("list")).toBe(true);
|
||||
expect(isOrganizationsViewMode("table")).toBe(false);
|
||||
expect(isOrganizationsViewMode(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("persists the choice and ignores garbage in storage", () => {
|
||||
const { store } = installBrowserGlobals();
|
||||
expect(getOrganizationsView()).toBe("cards");
|
||||
|
||||
setOrganizationsView("list");
|
||||
expect(store.get(ORGANIZATIONS_VIEW_STORAGE_KEY)).toBe("list");
|
||||
expect(getOrganizationsView()).toBe("list");
|
||||
|
||||
store.set(ORGANIZATIONS_VIEW_STORAGE_KEY, "sideways");
|
||||
expect(getOrganizationsView()).toBe("cards");
|
||||
});
|
||||
|
||||
it("notifies subscribers in this tab and from the storage event", () => {
|
||||
const { listeners } = installBrowserGlobals();
|
||||
let calls = 0;
|
||||
const unsubscribe = subscribeToOrganizationsViewChange(() => {
|
||||
calls += 1;
|
||||
});
|
||||
|
||||
setOrganizationsView("list");
|
||||
expect(calls).toBe(1);
|
||||
|
||||
// Another tab changed it.
|
||||
listeners.get("storage")?.forEach((cb) => cb({ key: ORGANIZATIONS_VIEW_STORAGE_KEY }));
|
||||
expect(calls).toBe(2);
|
||||
// An unrelated key does nothing.
|
||||
listeners.get("storage")?.forEach((cb) => cb({ key: "timeFormat" }));
|
||||
expect(calls).toBe(2);
|
||||
|
||||
unsubscribe();
|
||||
(globalThis as any).window.dispatchEvent(new CustomEvent(ORGANIZATIONS_VIEW_CHANGE_EVENT));
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("swallows a storage that throws", () => {
|
||||
installBrowserGlobals();
|
||||
(globalThis as any).localStorage = {
|
||||
getItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
};
|
||||
expect(getOrganizationsView()).toBe("cards");
|
||||
expect(() => setOrganizationsView("list")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Cards or list on the Organizations page (#428).
|
||||
*
|
||||
* A pure display preference, so it lives in localStorage next to the theme
|
||||
* and time format preferences rather than in the database. Reading is safe
|
||||
* during SSR and in tests, where localStorage does not exist.
|
||||
*/
|
||||
|
||||
export type OrganizationsViewMode = "cards" | "list";
|
||||
|
||||
export const ORGANIZATIONS_VIEW_STORAGE_KEY = "organizationsView";
|
||||
export const ORGANIZATIONS_VIEW_CHANGE_EVENT = "gitea-mirror:organizations-view-change";
|
||||
|
||||
export const DEFAULT_ORGANIZATIONS_VIEW: OrganizationsViewMode = "cards";
|
||||
|
||||
export function isOrganizationsViewMode(value: unknown): value is OrganizationsViewMode {
|
||||
return value === "cards" || value === "list";
|
||||
}
|
||||
|
||||
export function getOrganizationsView(): OrganizationsViewMode {
|
||||
if (typeof localStorage === "undefined") return DEFAULT_ORGANIZATIONS_VIEW;
|
||||
try {
|
||||
const stored = localStorage.getItem(ORGANIZATIONS_VIEW_STORAGE_KEY);
|
||||
return isOrganizationsViewMode(stored) ? stored : DEFAULT_ORGANIZATIONS_VIEW;
|
||||
} catch {
|
||||
return DEFAULT_ORGANIZATIONS_VIEW;
|
||||
}
|
||||
}
|
||||
|
||||
export function setOrganizationsView(mode: OrganizationsViewMode): void {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(ORGANIZATIONS_VIEW_STORAGE_KEY, mode);
|
||||
}
|
||||
} catch {
|
||||
// Persisting is best effort; listeners are still told.
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(ORGANIZATIONS_VIEW_CHANGE_EVENT, { detail: mode }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to changes from this tab and from other tabs. */
|
||||
export function subscribeToOrganizationsViewChange(callback: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === null || event.key === ORGANIZATIONS_VIEW_STORAGE_KEY) callback();
|
||||
};
|
||||
window.addEventListener(ORGANIZATIONS_VIEW_CHANGE_EVENT, callback);
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
window.removeEventListener(ORGANIZATIONS_VIEW_CHANGE_EVENT, callback);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}
|
||||
@@ -35,6 +35,8 @@ The practical effect is that sync frequency mostly stops mattering. A `30m` inte
|
||||
|
||||
Issues and pull requests go one step further. After the first complete pass, a scheduled sync asks GitHub only for the issues and pull requests updated since the previous one, and fetches comments, commits and changed files for those alone. A tracker with thousands of issues and a handful of changes costs a handful of requests. A full pass still runs once a week to catch anything the incremental listing cannot see.
|
||||
|
||||
When the limit is hit anyway, nothing else is sent. Every GitHub client pauses until the reset GitHub reported: a reset within two minutes is waited out inside the request, a longer one fails the request at once without contacting GitHub, and the run ends so the next scheduled one starts a minute after the reset. Repositories that failed only because of the limit go back to their previous status and are retried then. This matters because GitHub counts requests made while limited toward abuse detection, which can suspend the account.
|
||||
|
||||
## Configuration that stays inside the budget
|
||||
|
||||
A few settings still matter for large or busy accounts:
|
||||
|
||||
Reference in new issue
Block a user