mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-11-03 07:42:19 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1d2ea4d6d | ||
|
|
e7270a6ca3 | ||
|
|
4ac2c4323c | ||
|
|
cadeb64dce | ||
|
|
7fcce46821 | ||
|
|
64e3b18e1e | ||
|
|
b3e8c69566 | ||
|
|
f06f6695bb | ||
|
|
a000073a68 | ||
|
|
2355284d27 | ||
|
|
6f3727c47e | ||
|
|
1bb096aa93 | ||
|
|
feb13ff58a | ||
|
|
41592cb8fd |
No files matched your search
+10
-17
@@ -7,8 +7,7 @@ This directory contains GitHub Actions workflows that automate the build, test,
|
||||
| Workflow | File | Purpose |
|
||||
|----------|------|---------|
|
||||
| Astro Build and Test | `astro-build-test.yml` | Builds and tests the Astro application for all branches and PRs |
|
||||
| Docker Build and Push | `docker-build.yml` | Builds and pushes Docker images only for the main branch |
|
||||
| Docker Security Scan | `docker-scan.yml` | Scans Docker images for security vulnerabilities |
|
||||
| Docker Build, Push & Security Scan | `docker-build.yml` | Builds, scans, and pushes Docker images |
|
||||
|
||||
## Workflow Details
|
||||
|
||||
@@ -28,14 +27,15 @@ This workflow runs on all branches and pull requests. It:
|
||||
- Caches dependencies to speed up builds
|
||||
- Uploads build artifacts for 7 days
|
||||
|
||||
### Docker Build and Push (`docker-build.yml`)
|
||||
### Docker Build, Push & Security Scan (`docker-build.yml`)
|
||||
|
||||
This workflow builds Docker images on pushes and pull requests, and pushes to GitHub Container Registry (ghcr.io) when permissions allow (main/tags and same-repo PRs).
|
||||
This workflow builds Docker images on pushes and pull requests, scans them, and pushes to GitHub Container Registry (ghcr.io) when permissions allow (main/tags and same-repo PRs).
|
||||
|
||||
**When it runs:**
|
||||
- On push to the main branch
|
||||
- On tag creation (v*)
|
||||
- On pull requests (build + scan; push only for same-repo PRs)
|
||||
- Weekly on Sunday at midnight (scheduled security scan)
|
||||
|
||||
**Key features:**
|
||||
- Builds multi-architecture images (amd64 and arm64)
|
||||
@@ -47,25 +47,18 @@ This workflow builds Docker images on pushes and pull requests, and pushes to Gi
|
||||
- Validates release tags use semver format before building
|
||||
- After tag builds succeed, writes the same version back to `main/package.json`
|
||||
|
||||
### Docker Security Scan (`docker-scan.yml`)
|
||||
|
||||
This workflow scans Docker images for security vulnerabilities using Trivy.
|
||||
|
||||
**When it runs:**
|
||||
- On push to the main branch that affects Docker-related files
|
||||
- Weekly on Sunday at midnight (scheduled)
|
||||
|
||||
**Key features:**
|
||||
- Scans for critical and high severity vulnerabilities
|
||||
- Fails the build if vulnerabilities are found
|
||||
- Ignores unfixed vulnerabilities
|
||||
**Security scanning:**
|
||||
- Docker Scout runs on main/tag pushes, the weekly schedule, and same-repo PRs. It needs the `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` secrets, posts a PR comment, and uploads SARIF to the Security tab.
|
||||
- Trivy runs on every PR, including forks. It needs no credentials and writes critical/high (fixable) findings to the job summary.
|
||||
- Fork PRs cannot use Docker Scout because GitHub never exposes repository secrets to them. Do not "fix" that with `pull_request_target`; it would run untrusted PR code with the secrets available.
|
||||
- Both scanners are informational and do not fail the build.
|
||||
|
||||
## CI/CD Pipeline Philosophy
|
||||
|
||||
Our CI/CD pipeline follows these principles:
|
||||
|
||||
1. **Fast feedback for developers**: The Astro build and test workflow runs on all branches and PRs to provide quick feedback.
|
||||
2. **Efficient resource usage**: Docker images are only built when changes are merged to main, not for every PR.
|
||||
2. **Efficient resource usage**: Docker images are built for every PR so they can be scanned, but only pushed for main, tags, and same-repo PRs.
|
||||
3. **Security first**: Regular security scanning ensures our Docker images are free from known vulnerabilities.
|
||||
4. **Multi-architecture support**: All Docker images are built for both amd64 and arm64 architectures.
|
||||
|
||||
|
||||
@@ -64,8 +64,11 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Login to Docker Hub for Docker Scout (optional - provides better vulnerability data)
|
||||
# Add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets to enable this
|
||||
# Add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets to enable this.
|
||||
# Skipped on fork PRs: GitHub never exposes repository secrets to them,
|
||||
# so the login could only fail with "Username and password required".
|
||||
- name: Log into Docker Hub
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
@@ -224,10 +227,14 @@ jobs:
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Docker Scout for Pull Requests (using local image)
|
||||
# Docker Scout for Pull Requests (using local image).
|
||||
# Same-repo PRs only: Scout needs the Docker Hub login above, and fork
|
||||
# PRs never receive the secrets for it. They used to fail here with
|
||||
# "user githubactions not entitled to use Docker Scout". Fork PRs get
|
||||
# the credential-free Trivy scan further down instead.
|
||||
- name: Docker Scout - Vulnerability Analysis (PR)
|
||||
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
with:
|
||||
command: cves,recommendations
|
||||
image: local://gitea-mirror:scan
|
||||
@@ -238,10 +245,10 @@ jobs:
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Compare to latest for PRs and pushes
|
||||
# Compare to latest (same-repo PRs only, same reason as above)
|
||||
- name: Docker Scout - Compare to Latest
|
||||
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
with:
|
||||
command: compare
|
||||
image: local://gitea-mirror:scan
|
||||
@@ -251,6 +258,39 @@ jobs:
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Trivy runs on every PR, forks included. It needs no credentials, so it
|
||||
# is the scan external contributors actually get. Results go to the job
|
||||
# summary because GITHUB_TOKEN is read-only on fork PRs (no PR comments,
|
||||
# no SARIF upload). Informational only, matching Scout's exit-code: false.
|
||||
- name: Trivy - Vulnerability Analysis (PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: gitea-mirror:scan
|
||||
scanners: vuln
|
||||
format: table
|
||||
output: trivy-results.txt
|
||||
severity: CRITICAL,HIGH
|
||||
ignore-unfixed: true
|
||||
exit-code: '0'
|
||||
|
||||
- name: Trivy - Write job summary
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
{
|
||||
echo "## Trivy image scan (critical and high, fixable only)"
|
||||
echo
|
||||
echo "Image: \`gitea-mirror:scan\` built from this PR. Unfixed vulnerabilities are hidden."
|
||||
echo
|
||||
if [ -s trivy-results.txt ]; then
|
||||
echo '```'
|
||||
cat trivy-results.txt
|
||||
echo '```'
|
||||
else
|
||||
echo "No critical or high vulnerabilities with an available fix."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# GitHub rejects SARIF files where a single result carries more than
|
||||
# 1000 relatedLocations, which Scout produces for widely-referenced OS
|
||||
# packages — every upload had been silently failing on this since May
|
||||
@@ -266,10 +306,12 @@ jobs:
|
||||
mv scout-results.sanitized.sarif scout-results.sarif
|
||||
fi
|
||||
|
||||
# Upload security scan results to GitHub Security tab
|
||||
# Upload security scan results to GitHub Security tab.
|
||||
# Skipped when no Scout step ran (fork PRs), otherwise the upload
|
||||
# errors with "Path does not exist: scout-results.sarif".
|
||||
- name: Upload Docker Scout scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always()
|
||||
if: always() && hashFiles('scout-results.sarif') != ''
|
||||
continue-on-error: true
|
||||
with:
|
||||
sarif_file: scout-results.sarif
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.27.2",
|
||||
"version": "3.29.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { MirrorJob } from '@/lib/db/schema';
|
||||
import Fuse from 'fuse.js';
|
||||
import { Button } from '../ui/button';
|
||||
import { RefreshCw, Check, X, Loader2, Import } from 'lucide-react';
|
||||
import {
|
||||
RefreshCw,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
Import,
|
||||
Activity as ActivityIcon,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
GitFork,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../ui/card';
|
||||
import { formatDate, getStatusColor } from '@/lib/utils';
|
||||
import { cn, formatDate, formatLastSyncTime } from '@/lib/utils';
|
||||
import { useTimeFormat } from '@/hooks/useTimeFormat';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import type { FilterParams } from '@/types/filter';
|
||||
@@ -16,22 +25,196 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '../ui/tooltip';
|
||||
|
||||
/** How each status presents itself. Previously this was a chain of ternaries
|
||||
* written out twice, once for mobile and once for desktop. */
|
||||
export const STATUS_PRESENTATION: Record<
|
||||
string,
|
||||
{
|
||||
Icon: typeof Check;
|
||||
label: string;
|
||||
/** One word, for the stat chips. */
|
||||
short: string;
|
||||
tone: string;
|
||||
wash: string;
|
||||
spin?: boolean;
|
||||
}
|
||||
> = {
|
||||
synced: {
|
||||
Icon: Check,
|
||||
label: 'Sync successful',
|
||||
short: 'synced',
|
||||
tone: 'text-teal-600 dark:text-teal-400',
|
||||
wash: 'bg-teal-500/10',
|
||||
},
|
||||
mirrored: {
|
||||
Icon: Check,
|
||||
label: 'Mirror successful',
|
||||
short: 'mirrored',
|
||||
tone: 'text-emerald-600 dark:text-emerald-400',
|
||||
wash: 'bg-emerald-500/10',
|
||||
},
|
||||
failed: {
|
||||
Icon: X,
|
||||
label: 'Operation failed',
|
||||
short: 'failed',
|
||||
tone: 'text-rose-600 dark:text-rose-400',
|
||||
wash: 'bg-rose-500/10',
|
||||
},
|
||||
syncing: {
|
||||
Icon: Loader2,
|
||||
label: 'Syncing in progress',
|
||||
short: 'syncing',
|
||||
tone: 'text-indigo-600 dark:text-indigo-400',
|
||||
wash: 'bg-indigo-500/10',
|
||||
spin: true,
|
||||
},
|
||||
mirroring: {
|
||||
Icon: Loader2,
|
||||
label: 'Mirroring in progress',
|
||||
short: 'mirroring',
|
||||
tone: 'text-yellow-600 dark:text-yellow-400',
|
||||
wash: 'bg-yellow-500/10',
|
||||
spin: true,
|
||||
},
|
||||
imported: {
|
||||
Icon: Import,
|
||||
label: 'Imported',
|
||||
short: 'imported',
|
||||
tone: 'text-blue-600 dark:text-blue-400',
|
||||
wash: 'bg-blue-500/10',
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK_PRESENTATION = {
|
||||
Icon: ActivityIcon,
|
||||
label: '',
|
||||
short: '',
|
||||
tone: 'text-foreground',
|
||||
wash: 'bg-muted',
|
||||
spin: false,
|
||||
};
|
||||
|
||||
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
||||
/**
|
||||
* One activity row. Follows the dashboard's Recent Activity shape (status
|
||||
* circle, message, time) but uses the extra width here to also carry the
|
||||
* repository or organization it belongs to and the raw message, with the
|
||||
* details pane opening in place.
|
||||
*/
|
||||
function RowBody({
|
||||
activity,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
activity: MirrorJobWithKey;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const presentation =
|
||||
STATUS_PRESENTATION[activity.status] ?? FALLBACK_PRESENTATION;
|
||||
const { Icon, tone, wash, spin } = presentation;
|
||||
// Unknown statuses have no label of their own, so the message stands in.
|
||||
const label = presentation.label || activity.message;
|
||||
const name = activity.repositoryName || activity.organizationName;
|
||||
const NameIcon = activity.repositoryName ? GitFork : Building2;
|
||||
const hasDetails = !!activity.details;
|
||||
// The label already says what happened; repeating it below is noise.
|
||||
const showMessage = activity.message && activity.message !== label;
|
||||
|
||||
const content = (
|
||||
<div className='flex w-full items-start gap-3 px-4 py-3 text-left'>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full',
|
||||
wash,
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4', tone, spin && 'animate-spin')} />
|
||||
</span>
|
||||
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex flex-wrap items-center gap-x-2 gap-y-1'>
|
||||
<span className={cn('text-sm font-medium', tone)}>{label}</span>
|
||||
{name && (
|
||||
<span className='flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground'>
|
||||
<NameIcon className='h-3 w-3 shrink-0' />
|
||||
<span className='truncate font-mono'>{name}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showMessage && (
|
||||
<p className='mt-1 truncate text-[13px] text-muted-foreground'>
|
||||
{activity.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-2'>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='cursor-help text-xs text-muted-foreground'>
|
||||
{formatLastSyncTime(activity.timestamp)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='left'>
|
||||
{formatDate(activity.timestamp)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{hasDetails && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasDetails ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
className='w-full cursor-pointer transition-colors hover:bg-muted/50'
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
|
||||
{isExpanded && activity.details && (
|
||||
<pre className='mx-4 mb-3 max-h-64 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs'>
|
||||
{activity.details}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface ActivityListProps {
|
||||
/** Already filtered by ActivityLog. */
|
||||
activities: MirrorJobWithKey[];
|
||||
isLoading: boolean;
|
||||
isLiveActive?: boolean;
|
||||
filter: FilterParams;
|
||||
setFilter: (filter: FilterParams) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ActivityList({
|
||||
activities,
|
||||
isLoading,
|
||||
isLiveActive = false,
|
||||
filter,
|
||||
setFilter,
|
||||
className,
|
||||
}: ActivityListProps) {
|
||||
// Re-render timestamps when the user changes the 12h/24h preference.
|
||||
useTimeFormat();
|
||||
@@ -41,62 +224,32 @@ export default function ActivityList({
|
||||
);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// We keep the ref only for possible future scroll-to-row logic.
|
||||
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map()); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
|
||||
const filteredActivities = useMemo(() => {
|
||||
let result = activities;
|
||||
|
||||
if (filter.status) {
|
||||
result = result.filter((a) => a.status === filter.status);
|
||||
}
|
||||
|
||||
if (filter.type) {
|
||||
result =
|
||||
filter.type === 'repository'
|
||||
? result.filter((a) => !!a.repositoryId)
|
||||
: filter.type === 'organization'
|
||||
? result.filter((a) => !!a.organizationId)
|
||||
: result;
|
||||
}
|
||||
|
||||
if (filter.name) {
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
a.repositoryName === filter.name ||
|
||||
a.organizationName === filter.name,
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.searchTerm) {
|
||||
const fuse = new Fuse(result, {
|
||||
keys: ['message', 'details', 'organizationName', 'repositoryName'],
|
||||
threshold: 0.3,
|
||||
});
|
||||
result = fuse.search(filter.searchTerm).map((r) => r.item);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [activities, filter]);
|
||||
// Kept so a toggled row can be re-measured against its real height.
|
||||
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredActivities.length,
|
||||
count: activities.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: (idx) =>
|
||||
expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 100,
|
||||
expandedItems.has(activities[idx]._rowKey) ? 200 : 69,
|
||||
overscan: 5,
|
||||
measureElement: (el) => el.getBoundingClientRect().height + 8,
|
||||
measureElement: (el) => el.getBoundingClientRect().height,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
// Expanding a row changes its height. Re-measure the mounted rows rather
|
||||
// than calling virtualizer.measure(), which throws away every measurement
|
||||
// and drops all rows back to estimateSize.
|
||||
useLayoutEffect(() => {
|
||||
rowRefs.current.forEach((node) => {
|
||||
if (node) virtualizer.measureElement(node);
|
||||
});
|
||||
}, [expandedItems, virtualizer]);
|
||||
|
||||
/* ------------------------------ render ------------------------------ */
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex flex-col gap-y-4'>
|
||||
<div className={cn('flex flex-col gap-y-4', className)}>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className='h-28 w-full rounded-md' />
|
||||
))}
|
||||
@@ -104,12 +257,12 @@ export default function ActivityList({
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredActivities.length === 0) {
|
||||
if (activities.length === 0) {
|
||||
const hasFilter =
|
||||
filter.searchTerm || filter.status || filter.type || filter.name;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
|
||||
<RefreshCw className='mb-4 h-12 w-12 text-muted-foreground' />
|
||||
<h3 className='text-lg font-medium'>No activities found</h3>
|
||||
<p className='mt-1 mb-4 max-w-md text-sm text-muted-foreground'>
|
||||
@@ -132,10 +285,10 @@ export default function ActivityList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border rounded-md">
|
||||
<div className={cn("flex min-h-0 flex-col border rounded-md", className)}>
|
||||
<Card
|
||||
ref={parentRef}
|
||||
className='relative max-h-[calc(100dvh-231px)] overflow-y-auto rounded-none border-0'
|
||||
className='relative min-h-0 flex-1 overflow-y-auto rounded-none border-0'
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -145,12 +298,15 @@ export default function ActivityList({
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vRow) => {
|
||||
const activity = filteredActivities[vRow.index];
|
||||
const activity = activities[vRow.index];
|
||||
const isExpanded = expandedItems.has(activity._rowKey);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={activity._rowKey}
|
||||
// measureElement reads the row index off this attribute; without
|
||||
// it the measurement is dropped and rows keep their estimate.
|
||||
data-index={vRow.index}
|
||||
ref={(node) => {
|
||||
rowRefs.current.set(activity._rowKey, node);
|
||||
if (node) virtualizer.measureElement(node);
|
||||
@@ -161,196 +317,27 @@ export default function ActivityList({
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vRow.start}px)`,
|
||||
paddingBottom: '8px',
|
||||
}}
|
||||
className='border-b px-4 pt-4'
|
||||
className='border-b'
|
||||
>
|
||||
<div className='flex items-start gap-3 sm:gap-4'>
|
||||
<div className='relative mt-2 flex-shrink-0'>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||
activity.status,
|
||||
)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='mb-1 flex items-start justify-between gap-2'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
{/* Mobile: Show simplified status-based message */}
|
||||
<div className='block sm:hidden'>
|
||||
<p className='font-medium flex items-center gap-1.5'>
|
||||
{activity.status === 'synced' ? (
|
||||
<>
|
||||
<Check className='h-4 w-4 text-teal-600 dark:text-teal-400' />
|
||||
<span className='text-teal-600 dark:text-teal-400'>Sync successful</span>
|
||||
</>
|
||||
) : activity.status === 'mirrored' ? (
|
||||
<>
|
||||
<Check className='h-4 w-4 text-emerald-600 dark:text-emerald-400' />
|
||||
<span className='text-emerald-600 dark:text-emerald-400'>Mirror successful</span>
|
||||
</>
|
||||
) : activity.status === 'failed' ? (
|
||||
<>
|
||||
<X className='h-4 w-4 text-rose-600 dark:text-rose-400' />
|
||||
<span className='text-rose-600 dark:text-rose-400'>Operation failed</span>
|
||||
</>
|
||||
) : activity.status === 'syncing' ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 text-indigo-600 dark:text-indigo-400 animate-spin' />
|
||||
<span className='text-indigo-600 dark:text-indigo-400'>Syncing in progress</span>
|
||||
</>
|
||||
) : activity.status === 'mirroring' ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 text-yellow-600 dark:text-yellow-400 animate-spin' />
|
||||
<span className='text-yellow-600 dark:text-yellow-400'>Mirroring in progress</span>
|
||||
</>
|
||||
) : activity.status === 'imported' ? (
|
||||
<>
|
||||
<Import className='h-4 w-4 text-blue-600 dark:text-blue-400' />
|
||||
<span className='text-blue-600 dark:text-blue-400'>Imported</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{activity.message}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* Desktop: Show status with icon and full message in tooltip */}
|
||||
<div className='hidden sm:block'>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className='font-medium flex items-center gap-1.5 cursor-help'>
|
||||
{activity.status === 'synced' ? (
|
||||
<>
|
||||
<Check className='h-4 w-4 text-teal-600 dark:text-teal-400 flex-shrink-0' />
|
||||
<span className='text-teal-600 dark:text-teal-400'>Sync successful</span>
|
||||
</>
|
||||
) : activity.status === 'mirrored' ? (
|
||||
<>
|
||||
<Check className='h-4 w-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0' />
|
||||
<span className='text-emerald-600 dark:text-emerald-400'>Mirror successful</span>
|
||||
</>
|
||||
) : activity.status === 'failed' ? (
|
||||
<>
|
||||
<X className='h-4 w-4 text-rose-600 dark:text-rose-400 flex-shrink-0' />
|
||||
<span className='text-rose-600 dark:text-rose-400'>Operation failed</span>
|
||||
</>
|
||||
) : activity.status === 'syncing' ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 text-indigo-600 dark:text-indigo-400 animate-spin flex-shrink-0' />
|
||||
<span className='text-indigo-600 dark:text-indigo-400'>Syncing in progress</span>
|
||||
</>
|
||||
) : activity.status === 'mirroring' ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 text-yellow-600 dark:text-yellow-400 animate-spin flex-shrink-0' />
|
||||
<span className='text-yellow-600 dark:text-yellow-400'>Mirroring in progress</span>
|
||||
</>
|
||||
) : activity.status === 'imported' ? (
|
||||
<>
|
||||
<Import className='h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0' />
|
||||
<span className='text-blue-600 dark:text-blue-400'>Imported</span>
|
||||
</>
|
||||
) : (
|
||||
<span className='truncate'>{activity.message}</span>
|
||||
)}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-[400px]">
|
||||
<p className="whitespace-pre-wrap break-words">{activity.message}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-sm text-muted-foreground whitespace-nowrap flex-shrink-0 ml-2'>
|
||||
{formatDate(activity.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3'>
|
||||
{activity.repositoryName && (
|
||||
<p className='text-sm text-muted-foreground truncate'>
|
||||
<span className='font-medium'>Repo:</span> {activity.repositoryName}
|
||||
</p>
|
||||
)}
|
||||
{activity.organizationName && (
|
||||
<p className='text-sm text-muted-foreground truncate'>
|
||||
<span className='font-medium'>Org:</span> {activity.organizationName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activity.details && (
|
||||
<div className='mt-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-7 px-2 text-xs'
|
||||
onClick={() =>
|
||||
setExpandedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(activity._rowKey)
|
||||
? next.delete(activity._rowKey)
|
||||
: next.add(activity._rowKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{isExpanded ? 'Hide Details' : activity.status === 'failed' ? 'Show Error Details' : 'Show Details'}
|
||||
</Button>
|
||||
|
||||
{isExpanded && (
|
||||
<pre className='mt-2 min-h-[100px] whitespace-pre-wrap overflow-auto rounded-md bg-muted p-3 text-xs'>
|
||||
{activity.details}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<RowBody
|
||||
activity={activity}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() =>
|
||||
setExpandedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(activity._rowKey)
|
||||
? next.delete(activity._rowKey)
|
||||
: next.add(activity._rowKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3 relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{filteredActivities.length} {filteredActivities.length === 1 ? 'activity' : 'activities'} total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center - Live active indicator */}
|
||||
{isLiveActive && (
|
||||
<div className="flex items-center gap-1.5 absolute left-1/2 transform -translate-x-1/2">
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite'
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
Live active
|
||||
</span>
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
animationDelay: '1s'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(filter.searchTerm || filter.status || filter.type || filter.name) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Filters applied
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import Fuse from 'fuse.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronDown, Download, RefreshCw, Search, Trash2, Filter, StopCircle } from 'lucide-react';
|
||||
import {
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog';
|
||||
import { apiRequest, formatDate, showErrorToast } from '@/lib/utils';
|
||||
import { apiRequest, cn, formatDate, showErrorToast } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import type { MirrorJob } from '@/lib/db/schema';
|
||||
import type { ActivityApiResponse } from '@/types/activities';
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
import { repoStatusEnum, type RepoStatus } from '@/types/Repository';
|
||||
import ActivityList from './ActivityList';
|
||||
import ActivityList, { STATUS_PRESENTATION } from './ActivityList';
|
||||
import { ActivityNameCombobox } from './ActivityNameCombobox';
|
||||
import { useSSE } from '@/hooks/useSEE';
|
||||
import { useFilterParams } from '@/hooks/useFilterParams';
|
||||
@@ -250,6 +251,20 @@ export function ActivityLog() {
|
||||
});
|
||||
};
|
||||
|
||||
// The search used to run again inside ActivityList over the list this had
|
||||
// already filtered. Doing all of it here means one pass, and the count below
|
||||
// matches exactly what the list renders.
|
||||
const visibleActivities = useMemo(() => {
|
||||
const rows = applyLightFilter(activities);
|
||||
if (!filter.searchTerm) return rows;
|
||||
|
||||
const fuse = new Fuse(rows, {
|
||||
keys: ['message', 'details', 'organizationName', 'repositoryName'],
|
||||
threshold: 0.3,
|
||||
});
|
||||
return fuse.search(filter.searchTerm).map((r) => r.item);
|
||||
}, [activities, filter]);
|
||||
|
||||
const exportAsCSV = () => {
|
||||
const rows = applyLightFilter(activities);
|
||||
if (!rows.length) return toast.error('No activities to export.');
|
||||
@@ -391,6 +406,35 @@ export function ActivityLog() {
|
||||
};
|
||||
|
||||
// Check if any filters are active
|
||||
// Counts the current state of each repository or organization, not every
|
||||
// event: a repo that failed and then synced is only "synced". Activities
|
||||
// arrive newest first, so the first entry seen for a subject is its state.
|
||||
// Taken from the unfiltered set so the chips stay put when one is clicked.
|
||||
const statusCounts = useMemo(() => {
|
||||
const latestBySubject = new Map<string, string>();
|
||||
activities.forEach((a) => {
|
||||
const subject =
|
||||
a.repositoryId ||
|
||||
a.organizationId ||
|
||||
a.repositoryName ||
|
||||
a.organizationName;
|
||||
// Events with no subject have no state of their own to report.
|
||||
if (!subject || latestBySubject.has(subject)) return;
|
||||
latestBySubject.set(subject, a.status);
|
||||
});
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
latestBySubject.forEach((status) => {
|
||||
counts.set(status, (counts.get(status) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [activities]);
|
||||
|
||||
// Most actionable first, and only statuses that actually occurred.
|
||||
const statChips = ['failed', 'syncing', 'mirroring', 'imported', 'synced', 'mirrored']
|
||||
.filter((status) => (statusCounts.get(status) ?? 0) > 0)
|
||||
.slice(0, 4);
|
||||
|
||||
const hasActiveFilters = !!(filter.status || filter.type || filter.name);
|
||||
const activeFilterCount = [filter.status, filter.type, filter.name].filter(Boolean).length;
|
||||
|
||||
@@ -407,9 +451,9 @@ export function ActivityLog() {
|
||||
/* ------------------------------ UI ------------------------------ */
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-y-4 sm:gap-y-8'>
|
||||
<div className='flex h-full min-h-0 flex-col gap-y-4 sm:gap-y-8'>
|
||||
{/* Mobile: Search bar with filter and action buttons */}
|
||||
<div className="flex flex-col gap-2 sm:hidden">
|
||||
<div className="flex flex-col gap-2 lg:hidden">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="relative flex-grow">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
@@ -612,7 +656,7 @@ export function ActivityLog() {
|
||||
</div>
|
||||
|
||||
{/* Desktop: Original layout */}
|
||||
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
|
||||
<div className="hidden lg:flex lg:flex-row lg:items-center lg:gap-4 lg:w-full">
|
||||
{/* search input */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
@@ -630,74 +674,6 @@ export function ActivityLog() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* status select */}
|
||||
<Select
|
||||
value={filter.status || 'all'}
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({
|
||||
...p,
|
||||
status: v === 'all' ? '' : (v as RepoStatus),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-10">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['all', ...repoStatusEnum.options].map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
<span className="flex items-center gap-2">
|
||||
{s !== 'all' && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
s === 'synced' ? 'bg-green-500' :
|
||||
s === 'failed' ? 'bg-red-500' :
|
||||
s === 'syncing' ? 'bg-blue-500' :
|
||||
'bg-yellow-500'
|
||||
}`} />
|
||||
)}
|
||||
{s === 'all' ? 'All statuses' : s[0].toUpperCase() + s.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* type select */}
|
||||
<Select
|
||||
value={filter.type || 'all'}
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-10">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['all', 'repository', 'organization'].map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
<span className="flex items-center gap-2">
|
||||
{t !== 'all' && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
t === 'repository' ? 'bg-blue-500' : 'bg-purple-500'
|
||||
}`} />
|
||||
)}
|
||||
{t === 'all' ? 'All types' : t[0].toUpperCase() + t.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* repo/org name combobox */}
|
||||
<ActivityNameCombobox
|
||||
activities={activities}
|
||||
value={filter.name || ''}
|
||||
onChange={(name) => setFilter((p) => ({ ...p, name }))}
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{/* export dropdown */}
|
||||
@@ -755,11 +731,125 @@ export function ActivityLog() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: filters on their own row so the search box keeps its width */}
|
||||
<div className="hidden lg:flex lg:items-center lg:justify-between lg:gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{visibleActivities.length === activities.length
|
||||
? `${activities.length} ${
|
||||
activities.length === 1 ? 'activity' : 'activities'
|
||||
}`
|
||||
: `Showing ${visibleActivities.length} of ${activities.length} activities`}
|
||||
</span>
|
||||
{statChips.map((status) => {
|
||||
const { Icon, short, tone, wash, spin } = STATUS_PRESENTATION[status];
|
||||
const isActive = filter.status === status;
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
title={`${statusCounts.get(status)} currently ${short}. Click to show every ${short} event.`}
|
||||
onClick={() =>
|
||||
setFilter((p) => ({
|
||||
...p,
|
||||
status: p.status === status ? '' : (status as RepoStatus),
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-xs transition-colors',
|
||||
wash,
|
||||
tone,
|
||||
isActive
|
||||
? 'ring-1 ring-current'
|
||||
: 'opacity-80 hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-3 w-3', spin && 'animate-spin')} />
|
||||
{statusCounts.get(status)} {short}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* status select */}
|
||||
<Select
|
||||
value={filter.status || 'all'}
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({
|
||||
...p,
|
||||
status: v === 'all' ? '' : (v as RepoStatus),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-10">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['all', ...repoStatusEnum.options].map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
<span className="flex items-center gap-2">
|
||||
{s !== 'all' && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
s === 'synced' ? 'bg-green-500' :
|
||||
s === 'failed' ? 'bg-red-500' :
|
||||
s === 'syncing' ? 'bg-blue-500' :
|
||||
'bg-yellow-500'
|
||||
}`} />
|
||||
)}
|
||||
{s === 'all' ? 'All statuses' : s[0].toUpperCase() + s.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* type select */}
|
||||
<Select
|
||||
value={filter.type || 'all'}
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-10">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['all', 'repository', 'organization'].map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
<span className="flex items-center gap-2">
|
||||
{t !== 'all' && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
t === 'repository' ? 'bg-blue-500' : 'bg-purple-500'
|
||||
}`} />
|
||||
)}
|
||||
{t === 'all' ? 'All types' : t[0].toUpperCase() + t.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* repo/org name combobox */}
|
||||
<ActivityNameCombobox
|
||||
activities={activities}
|
||||
value={filter.name || ''}
|
||||
onChange={(name) => setFilter((p) => ({ ...p, name }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* activity list */}
|
||||
<ActivityList
|
||||
activities={applyLightFilter(activities)}
|
||||
className="min-h-0 flex-1"
|
||||
activities={visibleActivities}
|
||||
isLoading={isInitialLoading || !connected}
|
||||
isLiveActive={isLiveEnabled && isFullyConfigured}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
@@ -823,7 +913,7 @@ export function ActivityLog() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="fixed bottom-4 right-4 rounded-full h-12 w-12 shadow-lg p-0 z-10 sm:hidden"
|
||||
className="fixed bottom-4 right-4 rounded-full h-12 w-12 shadow-lg p-0 z-10 lg:hidden"
|
||||
variant="default"
|
||||
>
|
||||
<Download className="h-6 w-6" />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { ChevronsUpDown, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -14,15 +13,25 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { selectTriggerClassName } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ActivityNameComboboxProps = {
|
||||
activities: any[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ActivityNameCombobox({ activities, value, onChange }: ActivityNameComboboxProps) {
|
||||
/** Stays a combobox rather than a Select because the name list grows with the
|
||||
* activity log and needs the search box. The trigger borrows the Select
|
||||
* styling so it reads as the same control as the status and type dropdowns. */
|
||||
export function ActivityNameCombobox({
|
||||
activities,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: ActivityNameComboboxProps) {
|
||||
// Collect unique names from repositoryName and organizationName
|
||||
const names = React.useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
@@ -34,25 +43,30 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa
|
||||
}, [activities]);
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{/* The button stays inline: PopoverTrigger asChild clones its child to
|
||||
attach the click handler and ref, which a wrapper component would
|
||||
swallow unless it forwarded both. */}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full sm:w-[180px] justify-between h-10"
|
||||
className={cn(
|
||||
selectTriggerClassName,
|
||||
"h-10 w-full lg:w-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"truncate",
|
||||
!value && "text-muted-foreground"
|
||||
)}>
|
||||
<span className={cn("truncate", !value && "text-muted-foreground")}>
|
||||
{value || "All names"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-0">
|
||||
<PopoverContent className="w-50 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search name..." />
|
||||
<CommandList>
|
||||
@@ -66,7 +80,12 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === "" ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
All names
|
||||
</CommandItem>
|
||||
{names.map((name) => (
|
||||
@@ -78,7 +97,12 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === name ? "opacity-100" : "opacity-0")} />
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{name}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
@@ -322,7 +322,7 @@ export function AutomationSettings({
|
||||
<>
|
||||
<StatusFooterItem
|
||||
icon={History}
|
||||
label="Last cleanup"
|
||||
label="Last run"
|
||||
value={
|
||||
cleanupConfig.lastRun
|
||||
? formatDate(cleanupConfig.lastRun)
|
||||
@@ -332,7 +332,7 @@ export function AutomationSettings({
|
||||
{cleanupConfig.enabled && cleanupConfig.nextRun ? (
|
||||
<StatusFooterItem
|
||||
icon={Calendar}
|
||||
label="Next cleanup"
|
||||
label="Next run"
|
||||
value={formatDate(cleanupConfig.nextRun)}
|
||||
valueClassName="text-indigo-500"
|
||||
/>
|
||||
|
||||
@@ -66,7 +66,7 @@ const backupStrategies = [
|
||||
{
|
||||
value: "on-force-push" as const,
|
||||
label: "Smart",
|
||||
description: "Snapshot only on history rewrites",
|
||||
description: "Snapshot only on rewrites",
|
||||
icon: Sparkles,
|
||||
info: "Backs up only when a force-push is detected.",
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
@@ -20,7 +21,9 @@ import type { MirrorOverrides } from "@/lib/db/schema";
|
||||
import {
|
||||
getMirrorOverrideGating,
|
||||
MIRROR_OVERRIDE_LABELS,
|
||||
normalizeReleaseLimit,
|
||||
UI_MIRROR_OVERRIDE_KEYS,
|
||||
type InheritedMirrorOptions,
|
||||
type MirrorOverrideKey,
|
||||
} from "@/lib/utils/mirror-overrides";
|
||||
|
||||
@@ -42,6 +45,17 @@ function fromTriState(value: TriState): boolean | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The release limit is edited as free text so the user can clear the field
|
||||
* back to "inherit" or type a multi-digit number without every intermediate
|
||||
* keystroke being rejected. Empty means inherit.
|
||||
*/
|
||||
function parseReleaseLimitDraft(text: string): number | undefined {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return normalizeReleaseLimit(Number(trimmed));
|
||||
}
|
||||
|
||||
export interface MirrorOverridesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -55,7 +69,7 @@ export interface MirrorOverridesDialogProps {
|
||||
* "Inherit" actually resolves to right now. For a repository this should
|
||||
* already be global merged with its organization's overrides.
|
||||
*/
|
||||
inheritedFrom?: Partial<Record<MirrorOverrideKey, boolean>>;
|
||||
inheritedFrom?: InheritedMirrorOptions;
|
||||
inheritedLabel?: string;
|
||||
/**
|
||||
* True while the inherited values are still being fetched. The hint is
|
||||
@@ -84,18 +98,25 @@ export function MirrorOverridesDialog({
|
||||
const [draft, setDraft] = useState<Record<MirrorOverrideKey, TriState>>(
|
||||
() => buildDraft(value)
|
||||
);
|
||||
const [releaseLimitDraft, setReleaseLimitDraft] = useState(() =>
|
||||
buildReleaseLimitDraft(value)
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Re-seed whenever the dialog opens or targets a different object, so a
|
||||
// previous edit never leaks into the next one.
|
||||
useEffect(() => {
|
||||
if (open) setDraft(buildDraft(value));
|
||||
if (open) {
|
||||
setDraft(buildDraft(value));
|
||||
setReleaseLimitDraft(buildReleaseLimitDraft(value));
|
||||
}
|
||||
}, [open, value]);
|
||||
|
||||
// What each flag currently resolves to, including the in-progress edit.
|
||||
// Drives the labels gate so it reacts live as issues is toggled.
|
||||
// Drives the labels and release-limit gates so they react live as issues
|
||||
// and releases are toggled.
|
||||
const effective = useMemo(() => {
|
||||
const next: Partial<Record<MirrorOverrideKey, boolean>> = {};
|
||||
const next: InheritedMirrorOptions = {};
|
||||
for (const key of UI_MIRROR_OVERRIDE_KEYS) {
|
||||
const pinned = fromTriState(draft[key]);
|
||||
next[key] = pinned ?? inheritedFrom?.[key] ?? false;
|
||||
@@ -114,9 +135,16 @@ export function MirrorOverridesDialog({
|
||||
[targetKind, isStarred, starredCodeOnly, effective]
|
||||
);
|
||||
|
||||
const pinnedReleaseLimit = parseReleaseLimitDraft(releaseLimitDraft);
|
||||
// Non-empty text that does not parse to a usable limit (0, negative, junk).
|
||||
const releaseLimitInvalid =
|
||||
releaseLimitDraft.trim() !== "" && pinnedReleaseLimit === undefined;
|
||||
|
||||
const overriddenCount = useMemo(
|
||||
() => Object.values(draft).filter((state) => state !== "inherit").length,
|
||||
[draft]
|
||||
() =>
|
||||
Object.values(draft).filter((state) => state !== "inherit").length +
|
||||
(pinnedReleaseLimit !== undefined ? 1 : 0),
|
||||
[draft, pinnedReleaseLimit]
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -129,6 +157,7 @@ export function MirrorOverridesDialog({
|
||||
const resolved = fromTriState(draft[key]);
|
||||
if (typeof resolved === "boolean") next[key] = resolved;
|
||||
}
|
||||
if (pinnedReleaseLimit !== undefined) next.releaseLimit = pinnedReleaseLimit;
|
||||
await onSave(Object.keys(next).length > 0 ? next : null);
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
@@ -138,8 +167,20 @@ export function MirrorOverridesDialog({
|
||||
|
||||
const handleResetAll = () => {
|
||||
setDraft(buildDraft(null));
|
||||
setReleaseLimitDraft("");
|
||||
};
|
||||
|
||||
const releaseLimitReason = gating.releaseLimit;
|
||||
const releaseLimitDisabled = !!releaseLimitReason;
|
||||
const inheritedReleaseLimit = inheritedFrom?.releaseLimit;
|
||||
// Only while the field is genuinely empty: an invalid entry gets the error
|
||||
// line instead, so "currently 10" is not shown next to a rejected "0".
|
||||
const showReleaseLimitHint =
|
||||
!inheritedLoading &&
|
||||
!releaseLimitDisabled &&
|
||||
releaseLimitDraft.trim() === "" &&
|
||||
inheritedReleaseLimit !== undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
@@ -164,42 +205,96 @@ export function MirrorOverridesDialog({
|
||||
inherited !== undefined;
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label
|
||||
htmlFor={`override-${key}`}
|
||||
className={isDisabled ? "flex-1 text-muted-foreground" : "flex-1"}
|
||||
>
|
||||
{MIRROR_OVERRIDE_LABELS[key]}
|
||||
{showHint && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
currently {inherited ? "on" : "off"}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft[key]}
|
||||
disabled={isDisabled}
|
||||
onValueChange={(next) =>
|
||||
setDraft((prev) => ({ ...prev, [key]: next as TriState }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`override-${key}`} className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">Inherit</SelectItem>
|
||||
<SelectItem value="on">On</SelectItem>
|
||||
<SelectItem value="off">Off</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Fragment key={key}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label
|
||||
htmlFor={`override-${key}`}
|
||||
className={isDisabled ? "flex-1 text-muted-foreground" : "flex-1"}
|
||||
>
|
||||
{MIRROR_OVERRIDE_LABELS[key]}
|
||||
{showHint && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
currently {inherited ? "on" : "off"}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft[key]}
|
||||
disabled={isDisabled}
|
||||
onValueChange={(next) =>
|
||||
setDraft((prev) => ({ ...prev, [key]: next as TriState }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`override-${key}`} className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">Inherit</SelectItem>
|
||||
<SelectItem value="on">On</SelectItem>
|
||||
<SelectItem value="off">Off</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isDisabled && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{disabledReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isDisabled && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{disabledReason}
|
||||
</p>
|
||||
|
||||
{/* The limit only means something while releases are mirrored,
|
||||
so it sits directly under that row. */}
|
||||
{key === "mirrorReleases" && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label
|
||||
htmlFor="override-releaseLimit"
|
||||
className={
|
||||
releaseLimitDisabled
|
||||
? "flex-1 text-muted-foreground"
|
||||
: "flex-1"
|
||||
}
|
||||
>
|
||||
{MIRROR_OVERRIDE_LABELS.releaseLimit}
|
||||
{showReleaseLimitHint && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
currently {inheritedReleaseLimit}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="override-releaseLimit"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
placeholder="Inherit"
|
||||
className="w-32"
|
||||
value={releaseLimitDraft}
|
||||
disabled={releaseLimitDisabled}
|
||||
aria-invalid={releaseLimitInvalid || undefined}
|
||||
onChange={(event) => setReleaseLimitDraft(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{releaseLimitDisabled ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{releaseLimitReason}
|
||||
</p>
|
||||
) : releaseLimitInvalid ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Enter a whole number of 1 or more, or leave it empty to
|
||||
inherit.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Newest releases to keep, assets included. Older ones
|
||||
past the limit are removed from Gitea on the next sync.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -225,7 +320,7 @@ export function MirrorOverridesDialog({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
<Button onClick={handleSave} disabled={isSaving || releaseLimitInvalid}>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -243,3 +338,10 @@ function buildDraft(
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function buildReleaseLimitDraft(
|
||||
value: MirrorOverrides | null | undefined
|
||||
): string {
|
||||
const limit = normalizeReleaseLimit(value?.releaseLimit);
|
||||
return limit === undefined ? "" : String(limit);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
@@ -342,395 +341,391 @@ export function SSOSettings() {
|
||||
</div>
|
||||
|
||||
{/* SSO Providers */}
|
||||
<Card className="flex h-full flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">Identity Providers</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Users sign in with their existing accounts (Google, Azure AD, Authentik...)
|
||||
</CardDescription>
|
||||
<div className="flex flex-col overflow-hidden rounded-xl border border-border bg-card">
|
||||
{/* py-3 rather than py-4: the h-8 button makes this row 56px tall,
|
||||
matching the Sign-in Methods header so both dividers line up. */}
|
||||
<div className="flex items-center justify-between gap-3 px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="text-base font-semibold">Identity Providers</h3>
|
||||
</div>
|
||||
<Dialog open={showProviderDialog} onOpenChange={setShowProviderDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="bg-indigo-500 text-white hover:bg-indigo-600">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add provider
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] md:max-h-[85vh] lg:max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{editingProvider ? 'Edit SSO Provider' : 'Add SSO Provider'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProvider
|
||||
? 'Update the configuration for this identity provider'
|
||||
: 'Configure an external identity provider for user authentication'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-1 -mx-1 [&_label]:text-xs [&_label]:font-medium [&_label]:text-muted-foreground">
|
||||
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
|
||||
<TabsList className="sticky top-0 z-10 grid w-full grid-cols-2 gap-1 rounded-lg bg-muted p-1">
|
||||
<TabsTrigger
|
||||
value="oidc"
|
||||
className="rounded-md text-xs text-muted-foreground data-[state=active]:bg-background data-[state=active]:font-medium data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||
>
|
||||
OIDC / OAuth2
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="saml"
|
||||
className="rounded-md text-xs text-muted-foreground data-[state=active]:bg-background data-[state=active]:font-medium data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||
>
|
||||
SAML 2.0
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Common Fields */}
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="providerId">Provider ID</Label>
|
||||
<Input
|
||||
id="providerId"
|
||||
value={providerForm.providerId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))}
|
||||
placeholder="google-sso"
|
||||
disabled={!!editingProvider}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="domain">Email Domain</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
value={providerForm.domain}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, domain: e.target.value }))}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer">Issuer URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="issuer"
|
||||
value={providerForm.issuer}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, issuer: e.target.value }))}
|
||||
placeholder={providerType === 'oidc' ? "https://accounts.google.com" : "https://idp.example.com"}
|
||||
/>
|
||||
{providerType === 'oidc' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={discoverOIDC}
|
||||
disabled={isDiscovering}
|
||||
>
|
||||
{isDiscovering ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Discover'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="organizationId">Organization ID (Optional)</Label>
|
||||
<Input
|
||||
id="organizationId"
|
||||
value={providerForm.organizationId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, organizationId: e.target.value }))}
|
||||
placeholder="org_123"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Link this provider to an organization for automatic user provisioning</p>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={showProviderDialog} onOpenChange={setShowProviderDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="bg-indigo-500 text-white hover:bg-indigo-600">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add provider
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] md:max-h-[85vh] lg:max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{editingProvider ? 'Edit SSO Provider' : 'Add SSO Provider'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProvider
|
||||
? 'Update the configuration for this identity provider'
|
||||
: 'Configure an external identity provider for user authentication'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-1 -mx-1 [&_label]:text-xs [&_label]:font-medium [&_label]:text-muted-foreground">
|
||||
<Tabs value={providerType} onValueChange={(value) => setProviderType(value as 'oidc' | 'saml')}>
|
||||
<TabsList className="sticky top-0 z-10 grid w-full grid-cols-2 gap-1 rounded-lg bg-muted p-1">
|
||||
<TabsTrigger
|
||||
value="oidc"
|
||||
className="rounded-md text-xs text-muted-foreground data-[state=active]:bg-background data-[state=active]:font-medium data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||
>
|
||||
OIDC / OAuth2
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="saml"
|
||||
className="rounded-md text-xs text-muted-foreground data-[state=active]:bg-background data-[state=active]:font-medium data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||
>
|
||||
SAML 2.0
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Common Fields */}
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="providerId">Provider ID</Label>
|
||||
<Input
|
||||
id="providerId"
|
||||
value={providerForm.providerId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, providerId: e.target.value }))}
|
||||
placeholder="google-sso"
|
||||
disabled={!!editingProvider}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="domain">Email Domain</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
value={providerForm.domain}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, domain: e.target.value }))}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer">Issuer URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="issuer"
|
||||
value={providerForm.issuer}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, issuer: e.target.value }))}
|
||||
placeholder={providerType === 'oidc' ? "https://accounts.google.com" : "https://idp.example.com"}
|
||||
/>
|
||||
{providerType === 'oidc' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={discoverOIDC}
|
||||
disabled={isDiscovering}
|
||||
>
|
||||
{isDiscovering ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Discover'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="organizationId">Organization ID (Optional)</Label>
|
||||
<Input
|
||||
id="organizationId"
|
||||
value={providerForm.organizationId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, organizationId: e.target.value }))}
|
||||
placeholder="org_123"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Link this provider to an organization for automatic user provisioning</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="oidc" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientId">Client ID</Label>
|
||||
<Input
|
||||
id="clientId"
|
||||
value={providerForm.clientId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, clientId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientSecret">Client Secret</Label>
|
||||
<Input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
value={providerForm.clientSecret}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, clientSecret: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authEndpoint">Authorization Endpoint</Label>
|
||||
<Input
|
||||
id="authEndpoint"
|
||||
value={providerForm.authorizationEndpoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, authorizationEndpoint: e.target.value }))}
|
||||
placeholder="https://accounts.google.com/o/oauth2/auth"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tokenEndpoint">Token Endpoint</Label>
|
||||
<Input
|
||||
id="tokenEndpoint"
|
||||
value={providerForm.tokenEndpoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, tokenEndpoint: e.target.value }))}
|
||||
placeholder="https://oauth2.googleapis.com/token"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">OAuth Scopes</Label>
|
||||
<MultiSelect
|
||||
options={[
|
||||
{ label: "OpenID", value: "openid" },
|
||||
{ label: "Email", value: "email" },
|
||||
{ label: "Profile", value: "profile" },
|
||||
{ label: "Offline Access", value: "offline_access" },
|
||||
]}
|
||||
selected={providerForm.scopes}
|
||||
onChange={(scopes) => setProviderForm(prev => ({ ...prev, scopes }))}
|
||||
placeholder="Select scopes..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select the OAuth scopes to request from the provider
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="pkce"
|
||||
checked={providerForm.pkce}
|
||||
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, pkce: checked }))}
|
||||
className={accentSwitch}
|
||||
/>
|
||||
<Label htmlFor="pkce">Enable PKCE</Label>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<p>Redirect URL: {buildAbsoluteAppUrl(`/api/auth/sso/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
{isTrustedIssuer(providerForm.issuer, ['google.com']) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="saml" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entryPoint">SAML Entry Point</Label>
|
||||
<Input
|
||||
id="entryPoint"
|
||||
value={providerForm.entryPoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, entryPoint: e.target.value }))}
|
||||
placeholder="https://idp.example.com/sso"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cert">X.509 Certificate</Label>
|
||||
<Textarea
|
||||
id="cert"
|
||||
value={providerForm.cert}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, cert: e.target.value }))}
|
||||
placeholder="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="wantAssertionsSigned"
|
||||
checked={providerForm.wantAssertionsSigned}
|
||||
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, wantAssertionsSigned: checked }))}
|
||||
/>
|
||||
<Label htmlFor="wantAssertionsSigned">Require Signed Assertions</Label>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
<p>Callback URL: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
<p>SP Metadata: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/sp/metadata?providerId=${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="oidc" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientId">Client ID</Label>
|
||||
<Input
|
||||
id="clientId"
|
||||
value={providerForm.clientId}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, clientId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowProviderDialog(false);
|
||||
setEditingProvider(null);
|
||||
// Reset form
|
||||
setProviderForm({
|
||||
issuer: '',
|
||||
domain: '',
|
||||
providerId: '',
|
||||
organizationId: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
authorizationEndpoint: '',
|
||||
tokenEndpoint: '',
|
||||
jwksEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
discoveryEndpoint: '',
|
||||
scopes: ['openid', 'email', 'profile'] as string[],
|
||||
pkce: true,
|
||||
entryPoint: '',
|
||||
cert: '',
|
||||
callbackUrl: '',
|
||||
audience: '',
|
||||
wantAssertionsSigned: true,
|
||||
signatureAlgorithm: 'sha256',
|
||||
digestAlgorithm: 'sha256',
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
});
|
||||
}}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientSecret">Client Secret</Label>
|
||||
<Input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
value={providerForm.clientSecret}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, clientSecret: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authEndpoint">Authorization Endpoint</Label>
|
||||
<Input
|
||||
id="authEndpoint"
|
||||
value={providerForm.authorizationEndpoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, authorizationEndpoint: e.target.value }))}
|
||||
placeholder="https://accounts.google.com/o/oauth2/auth"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tokenEndpoint">Token Endpoint</Label>
|
||||
<Input
|
||||
id="tokenEndpoint"
|
||||
value={providerForm.tokenEndpoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, tokenEndpoint: e.target.value }))}
|
||||
placeholder="https://oauth2.googleapis.com/token"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">OAuth Scopes</Label>
|
||||
<MultiSelect
|
||||
options={[
|
||||
{ label: "OpenID", value: "openid" },
|
||||
{ label: "Email", value: "email" },
|
||||
{ label: "Profile", value: "profile" },
|
||||
{ label: "Offline Access", value: "offline_access" },
|
||||
]}
|
||||
selected={providerForm.scopes}
|
||||
onChange={(scopes) => setProviderForm(prev => ({ ...prev, scopes }))}
|
||||
placeholder="Select scopes..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select the OAuth scopes to request from the provider
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="pkce"
|
||||
checked={providerForm.pkce}
|
||||
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, pkce: checked }))}
|
||||
className={accentSwitch}
|
||||
/>
|
||||
<Label htmlFor="pkce">Enable PKCE</Label>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
<p>Redirect URL: {buildAbsoluteAppUrl(`/api/auth/sso/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
{isTrustedIssuer(providerForm.issuer, ['google.com']) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Note: Google doesn't support the "offline_access" scope. Make sure to exclude it from the selected scopes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="saml" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entryPoint">SAML Entry Point</Label>
|
||||
<Input
|
||||
id="entryPoint"
|
||||
value={providerForm.entryPoint}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, entryPoint: e.target.value }))}
|
||||
placeholder="https://idp.example.com/sso"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cert">X.509 Certificate</Label>
|
||||
<Textarea
|
||||
id="cert"
|
||||
value={providerForm.cert}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, cert: e.target.value }))}
|
||||
placeholder="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="wantAssertionsSigned"
|
||||
checked={providerForm.wantAssertionsSigned}
|
||||
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, wantAssertionsSigned: checked }))}
|
||||
/>
|
||||
<Label htmlFor="wantAssertionsSigned">Require Signed Assertions</Label>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
<p>Callback URL: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/callback/${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
<p>SP Metadata: {buildAbsoluteAppUrl(`/api/auth/sso/saml2/sp/metadata?providerId=${providerForm.providerId || '{provider-id}'}`)}</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowProviderDialog(false);
|
||||
setEditingProvider(null);
|
||||
// Reset form
|
||||
setProviderForm({
|
||||
issuer: '',
|
||||
domain: '',
|
||||
providerId: '',
|
||||
organizationId: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
authorizationEndpoint: '',
|
||||
tokenEndpoint: '',
|
||||
jwksEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
discoveryEndpoint: '',
|
||||
scopes: ['openid', 'email', 'profile'] as string[],
|
||||
pkce: true,
|
||||
entryPoint: '',
|
||||
cert: '',
|
||||
callbackUrl: '',
|
||||
audience: '',
|
||||
wantAssertionsSigned: true,
|
||||
signatureAlgorithm: 'sha256',
|
||||
digestAlgorithm: 'sha256',
|
||||
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={createProvider}
|
||||
disabled={addingProvider}
|
||||
className="bg-indigo-500 text-white hover:bg-indigo-600"
|
||||
>
|
||||
{addingProvider ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{editingProvider ? 'Updating...' : 'Creating...'}
|
||||
</>
|
||||
) : (
|
||||
editingProvider ? 'Update Provider' : 'Create Provider'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="border-t border-border" />
|
||||
<div className="flex-1 p-6">
|
||||
{providers.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto h-12 w-12 text-muted-foreground/50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium">No SSO providers configured</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground max-w-sm mx-auto">
|
||||
Enable Single Sign-On by adding an external identity provider like Google, Azure AD, or any OIDC-compliant service.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Button onClick={() => setShowProviderDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Your First Provider
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{providers.map(provider => (
|
||||
<div key={provider.id} className="border rounded-lg p-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-semibold text-sm">{provider.providerId}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{provider.samlConfig ? 'SAML' : 'OIDC'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">{provider.domain}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="w-24 shrink-0 font-medium text-foreground">Issuer:</span>
|
||||
<span className="text-muted-foreground break-all">{provider.issuer}</span>
|
||||
</div>
|
||||
|
||||
{provider.oidcConfig && (
|
||||
<>
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="w-24 shrink-0 font-medium text-foreground">Client ID:</span>
|
||||
<span className="font-mono text-xs text-muted-foreground break-all">{provider.oidcConfig.clientId}</span>
|
||||
</div>
|
||||
|
||||
{provider.oidcConfig.scopes && provider.oidcConfig.scopes.length > 0 && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="w-24 shrink-0 font-medium text-foreground">Scopes:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{provider.oidcConfig.scopes.map(scope => (
|
||||
<Badge key={scope} variant="secondary" className="text-xs">
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{provider.samlConfig && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="w-24 shrink-0 font-medium text-foreground">Entry Point:</span>
|
||||
<span className="text-muted-foreground break-all">{provider.samlConfig.entryPoint}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider.organizationId && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="w-24 shrink-0 font-medium text-foreground">Organization:</span>
|
||||
<span className="text-muted-foreground">{provider.organizationId}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => startEditProvider(provider)}
|
||||
>
|
||||
Cancel
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={createProvider}
|
||||
disabled={addingProvider}
|
||||
className="bg-indigo-500 text-white hover:bg-indigo-600"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteProvider(provider.id)}
|
||||
>
|
||||
{addingProvider ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{editingProvider ? 'Updating...' : 'Creating...'}
|
||||
</>
|
||||
) : (
|
||||
editingProvider ? 'Update Provider' : 'Create Provider'
|
||||
)}
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
{providers.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto h-12 w-12 text-muted-foreground/50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium">No SSO providers configured</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground max-w-sm mx-auto">
|
||||
Enable Single Sign-On by adding an external identity provider like Google, Azure AD, or any OIDC-compliant service.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Button onClick={() => setShowProviderDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Your First Provider
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{providers.map(provider => (
|
||||
<div key={provider.id} className="border rounded-lg p-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-semibold text-sm">{provider.providerId}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{provider.samlConfig ? 'SAML' : 'OIDC'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">{provider.domain}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Issuer:</span>
|
||||
<span className="text-muted-foreground break-all">{provider.issuer}</span>
|
||||
</div>
|
||||
|
||||
{provider.oidcConfig && (
|
||||
<>
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Client ID:</span>
|
||||
<span className="font-mono text-xs text-muted-foreground break-all">{provider.oidcConfig.clientId}</span>
|
||||
</div>
|
||||
|
||||
{provider.oidcConfig.scopes && provider.oidcConfig.scopes.length > 0 && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Scopes:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{provider.oidcConfig.scopes.map(scope => (
|
||||
<Badge key={scope} variant="secondary" className="text-xs">
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{provider.samlConfig && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Entry Point:</span>
|
||||
<span className="text-muted-foreground break-all">{provider.samlConfig.entryPoint}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider.organizationId && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<span className="text-muted-foreground min-w-[80px]">Organization:</span>
|
||||
<span className="text-muted-foreground">{provider.organizationId}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => startEditProvider(provider)}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteProvider(provider.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<div className="mt-auto border-t border-border" />
|
||||
<div className="flex items-center gap-2 px-6 py-3.5 text-xs text-muted-foreground/70">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
Users are matched to existing accounts by email address
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-border" />
|
||||
<div className="flex items-center gap-2 px-6 py-3.5 text-xs text-muted-foreground/70">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
Users are matched to existing accounts by email address
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -269,7 +269,7 @@ export function Dashboard() {
|
||||
<StatusCardSkeleton />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6 items-start">
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Repository List Skeleton */}
|
||||
<div className="w-full lg:w-1/2 border rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
@@ -327,7 +327,7 @@ export function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6 items-start">
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
<div className="w-full lg:w-1/2">
|
||||
<RepositoryList repositories={repositories.slice(0, 8)} />
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
useTimeFormat();
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<Card className="h-full w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-3 text-base font-semibold">
|
||||
<Activity className="h-5 w-5 text-muted-foreground" />
|
||||
@@ -71,7 +71,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
{activities.map((activity, index) => {
|
||||
const { Icon, color } = activityIcon(activity.status);
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-x-3 py-3">
|
||||
<div key={index} className="flex items-center gap-x-3 py-3.5">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className={`h-4 w-4 ${color}`} />
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { GitFork } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import { getStatusColor } from "@/lib/utils";
|
||||
import { buildGiteaWebUrl } from "@/lib/gitea-url";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
@@ -39,7 +38,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<Card className="h-full w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-3 text-base font-semibold">
|
||||
<GitFork className="h-5 w-5 text-muted-foreground" />
|
||||
@@ -68,13 +67,6 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||
key={index}
|
||||
className="flex items-center gap-x-3 py-3.5"
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||
repo.status
|
||||
)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-medium truncate">{repo.name}</h4>
|
||||
|
||||
@@ -133,7 +133,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
|
||||
return (
|
||||
<NavigationContext.Provider value={{ navigationKey }}>
|
||||
<main className="flex min-h-screen flex-col">
|
||||
<main className="flex h-dvh flex-col overflow-hidden">
|
||||
<Header
|
||||
currentPage={currentPage}
|
||||
onNavigate={handleNavigation}
|
||||
@@ -142,7 +142,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
isSidebarCollapsed={sidebarCollapsed}
|
||||
isSidebarOpen={sidebarOpen}
|
||||
/>
|
||||
<div className="flex flex-1 relative">
|
||||
<div className="flex flex-1 min-h-0 relative">
|
||||
<Sidebar
|
||||
onNavigate={handleNavigation}
|
||||
isOpen={sidebarOpen}
|
||||
@@ -150,7 +150,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
<section className={`flex-1 p-4 sm:p-6 overflow-y-auto h-[calc(100dvh-4.55rem)] w-full transition-all duration-200 ${
|
||||
<section className={`flex-1 min-h-0 p-4 sm:p-6 overflow-y-auto w-full transition-all duration-200 ${
|
||||
sidebarCollapsed ? 'md:w-[calc(100%-5rem)] xl:w-[calc(100%-16rem)]' : 'md:w-[calc(100%-16rem)]'
|
||||
}`}>
|
||||
{currentPage === "dashboard" && <Dashboard />}
|
||||
|
||||
@@ -13,6 +13,10 @@ import { LoaderCircle, Plus } from "lucide-react";
|
||||
import type { MembershipRole } from "@/types/organizations";
|
||||
import { RadioGroup, RadioGroupItem } from "../ui/radio";
|
||||
import { Label } from "../ui/label";
|
||||
import { parseGitHubOwnerReference } from "@/lib/utils/github-url";
|
||||
|
||||
const inputClassName =
|
||||
"w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
|
||||
|
||||
interface AddOrganizationDialogProps {
|
||||
isDialogOpen: boolean;
|
||||
@@ -33,24 +37,63 @@ export default function AddOrganizationDialog({
|
||||
setIsDialogOpen,
|
||||
onAddOrganization,
|
||||
}: AddOrganizationDialogProps) {
|
||||
const [url, setUrl] = useState<string>("");
|
||||
const [org, setOrg] = useState<string>("");
|
||||
const [role, setRole] = useState<MembershipRole>("member");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const resetForm = () => {
|
||||
setError("");
|
||||
setUrl("");
|
||||
setOrg("");
|
||||
setRole("member");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen) {
|
||||
setError("");
|
||||
setOrg("");
|
||||
setRole("member");
|
||||
resetForm();
|
||||
}
|
||||
}, [isDialogOpen]);
|
||||
|
||||
/** Fill the name field from anything that names an account. */
|
||||
const applyReference = (value: string): boolean => {
|
||||
const parsed = parseGitHubOwnerReference(value);
|
||||
if (!parsed) return false;
|
||||
setOrg(parsed);
|
||||
setError("");
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleUrlChange = (value: string) => {
|
||||
setUrl(value);
|
||||
if (!value.trim()) return;
|
||||
if (!applyReference(value)) {
|
||||
setOrg("");
|
||||
}
|
||||
};
|
||||
|
||||
/** Pasting a URL into the name box fills the name rather than the whole URL. */
|
||||
const handleReferencePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const pasted = e.clipboardData.getData("text");
|
||||
if (!pasted.includes("/")) return;
|
||||
if (applyReference(pasted)) {
|
||||
e.preventDefault();
|
||||
setUrl(pasted.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const urlIsUnparsed = url.trim() !== "" && !org;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!org || org.trim() === "") {
|
||||
setError("Please enter a valid organization name.");
|
||||
setError(
|
||||
urlIsUnparsed
|
||||
? "That does not look like a GitHub organization URL."
|
||||
: "Please enter a valid organization name."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,9 +102,7 @@ export default function AddOrganizationDialog({
|
||||
|
||||
await onAddOrganization({ org, role });
|
||||
|
||||
setError("");
|
||||
setOrg("");
|
||||
setRole("member");
|
||||
resetForm();
|
||||
setIsDialogOpen(false);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to add organization.");
|
||||
@@ -90,20 +131,55 @@ export default function AddOrganizationDialog({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
htmlFor="organizationUrl"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
GitHub URL
|
||||
</label>
|
||||
<input
|
||||
id="organizationUrl"
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
className={inputClassName}
|
||||
placeholder="https://github.com/microsoft"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{urlIsUnparsed
|
||||
? "Could not read an organization from that."
|
||||
: "Paste an organization URL and the name below fills in."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-background px-2 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="organizationName"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Organization Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
id="organizationName"
|
||||
type="text"
|
||||
value={org}
|
||||
onChange={(e) => setOrg(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
onPaste={handleReferencePaste}
|
||||
className={inputClassName}
|
||||
placeholder="e.g., microsoft"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -490,9 +490,9 @@ export function Organization() {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4 sm:gap-y-8">
|
||||
{/* Search and filters */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4 w-full">
|
||||
<div className="flex flex-col lg:flex-row items-start lg:items-center gap-2 lg:gap-4 w-full">
|
||||
{/* Mobile: Search bar with filter button */}
|
||||
<div className="flex items-center gap-2 w-full sm:hidden">
|
||||
<div className="flex items-center gap-2 w-full lg:hidden">
|
||||
<div className="relative flex-grow">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
@@ -725,7 +725,7 @@ export function Organization() {
|
||||
</div>
|
||||
|
||||
{/* Desktop: Original layout */}
|
||||
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
|
||||
<div className="hidden lg:flex lg:flex-row lg:items-center lg:gap-4 lg:w-full">
|
||||
<div className="relative flex-grow">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog";
|
||||
import { LoaderCircle, Plus } from "lucide-react";
|
||||
import { parseGitHubRepoReference } from "@/lib/utils/github-url";
|
||||
|
||||
const inputClassName =
|
||||
"w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
|
||||
|
||||
interface AddRepositoryDialogProps {
|
||||
isDialogOpen: boolean;
|
||||
@@ -32,26 +36,71 @@ export default function AddRepositoryDialog({
|
||||
setIsDialogOpen,
|
||||
onAddRepository,
|
||||
}: AddRepositoryDialogProps) {
|
||||
const [url, setUrl] = useState<string>("");
|
||||
const [repo, setRepo] = useState<string>("");
|
||||
const [owner, setOwner] = useState<string>("");
|
||||
const [destinationOrg, setDestinationOrg] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const resetForm = () => {
|
||||
setError("");
|
||||
setUrl("");
|
||||
setRepo("");
|
||||
setOwner("");
|
||||
setDestinationOrg("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen) {
|
||||
setError("");
|
||||
setRepo("");
|
||||
setOwner("");
|
||||
setDestinationOrg("");
|
||||
resetForm();
|
||||
}
|
||||
}, [isDialogOpen]);
|
||||
|
||||
/** Fill the name and owner fields from anything that names a repository. */
|
||||
const applyReference = (value: string): boolean => {
|
||||
const parsed = parseGitHubRepoReference(value);
|
||||
if (!parsed) return false;
|
||||
setOwner(parsed.owner);
|
||||
setRepo(parsed.repo);
|
||||
setError("");
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleUrlChange = (value: string) => {
|
||||
setUrl(value);
|
||||
if (!value.trim()) return;
|
||||
if (!applyReference(value)) {
|
||||
setOwner("");
|
||||
setRepo("");
|
||||
}
|
||||
};
|
||||
|
||||
/** Pasting a URL into the name box fills both fields rather than one bad one. */
|
||||
const handleReferencePaste =
|
||||
(fallback: (value: string) => void) =>
|
||||
(e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const pasted = e.clipboardData.getData("text");
|
||||
if (!pasted.includes("/")) return;
|
||||
if (applyReference(pasted)) {
|
||||
e.preventDefault();
|
||||
setUrl(pasted.trim());
|
||||
} else {
|
||||
fallback(pasted);
|
||||
}
|
||||
};
|
||||
|
||||
const urlIsUnparsed = url.trim() !== "" && (!owner || !repo);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!repo || !owner || repo.trim() === "" || owner.trim() === "") {
|
||||
setError("Please enter a valid repository name and owner.");
|
||||
setError(
|
||||
urlIsUnparsed
|
||||
? "That does not look like a GitHub repository URL."
|
||||
: "Please enter a valid repository name and owner."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -64,10 +113,7 @@ export default function AddRepositoryDialog({
|
||||
destinationOrg: destinationOrg.trim() || undefined,
|
||||
});
|
||||
|
||||
setError("");
|
||||
setRepo("");
|
||||
setOwner("");
|
||||
setDestinationOrg("");
|
||||
resetForm();
|
||||
setIsDialogOpen(false);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to add repository.");
|
||||
@@ -96,37 +142,73 @@ export default function AddRepositoryDialog({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
htmlFor="repositoryUrl"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
GitHub URL
|
||||
</label>
|
||||
<input
|
||||
id="repositoryUrl"
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
className={inputClassName}
|
||||
placeholder="https://github.com/vercel/next.js"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{urlIsUnparsed
|
||||
? "Could not read an owner and repository from that."
|
||||
: "Paste a repository URL and the fields below fill in."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-background px-2 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="repositoryName"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Repository Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
id="repositoryName"
|
||||
type="text"
|
||||
value={repo}
|
||||
onChange={(e) => setRepo(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
onPaste={handleReferencePaste((value) => setRepo(value))}
|
||||
className={inputClassName}
|
||||
placeholder="e.g., next.js"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
htmlFor="repositoryOwner"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Repository Owner
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
id="repositoryOwner"
|
||||
type="text"
|
||||
value={owner}
|
||||
onChange={(e) => setOwner(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
onPaste={handleReferencePaste((value) => setOwner(value))}
|
||||
className={inputClassName}
|
||||
placeholder="e.g., vercel"
|
||||
autoComplete="off"
|
||||
required
|
||||
@@ -148,7 +230,7 @@ export default function AddRepositoryDialog({
|
||||
type="text"
|
||||
value={destinationOrg}
|
||||
onChange={(e) => setDestinationOrg(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className={inputClassName}
|
||||
placeholder="Gitea org or user (uses default strategy if empty)"
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import RepositoryTable from "./RepositoryTable";
|
||||
import RepositoryTable, { REPOSITORY_SORT_OPTIONS } from "./RepositoryTable";
|
||||
import type { MirrorJob, Repository } from "@/lib/db/schema";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
@@ -52,15 +52,6 @@ import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
const REPOSITORY_SORT_OPTIONS = [
|
||||
{ value: "imported-desc", label: "Recently Imported" },
|
||||
{ value: "imported-asc", label: "Oldest Imported" },
|
||||
{ value: "updated-desc", label: "Recently Updated" },
|
||||
{ value: "updated-asc", label: "Oldest Updated" },
|
||||
{ value: "name-asc", label: "Name (A-Z)" },
|
||||
{ value: "name-desc", label: "Name (Z-A)" },
|
||||
] as const;
|
||||
|
||||
export default function Repository() {
|
||||
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
@@ -1043,11 +1034,11 @@ export default function Repository() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4 sm:gap-y-8">
|
||||
<div className="flex h-full min-h-0 flex-col gap-y-4 sm:gap-y-8">
|
||||
{/* Search and filters */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4 w-full">
|
||||
{/* Mobile: Search bar with filter button */}
|
||||
<div className="flex items-center gap-2 w-full sm:hidden">
|
||||
<div className="flex items-center gap-2 w-full lg:hidden">
|
||||
<div className="relative flex-grow">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
@@ -1279,7 +1270,7 @@ export default function Repository() {
|
||||
</div>
|
||||
|
||||
{/* Desktop: Original layout */}
|
||||
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
|
||||
<div className="hidden lg:flex lg:flex-row lg:items-center lg:gap-4 lg:w-full">
|
||||
<div className="relative flex-grow min-w-[180px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
@@ -1311,87 +1302,15 @@ export default function Repository() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filter controls in a responsive row */}
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Select
|
||||
value={filter.status || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
status: value === "all" ? "" : (value as RepoStatus),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-10">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["all", ...repoStatusEnum.options].map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<span className="flex items-center gap-2">
|
||||
{status !== "all" && (
|
||||
<span className={`h-2 w-2 rounded-full ${getStatusColor(status)}`} />
|
||||
)}
|
||||
{status === "all"
|
||||
? "All statuses"
|
||||
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.hasOverrides || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
hasOverrides:
|
||||
value === "all" ? "" : (value as "overridden" | "default"),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[170px] h-10">
|
||||
<SelectValue placeholder="All mirror options" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All mirror options</SelectItem>
|
||||
<SelectItem value="overridden">Custom options</SelectItem>
|
||||
<SelectItem value="default">Using defaults</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.sort || "imported-desc"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
sort: value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[190px] h-10">
|
||||
<SelectValue placeholder="Sort repositories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPOSITORY_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
title="Refresh repositories"
|
||||
className="h-10 w-10 shrink-0"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
title="Refresh repositories"
|
||||
className="h-10 w-10 shrink-0"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Mirror All action */}
|
||||
<div className="flex items-center gap-2 border-l pl-4">
|
||||
@@ -1410,7 +1329,7 @@ export default function Repository() {
|
||||
|
||||
{/* Desktop: Bulk actions row - shown when repos are selected */}
|
||||
{selectedRepoIds.size > 0 && (
|
||||
<div className="hidden sm:flex items-center gap-2 flex-wrap">
|
||||
<div className="hidden lg:flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedRepoIds.size} selected
|
||||
@@ -1511,7 +1430,7 @@ export default function Repository() {
|
||||
|
||||
{/* Action buttons for mobile - only show when items are selected */}
|
||||
{selectedRepoIds.size > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap sm:hidden">
|
||||
<div className="flex items-center gap-2 flex-wrap lg:hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedRepoIds.size} selected
|
||||
@@ -1633,7 +1552,6 @@ export default function Repository() {
|
||||
<RepositoryTable
|
||||
repositories={repositories}
|
||||
isLoading={isInitialLoading || !connected}
|
||||
isLiveActive={isLiveEnabled && isFullyConfigured}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
onMirror={handleMirrorRepo}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { ChevronsUpDown, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { selectTriggerClassName } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ComboboxProps = {
|
||||
@@ -24,31 +24,47 @@ type ComboboxProps = {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function OwnerCombobox({ options, value, onChange, placeholder = "Owner" }: ComboboxProps) {
|
||||
/** These stay comboboxes rather than Selects because the owner and
|
||||
* organization lists grow with the number of mirrored repositories and need
|
||||
* the search box. The trigger borrows the Select styling so it still reads as
|
||||
* the same control as the status and sort dropdowns beside it. */
|
||||
function FilterCombobox({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
emptyLabel,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
}: ComboboxProps & {
|
||||
emptyLabel: string;
|
||||
searchPlaceholder: string;
|
||||
emptyMessage: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{/* The button stays inline: PopoverTrigger asChild clones its child to
|
||||
attach the click handler and ref, which a wrapper component would
|
||||
swallow unless it forwarded both. */}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full sm:w-[160px] justify-between h-10"
|
||||
className={cn(selectTriggerClassName, "h-10 w-full lg:w-50")}
|
||||
>
|
||||
<span className={cn(
|
||||
"truncate",
|
||||
!value && "text-muted-foreground"
|
||||
)}>
|
||||
{value || "All owners"}
|
||||
<span className={cn("truncate", !value && "text-muted-foreground")}>
|
||||
{value || emptyLabel}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] sm:w-[160px] p-0">
|
||||
<PopoverContent className="w-50 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search owners..." />
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No owners found.</CommandEmpty>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
key="all"
|
||||
@@ -58,8 +74,13 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner"
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
||||
All owners
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === "" ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{emptyLabel}
|
||||
</CommandItem>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
@@ -70,7 +91,12 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner"
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")} />
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{option}
|
||||
</CommandItem>
|
||||
))}
|
||||
@@ -82,60 +108,24 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner"
|
||||
);
|
||||
}
|
||||
|
||||
export function OrganizationCombobox({ options, value, onChange, placeholder = "Organization" }: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
export function OwnerCombobox(props: ComboboxProps) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full sm:w-[160px] justify-between h-10"
|
||||
>
|
||||
<span className={cn(
|
||||
"truncate",
|
||||
!value && "text-muted-foreground"
|
||||
)}>
|
||||
{value || "All organizations"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] sm:w-[160px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search organizations..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No organizations found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
key="all"
|
||||
value=""
|
||||
onSelect={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
||||
All organizations
|
||||
</CommandItem>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option}
|
||||
onSelect={() => {
|
||||
onChange(option);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")} />
|
||||
{option}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FilterCombobox
|
||||
{...props}
|
||||
emptyLabel="All owners"
|
||||
searchPlaceholder="Search owners..."
|
||||
emptyMessage="No owners found."
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function OrganizationCombobox(props: ComboboxProps) {
|
||||
return (
|
||||
<FilterCombobox
|
||||
{...props}
|
||||
emptyLabel="All organizations"
|
||||
searchPlaceholder="Search organizations..."
|
||||
emptyMessage="No organizations found."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,9 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { FlipHorizontal, GitFork, MoreVertical, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, SlidersHorizontal, Trash2, X } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { MirrorOverrides, Repository } from "@/lib/db/schema";
|
||||
import { repoStatusEnum, type RepoStatus } from "@/types/Repository";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatLastSyncTime } from "@/lib/utils";
|
||||
import { cn, formatLastSyncTime, getStatusColor } from "@/lib/utils";
|
||||
import { buildGiteaWebUrl } from "@/lib/gitea-url";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -31,9 +32,17 @@ import { MirrorOverridesDialog } from "@/components/config/MirrorOverridesDialog
|
||||
import {
|
||||
hasMirrorOverrides,
|
||||
mirrorOptionsToFlags,
|
||||
normalizeReleaseLimit,
|
||||
type MirrorOverrideKey,
|
||||
} from "@/lib/utils/mirror-overrides";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import {
|
||||
@@ -44,10 +53,18 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export const REPOSITORY_SORT_OPTIONS = [
|
||||
{ value: "imported-desc", label: "Recently Imported" },
|
||||
{ value: "imported-asc", label: "Oldest Imported" },
|
||||
{ value: "updated-desc", label: "Recently Updated" },
|
||||
{ value: "updated-asc", label: "Oldest Updated" },
|
||||
{ value: "name-asc", label: "Name (A-Z)" },
|
||||
{ value: "name-desc", label: "Name (Z-A)" },
|
||||
] as const;
|
||||
|
||||
interface RepositoryTableProps {
|
||||
repositories: Repository[];
|
||||
isLoading: boolean;
|
||||
isLiveActive?: boolean;
|
||||
filter: FilterParams;
|
||||
setFilter: (filter: FilterParams) => void;
|
||||
onMirror: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
@@ -90,7 +107,6 @@ function getTableSorting(sortOrder: string | undefined): SortingState {
|
||||
export default function RepositoryTable({
|
||||
repositories,
|
||||
isLoading,
|
||||
isLiveActive = false,
|
||||
filter,
|
||||
setFilter,
|
||||
onMirror,
|
||||
@@ -162,6 +178,8 @@ export default function RepositoryTable({
|
||||
const orgValue = orgOverrides[key];
|
||||
if (typeof orgValue === "boolean") globals[key] = orgValue;
|
||||
}
|
||||
const orgReleaseLimit = normalizeReleaseLimit(orgOverrides.releaseLimit);
|
||||
if (orgReleaseLimit !== undefined) globals.releaseLimit = orgReleaseLimit;
|
||||
return globals;
|
||||
}, [mirrorOptions, orgOverrides]);
|
||||
|
||||
@@ -235,7 +253,9 @@ export default function RepositoryTable({
|
||||
filter.owner,
|
||||
filter.organization,
|
||||
filter.hasOverrides,
|
||||
].some((val) => val?.toString().trim() !== "");
|
||||
// val?.toString() is undefined for an unset filter, and undefined !== ""
|
||||
// is true, so this used to report a filter on a completely clean view.
|
||||
].some((val) => (val ?? "").toString().trim() !== "");
|
||||
|
||||
const columnFilters = useMemo<ColumnFiltersState>(() => {
|
||||
const next: ColumnFiltersState = [];
|
||||
@@ -395,51 +415,66 @@ export default function RepositoryTable({
|
||||
<Card className="mb-3">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header with checkbox and repo name */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
onClickCapture={(e) => {
|
||||
if (e.shiftKey && repo.id) {
|
||||
e.stopPropagation();
|
||||
handleShiftRangeSelect(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean, index)}
|
||||
className="mt-1 h-5 w-5"
|
||||
aria-label={`Select ${repo.name}`}
|
||||
/>
|
||||
{/* Header: checkbox, name, Custom badge and menu share one
|
||||
vertically centered row, so the name sits at the same height
|
||||
whether or not the badge is present. Secondary badges get
|
||||
their own row below, indented to the name. */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex shrink-0 items-center"
|
||||
onClickCapture={(e) => {
|
||||
if (e.shiftKey && repo.id) {
|
||||
e.stopPropagation();
|
||||
handleShiftRangeSelect(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean, index)}
|
||||
className="h-5 w-5"
|
||||
aria-label={`Select ${repo.name}`}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="min-w-0 flex-1 truncate text-base font-medium">{repo.name}</h3>
|
||||
{hasMirrorOverrides(repo.mirrorOverrides) && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-6 shrink-0 text-xs"
|
||||
title="This repository overrides the global mirror options"
|
||||
>
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
Custom
|
||||
</Badge>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" disabled={isLoading}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setOverridesTarget(repo)}>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror Options
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-base truncate">{repo.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{(repo.isPrivate || repo.isForked || repo.isStarred) && (
|
||||
<div className="flex flex-wrap items-center gap-2 pl-8">
|
||||
{repo.isPrivate && <Badge variant="secondary" className="text-xs h-5"><Lock className="h-3 w-3 mr-1" />Private</Badge>}
|
||||
{repo.isForked && <Badge variant="secondary" className="text-xs h-5"><GitFork className="h-3 w-3 mr-1" />Fork</Badge>}
|
||||
{repo.isStarred && <Badge variant="secondary" className="text-xs h-5"><Star className="h-3 w-3 mr-1" />Starred</Badge>}
|
||||
{hasMirrorOverrides(repo.mirrorOverrides) && <Badge variant="outline" className="text-xs h-5"><SlidersHorizontal className="h-3 w-3 mr-1" />Custom</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" disabled={isLoading}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setOverridesTarget(repo)}>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror Options
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Repository details */}
|
||||
<div className="space-y-2">
|
||||
{/* Owner & Organization */}
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{/* Details: owner/org/destination on the left, status and last
|
||||
sync stacked on the right so the row's width is used. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Owner:</span>
|
||||
<span className="truncate">{repo.owner}</span>
|
||||
@@ -457,9 +492,7 @@ export default function RepositoryTable({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status & Last Mirrored */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<Badge
|
||||
className={`capitalize
|
||||
${repo.status === 'imported' ? 'bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 dark:text-yellow-400' :
|
||||
@@ -646,97 +679,19 @@ export default function RepositoryTable({
|
||||
);
|
||||
};
|
||||
|
||||
return isLoading ? (
|
||||
<div className="space-y-3 lg:space-y-0">
|
||||
{/* Mobile skeleton */}
|
||||
<div className="lg:hidden">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i} className="mb-3">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Skeleton className="h-4 w-4 mt-1" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop skeleton */}
|
||||
<div className="hidden lg:block border rounded-md">
|
||||
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="h-full py-3 text-sm font-medium flex-[2.3]">
|
||||
Repository
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Organization
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Last Mirrored
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Actions
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
|
||||
Links
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[65px] flex items-center justify-between border-b bg-transparent"
|
||||
>
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[2.3]">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-3 w-24 mt-1" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[0.8] flex items-center justify-center gap-1">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{hasAnyFilter && (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Showing {visibleRepositories.length} of {repositories.length} repositories
|
||||
</span>
|
||||
/* Sits above the table in both the loading and loaded states so the
|
||||
controls do not appear and disappear as data arrives. */
|
||||
const filterRow = (
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{hasAnyFilter
|
||||
? `Showing ${visibleRepositories.length} of ${repositories.length} repositories`
|
||||
: `${repositories.length} ${
|
||||
repositories.length === 1 ? "repository" : "repositories"
|
||||
}`}
|
||||
</span>
|
||||
{hasAnyFilter && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -752,8 +707,167 @@ export default function RepositoryTable({
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-wrap items-center gap-2 lg:flex">
|
||||
<Select
|
||||
value={filter.status || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter({
|
||||
...filter,
|
||||
status: value === "all" ? "" : (value as RepoStatus),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-[140px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["all", ...repoStatusEnum.options].map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<span className="flex items-center gap-2">
|
||||
{status !== "all" && (
|
||||
<span className={`h-2 w-2 rounded-full ${getStatusColor(status)}`} />
|
||||
)}
|
||||
{status === "all"
|
||||
? "All statuses"
|
||||
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.hasOverrides || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter({
|
||||
...filter,
|
||||
hasOverrides:
|
||||
value === "all" ? "" : (value as "overridden" | "default"),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-[170px]">
|
||||
<SelectValue placeholder="All mirror options" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All mirror options</SelectItem>
|
||||
<SelectItem value="overridden">Custom options</SelectItem>
|
||||
<SelectItem value="default">Using defaults</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.sort || "imported-desc"}
|
||||
onValueChange={(value) => setFilter({ ...filter, sort: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-[190px]">
|
||||
<SelectValue placeholder="Sort repositories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPOSITORY_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return isLoading ? (
|
||||
<div>
|
||||
{filterRow}
|
||||
<div className="space-y-3 lg:space-y-0">
|
||||
{/* Mobile skeleton */}
|
||||
<div className="lg:hidden">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i} className="mb-3">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Skeleton className="h-4 w-4 mt-1" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop skeleton */}
|
||||
<div className="hidden lg:block border rounded-md">
|
||||
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="h-full py-3 text-sm font-medium flex-[2.3]">
|
||||
Repository
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Organization
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Last Mirrored
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||
Actions
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
|
||||
Links
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[65px] flex items-center justify-between border-b bg-transparent"
|
||||
>
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[2.3]">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-3 w-24 mt-1" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[1]">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
<div className="h-full p-3 flex-[0.8] flex items-center justify-center gap-1">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{filterRow}
|
||||
|
||||
{visibleRepositories.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
@@ -764,8 +878,8 @@ export default function RepositoryTable({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile card view */}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Mobile card view: flows in the page scroll rather than its own. */}
|
||||
<div className="lg:hidden pb-20">
|
||||
{/* Select all checkbox */}
|
||||
<div className="flex items-center gap-3 mb-3 p-3 bg-muted/50 rounded-md">
|
||||
@@ -787,7 +901,7 @@ export default function RepositoryTable({
|
||||
</div>
|
||||
|
||||
{/* Desktop table view */}
|
||||
<div className="hidden lg:flex flex-col border rounded-md">
|
||||
<div className="hidden lg:flex min-h-0 flex-1 flex-col border rounded-md">
|
||||
{/* Table header */}
|
||||
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
@@ -819,7 +933,7 @@ export default function RepositoryTable({
|
||||
{/* Table body wrapper (for a parent in virtualization) */}
|
||||
<div
|
||||
ref={tableParentRef}
|
||||
className="flex flex-col max-h-[calc(100dvh-276px)] overflow-y-auto"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -1028,48 +1142,8 @@ export default function RepositoryTable({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3 relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{hasAnyFilter
|
||||
? `Showing ${visibleRepositories.length} of ${repositories.length} repositories`
|
||||
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center - Live active indicator */}
|
||||
{isLiveActive && (
|
||||
<div className="flex items-center gap-1.5 absolute left-1/2 transform -translate-x-1/2">
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite'
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
Live active
|
||||
</span>
|
||||
<div
|
||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
||||
style={{
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
animationDelay: '1s'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAnyFilter && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Filters applied
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MirrorOverridesDialog
|
||||
|
||||
@@ -4,6 +4,9 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const selectTriggerClassName =
|
||||
"border-input cursor-pointer data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
@@ -34,10 +37,7 @@ function SelectTrigger({
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input cursor-pointer data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
className={cn(selectTriggerClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -180,4 +180,5 @@ export {
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
selectTriggerClassName,
|
||||
};
|
||||
+10
-2
@@ -47,11 +47,18 @@ export const backupStrategyEnum = z.enum([
|
||||
/**
|
||||
* Per-object overrides for the global mirror options in `giteaConfigSchema`.
|
||||
*
|
||||
* Every flag is nullable, and `null`/absent means "inherit from the next tier
|
||||
* Every field is nullable, and `null`/absent means "inherit from the next tier
|
||||
* out". Resolution order is global config -> organization -> repository, and it
|
||||
* is applied per flag, so a repository can override LFS while still inheriting
|
||||
* is applied per field, so a repository can override LFS while still inheriting
|
||||
* the org's issue setting. See `resolveMirrorOptions` in
|
||||
* `src/lib/utils/mirror-overrides.ts`.
|
||||
*
|
||||
* `releaseLimit` is the one non-boolean: how many of the newest GitHub releases
|
||||
* to keep in Gitea (the global default is `giteaConfigSchema.releaseLimit`).
|
||||
* It is deliberately a plain `z.number()` rather than `int().min(1)`: a stored
|
||||
* out-of-range value must degrade to "inherit" for that field alone, not make
|
||||
* the whole overrides object unparseable and silently drop the LFS opt-out
|
||||
* next to it. `normalizeReleaseLimit` does the sanitizing.
|
||||
*/
|
||||
export const mirrorOverridesSchema = z.object({
|
||||
lfs: z.boolean().nullable().optional(),
|
||||
@@ -62,6 +69,7 @@ export const mirrorOverridesSchema = z.object({
|
||||
mirrorPullRequests: z.boolean().nullable().optional(),
|
||||
mirrorLabels: z.boolean().nullable().optional(),
|
||||
mirrorMilestones: z.boolean().nullable().optional(),
|
||||
releaseLimit: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export type MirrorOverrides = z.infer<typeof mirrorOverridesSchema>;
|
||||
|
||||
@@ -12,6 +12,7 @@ const mockMirrorGitRepoIssuesToGitea = mock(() => Promise.resolve());
|
||||
const mockMirrorGitRepoPullRequestsToGitea = mock(() => Promise.resolve());
|
||||
const mockMirrorGitRepoLabelsToGitea = mock(() => Promise.resolve());
|
||||
const mockMirrorGitRepoMilestonesToGitea = mock(() => Promise.resolve());
|
||||
const mockSyncRepositoryMetadataToGitea = mock(() => Promise.resolve());
|
||||
const mockGetGiteaRepoOwnerAsync = mock(() => Promise.resolve("starred"));
|
||||
const mockCreatePreSyncBundleBackup = mock(() =>
|
||||
Promise.resolve({ bundlePath: "/tmp/mock.bundle" })
|
||||
@@ -355,6 +356,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mockMirrorGitRepoPullRequestsToGitea.mockClear();
|
||||
mockMirrorGitRepoLabelsToGitea.mockClear();
|
||||
mockMirrorGitRepoMilestonesToGitea.mockClear();
|
||||
mockSyncRepositoryMetadataToGitea.mockClear();
|
||||
mockGetGiteaRepoOwnerAsync.mockClear();
|
||||
mockGetGiteaRepoOwnerAsync.mockImplementation(() => Promise.resolve("starred"));
|
||||
mockHttpGet.mockClear();
|
||||
@@ -572,6 +574,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow("Repository non-mirror-repo is not a mirror. Cannot sync.");
|
||||
@@ -620,6 +623,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -631,6 +635,71 @@ describe("Enhanced Gitea Operations", () => {
|
||||
expect(releaseCall.giteaRepoName).toBe("mirror-repo");
|
||||
expect(releaseCall.config.githubConfig?.token).toBe("github-token");
|
||||
expect(releaseCall.octokit).toBeDefined();
|
||||
// No override anywhere: the resolved default limit is handed through.
|
||||
expect(releaseCall.releaseLimit).toBe(10);
|
||||
|
||||
// Description/topics are reconciled on every sync, against the resolved
|
||||
// target, with the Gitea repo info already fetched during target
|
||||
// resolution so unchanged values can skip the write.
|
||||
expect(mockSyncRepositoryMetadataToGitea).toHaveBeenCalledTimes(1);
|
||||
const metadataCall = mockSyncRepositoryMetadataToGitea.mock.calls[0][0] as any;
|
||||
expect(metadataCall.giteaOwner).toBe("starred");
|
||||
expect(metadataCall.giteaRepoName).toBe("mirror-repo");
|
||||
expect(metadataCall.giteaToken).toBe("encrypted-token");
|
||||
expect(metadataCall.octokit).toBeDefined();
|
||||
expect(metadataCall.current?.mirror).toBe(true);
|
||||
});
|
||||
|
||||
test("passes the per-repository release limit through to the release mirror", async () => {
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: false,
|
||||
mirrorStarred: true,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
releaseLimit: 25,
|
||||
},
|
||||
};
|
||||
|
||||
const repository: Repository = {
|
||||
id: "repo456",
|
||||
name: "mirror-repo",
|
||||
fullName: "user/mirror-repo",
|
||||
owner: "user",
|
||||
cloneUrl: "https://github.com/user/mirror-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: true,
|
||||
status: repoStatusEnum.parse("mirrored"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
mirrorOverrides: { releaseLimit: 3 },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockMirrorGitHubReleasesToGitea).toHaveBeenCalledTimes(1);
|
||||
const releaseCall = mockMirrorGitHubReleasesToGitea.mock.calls[0][0] as any;
|
||||
expect(releaseCall.releaseLimit).toBe(3);
|
||||
});
|
||||
|
||||
test("prefers recorded mirroredLocation when owner resolution changes", async () => {
|
||||
@@ -677,6 +746,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -738,6 +808,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -821,6 +892,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -892,6 +964,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -970,6 +1043,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow("Repository collide-repo not found in Gitea. Tried locations:");
|
||||
@@ -1037,6 +1111,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow("Snapshot failed; sync blocked to protect history.");
|
||||
@@ -1096,6 +1171,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1153,6 +1229,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1220,6 +1297,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
|
||||
type SyncDependencies = {
|
||||
getGiteaRepoOwnerAsync: typeof import("./gitea")["getGiteaRepoOwnerAsync"];
|
||||
syncRepositoryMetadataToGitea: typeof import("./gitea")["syncRepositoryMetadataToGitea"];
|
||||
mirrorGitHubReleasesToGitea: typeof import("./gitea")["mirrorGitHubReleasesToGitea"];
|
||||
mirrorGitRepoIssuesToGitea: typeof import("./gitea")["mirrorGitRepoIssuesToGitea"];
|
||||
mirrorGitRepoPullRequestsToGitea: typeof import("./gitea")["mirrorGitRepoPullRequestsToGitea"];
|
||||
@@ -58,6 +59,10 @@ export interface GiteaRepoInfo {
|
||||
// GitHub source (vs. a same-named mirror of a different source).
|
||||
original_url?: string;
|
||||
private: boolean;
|
||||
// Current description/topics, so the per-sync metadata reconciliation can
|
||||
// skip writes that would not change anything.
|
||||
description?: string;
|
||||
topics?: string[];
|
||||
}
|
||||
|
||||
interface SyncTargetCandidate {
|
||||
@@ -755,6 +760,33 @@ export async function syncGiteaRepoEnhanced({
|
||||
return metadataOctokit;
|
||||
};
|
||||
|
||||
// Keep the description and topics in step with GitHub on every sync,
|
||||
// not only at migration time. Mirrors created before the description
|
||||
// was carried over (#224), or whose upstream description changed since,
|
||||
// otherwise stay stale forever (reported in #361). `repoInfo` is the
|
||||
// Gitea state fetched during target resolution, so unchanged values
|
||||
// cost no write. Without a GitHub token the stored description is used
|
||||
// and topics are left alone.
|
||||
try {
|
||||
await dependencies.syncRepositoryMetadataToGitea({
|
||||
config,
|
||||
octokit: ensureOctokit(),
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repoName,
|
||||
giteaToken: decryptedConfig.giteaConfig.token,
|
||||
current: repoInfo,
|
||||
});
|
||||
} catch (metadataError) {
|
||||
console.warn(
|
||||
`[Sync] Failed to reconcile description/topics for ${repository.name}: ${
|
||||
metadataError instanceof Error
|
||||
? metadataError.message
|
||||
: String(metadataError)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
// Reconcile metadata on every sync (matches the release path).
|
||||
// The underlying mirror* functions are idempotent: issues/PRs are
|
||||
// matched via [GH-ISSUE #N] / [GH-PR #N] markers and PATCHed in
|
||||
@@ -781,6 +813,7 @@ export async function syncGiteaRepoEnhanced({
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repoName,
|
||||
releaseLimit: mirrorOptions.releaseLimit,
|
||||
});
|
||||
metadataState.components.releases = true;
|
||||
metadataUpdated = true;
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Unit tests for syncRepositoryMetadataToGitea (description + topics).
|
||||
*
|
||||
* Follow-up to #361: the description was only sent at migration time, from the
|
||||
* locally stored value, so mirrors created before it was carried over (#224)
|
||||
* or whose upstream description changed since stayed stale for good. The sync
|
||||
* path now reconciles it on every run, reading GitHub live when a token is
|
||||
* available and skipping writes that would change nothing.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
const okResponse = () => ({
|
||||
data: {},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const mockHttpGet = mock(async (_url: string, _headers?: any) => okResponse());
|
||||
const mockHttpPatch = mock(async (_url: string, _body?: any, _headers?: any) =>
|
||||
okResponse()
|
||||
);
|
||||
const mockHttpPut = mock(async (_url: string, _body?: any, _headers?: any) =>
|
||||
okResponse()
|
||||
);
|
||||
const mockHttpPost = mock(async () => okResponse());
|
||||
const mockHttpDelete = mock(async () => okResponse());
|
||||
|
||||
class MockHttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public statusText: string,
|
||||
public response?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
}
|
||||
}
|
||||
|
||||
mock.module("@/lib/http-client", () => ({
|
||||
httpGet: mockHttpGet,
|
||||
httpPatch: mockHttpPatch,
|
||||
httpPut: mockHttpPut,
|
||||
httpPost: mockHttpPost,
|
||||
httpDelete: mockHttpDelete,
|
||||
HttpError: MockHttpError,
|
||||
}));
|
||||
|
||||
// Capture what gets written back to the repositories row.
|
||||
const dbUpdateSetCalls: any[] = [];
|
||||
mock.module("@/lib/db", () => ({
|
||||
db: {
|
||||
update: () => ({
|
||||
set: (data: any) => {
|
||||
dbUpdateSetCalls.push(data);
|
||||
return { where: async () => {} };
|
||||
},
|
||||
}),
|
||||
select: () => ({
|
||||
from: () => ({ where: () => ({ limit: async () => [] }) }),
|
||||
}),
|
||||
insert: () => ({ values: async () => ({}) }),
|
||||
},
|
||||
users: {},
|
||||
configs: {},
|
||||
repositories: {},
|
||||
organizations: {},
|
||||
mirrorJobs: {},
|
||||
events: {},
|
||||
accounts: {},
|
||||
sessions: {},
|
||||
}));
|
||||
|
||||
mock.module("@/lib/helpers", () => ({
|
||||
createMirrorJob: mock(async () => "job-id"),
|
||||
}));
|
||||
|
||||
import { syncRepositoryMetadataToGitea } from "./gitea";
|
||||
import type { Config, Repository } from "./db/schema";
|
||||
|
||||
const GITEA_URL = "https://gitea.example.com";
|
||||
const REPO_API = `${GITEA_URL}/api/v1/repos/mirror/browser-use`;
|
||||
const UPSTREAM_DESCRIPTION = "Make websites accessible for AI agents";
|
||||
|
||||
function makeConfig(gitea: Record<string, unknown> = {}): Partial<Config> {
|
||||
return {
|
||||
userId: "user-1",
|
||||
giteaConfig: {
|
||||
url: GITEA_URL,
|
||||
token: "gitea-token",
|
||||
defaultOwner: "mirror",
|
||||
...gitea,
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repository> = {}): Repository {
|
||||
return {
|
||||
id: "repo-1",
|
||||
userId: "user-1",
|
||||
name: "browser-use",
|
||||
fullName: "browser-use/browser-use",
|
||||
owner: "browser-use",
|
||||
cloneUrl: "https://github.com/browser-use/browser-use.git",
|
||||
isPrivate: false,
|
||||
isStarred: false,
|
||||
status: "mirrored",
|
||||
visibility: "public",
|
||||
description: "stored description",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as Repository;
|
||||
}
|
||||
|
||||
/** Octokit stand-in: a single `request` that resolves to `data` or throws. */
|
||||
function makeOctokit(result: Record<string, unknown> | Error) {
|
||||
const request = mock(async () => {
|
||||
if (result instanceof Error) throw result;
|
||||
return { data: result, status: 200, headers: {} };
|
||||
});
|
||||
return { request } as any;
|
||||
}
|
||||
|
||||
async function run(opts: {
|
||||
octokit: any;
|
||||
repository?: Repository;
|
||||
config?: Partial<Config>;
|
||||
current?: { description?: string | null; topics?: string[] | null } | null;
|
||||
}) {
|
||||
await syncRepositoryMetadataToGitea({
|
||||
config: opts.config ?? makeConfig(),
|
||||
octokit: opts.octokit,
|
||||
repository: opts.repository ?? makeRepo(),
|
||||
giteaOwner: "mirror",
|
||||
giteaRepoName: "browser-use",
|
||||
giteaToken: "gitea-token",
|
||||
current: opts.current,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockHttpPatch.mockClear();
|
||||
mockHttpPut.mockClear();
|
||||
dbUpdateSetCalls.length = 0;
|
||||
});
|
||||
|
||||
describe("syncRepositoryMetadataToGitea", () => {
|
||||
test("reads description and topics from GitHub in one call and writes both to Gitea", async () => {
|
||||
const octokit = makeOctokit({
|
||||
description: UPSTREAM_DESCRIPTION,
|
||||
topics: ["ai", "browser"],
|
||||
});
|
||||
|
||||
await run({ octokit });
|
||||
|
||||
expect(octokit.request).toHaveBeenCalledTimes(1);
|
||||
expect(octokit.request.mock.calls[0][0]).toBe("GET /repos/{owner}/{repo}");
|
||||
expect(octokit.request.mock.calls[0][1]).toMatchObject({
|
||||
owner: "browser-use",
|
||||
repo: "browser-use",
|
||||
});
|
||||
|
||||
expect(mockHttpPatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockHttpPatch.mock.calls[0][0]).toBe(REPO_API);
|
||||
expect(mockHttpPatch.mock.calls[0][1]).toEqual({
|
||||
description: UPSTREAM_DESCRIPTION,
|
||||
});
|
||||
|
||||
expect(mockHttpPut).toHaveBeenCalledTimes(1);
|
||||
expect(mockHttpPut.mock.calls[0][0]).toBe(`${REPO_API}/topics`);
|
||||
expect(mockHttpPut.mock.calls[0][1]).toEqual({ topics: ["ai", "browser"] });
|
||||
});
|
||||
|
||||
test("GitHub wins over the stored description and the row is refreshed", async () => {
|
||||
await run({
|
||||
octokit: makeOctokit({ description: UPSTREAM_DESCRIPTION, topics: [] }),
|
||||
});
|
||||
|
||||
expect(mockHttpPatch.mock.calls[0][1]).toEqual({
|
||||
description: UPSTREAM_DESCRIPTION,
|
||||
});
|
||||
expect(dbUpdateSetCalls).toHaveLength(1);
|
||||
expect(dbUpdateSetCalls[0].description).toBe(UPSTREAM_DESCRIPTION);
|
||||
});
|
||||
|
||||
test("leaves the row alone when the stored description already matches", async () => {
|
||||
await run({
|
||||
octokit: makeOctokit({ description: "stored description", topics: [] }),
|
||||
});
|
||||
|
||||
expect(dbUpdateSetCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("skips both writes when Gitea already holds the upstream values", async () => {
|
||||
await run({
|
||||
octokit: makeOctokit({
|
||||
description: UPSTREAM_DESCRIPTION,
|
||||
topics: ["ai", "browser"],
|
||||
}),
|
||||
// Topic order from Gitea is not guaranteed; the comparison is a set.
|
||||
current: { description: UPSTREAM_DESCRIPTION, topics: ["browser", "ai"] },
|
||||
});
|
||||
|
||||
expect(mockHttpPatch).not.toHaveBeenCalled();
|
||||
expect(mockHttpPut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("writes only what changed", async () => {
|
||||
await run({
|
||||
octokit: makeOctokit({
|
||||
description: UPSTREAM_DESCRIPTION,
|
||||
topics: ["ai", "browser", "llm"],
|
||||
}),
|
||||
current: { description: UPSTREAM_DESCRIPTION, topics: ["ai", "browser"] },
|
||||
});
|
||||
|
||||
expect(mockHttpPatch).not.toHaveBeenCalled();
|
||||
expect(mockHttpPut).toHaveBeenCalledTimes(1);
|
||||
expect(mockHttpPut.mock.calls[0][1]).toEqual({
|
||||
topics: ["ai", "browser", "llm"],
|
||||
});
|
||||
});
|
||||
|
||||
test("a Gitea repo that never had a description still gets one", async () => {
|
||||
// Gitea reports an empty description for such repos. That must read as
|
||||
// "differs from upstream", not "unknown, skip".
|
||||
await run({
|
||||
octokit: makeOctokit({ description: UPSTREAM_DESCRIPTION, topics: [] }),
|
||||
current: { description: "", topics: [] },
|
||||
});
|
||||
|
||||
expect(mockHttpPatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockHttpPatch.mock.calls[0][1]).toEqual({
|
||||
description: UPSTREAM_DESCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
test("without `current` the description is written unconditionally (migration path)", async () => {
|
||||
await run({
|
||||
octokit: makeOctokit({ description: UPSTREAM_DESCRIPTION, topics: [] }),
|
||||
});
|
||||
|
||||
expect(mockHttpPatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("an empty upstream description clears the Gitea one and the stored one", async () => {
|
||||
await run({
|
||||
octokit: makeOctokit({ description: null, topics: [] }),
|
||||
current: { description: "old text", topics: [] },
|
||||
});
|
||||
|
||||
expect(mockHttpPatch.mock.calls[0][1]).toEqual({ description: "" });
|
||||
expect(dbUpdateSetCalls).toHaveLength(1);
|
||||
expect(dbUpdateSetCalls[0].description).toBeNull();
|
||||
});
|
||||
|
||||
test("falls back to the stored description when GitHub is unreachable", async () => {
|
||||
await run({ octokit: makeOctokit(new Error("GitHub down")) });
|
||||
|
||||
expect(mockHttpPatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockHttpPatch.mock.calls[0][1]).toEqual({
|
||||
description: "stored description",
|
||||
});
|
||||
// Nothing fresh to store, and no topic list to push.
|
||||
expect(dbUpdateSetCalls).toHaveLength(0);
|
||||
expect(mockHttpPut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a GitHub outage never wipes a description Gitea already has", async () => {
|
||||
// With no stored value and no upstream answer there is nothing
|
||||
// authoritative to write, so the transient-failure path must not PATCH
|
||||
// an empty string over a real description.
|
||||
await run({
|
||||
octokit: makeOctokit(new Error("GitHub down")),
|
||||
repository: makeRepo({ description: null }),
|
||||
current: { description: UPSTREAM_DESCRIPTION, topics: [] },
|
||||
});
|
||||
|
||||
expect(mockHttpPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("without a GitHub token uses the stored description and skips topics", async () => {
|
||||
await run({ octokit: null });
|
||||
|
||||
expect(mockHttpPatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockHttpPatch.mock.calls[0][1]).toEqual({
|
||||
description: "stored description",
|
||||
});
|
||||
expect(mockHttpPut).not.toHaveBeenCalled();
|
||||
expect(dbUpdateSetCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("honors addTopics=false but still syncs the description", async () => {
|
||||
await run({
|
||||
octokit: makeOctokit({ description: UPSTREAM_DESCRIPTION, topics: ["ai"] }),
|
||||
config: makeConfig({ addTopics: false }),
|
||||
});
|
||||
|
||||
expect(mockHttpPatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockHttpPut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("applies the configured topic prefix before comparing and writing", async () => {
|
||||
await run({
|
||||
octokit: makeOctokit({
|
||||
description: UPSTREAM_DESCRIPTION,
|
||||
topics: ["AI", "Browser Use"],
|
||||
}),
|
||||
config: makeConfig({ topicPrefix: "gh" }),
|
||||
current: { description: UPSTREAM_DESCRIPTION, topics: ["gh-ai"] },
|
||||
});
|
||||
|
||||
expect(mockHttpPut).toHaveBeenCalledTimes(1);
|
||||
expect(mockHttpPut.mock.calls[0][1]).toEqual({
|
||||
topics: ["gh-ai", "gh-browser-use"],
|
||||
});
|
||||
});
|
||||
|
||||
test("a failed Gitea write is logged, not thrown, and topics still get their turn", async () => {
|
||||
mockHttpPatch.mockImplementationOnce(async () => {
|
||||
throw new MockHttpError("nope", 500, "Server Error");
|
||||
});
|
||||
|
||||
await expect(
|
||||
run({
|
||||
octokit: makeOctokit({ description: UPSTREAM_DESCRIPTION, topics: ["ai"] }),
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mockHttpPut).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+137
-39
@@ -18,7 +18,10 @@ import {
|
||||
parseRepositoryMetadataState,
|
||||
serializeRepositoryMetadataState,
|
||||
} from "./metadata-state";
|
||||
import { resolveMirrorOptionsForRepository } from "./utils/mirror-overrides";
|
||||
import {
|
||||
normalizeReleaseLimit,
|
||||
resolveMirrorOptionsForRepository,
|
||||
} from "./utils/mirror-overrides";
|
||||
|
||||
/**
|
||||
* Helper function to get organization configuration including destination override
|
||||
@@ -416,17 +419,29 @@ const getSourceRepositoryCoordinates = (repository: Repository) => {
|
||||
};
|
||||
};
|
||||
|
||||
const fetchGitHubTopics = async ({
|
||||
type GitHubRepositoryMetadata = {
|
||||
description: string | null;
|
||||
/** Null when the payload carried no usable topic list. */
|
||||
topics: string[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the upstream description and topics in one call.
|
||||
* `GET /repos/{owner}/{repo}` carries both, and the client's ETag layer (see
|
||||
* createGitHubClient) turns the per-sync re-check into a rate-limit-free 304
|
||||
* whenever nothing changed upstream.
|
||||
*/
|
||||
const fetchGitHubRepositoryMetadata = async ({
|
||||
octokit,
|
||||
repository,
|
||||
}: {
|
||||
octokit: Octokit;
|
||||
repository: Repository;
|
||||
}): Promise<string[] | null> => {
|
||||
}): Promise<GitHubRepositoryMetadata | null> => {
|
||||
const { owner, repo } = getSourceRepositoryCoordinates(repository);
|
||||
|
||||
try {
|
||||
const response = await octokit.request("GET /repos/{owner}/{repo}/topics", {
|
||||
const response = await octokit.request("GET /repos/{owner}/{repo}", {
|
||||
owner,
|
||||
repo,
|
||||
headers: {
|
||||
@@ -434,18 +449,23 @@ const fetchGitHubTopics = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const names = (response.data as { names?: unknown }).names;
|
||||
if (!Array.isArray(names)) {
|
||||
const data = response.data as { description?: unknown; topics?: unknown };
|
||||
const description =
|
||||
typeof data.description === "string" ? data.description : null;
|
||||
const topics = Array.isArray(data.topics)
|
||||
? data.topics.filter((topic): topic is string => typeof topic === "string")
|
||||
: null;
|
||||
|
||||
if (topics === null) {
|
||||
console.warn(
|
||||
`[Metadata] Unexpected topics payload for ${repository.fullName}; skipping topic sync.`
|
||||
`[Metadata] Unexpected topics payload for ${repository.fullName}; topic sync will be skipped.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return names.filter((topic): topic is string => typeof topic === "string");
|
||||
return { description, topics };
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Metadata] Failed to fetch topics from GitHub for ${repository.fullName}: ${
|
||||
`[Metadata] Failed to fetch repository metadata from GitHub for ${repository.fullName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
@@ -453,55 +473,116 @@ const fetchGitHubTopics = async ({
|
||||
}
|
||||
};
|
||||
|
||||
const syncRepositoryMetadataToGitea = async ({
|
||||
/** Order-insensitive, case-insensitive topic list comparison. */
|
||||
const sameTopicSet = (a: string[], b: string[]): boolean => {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = a.map((topic) => topic.toLowerCase()).sort();
|
||||
const sortedB = b.map((topic) => topic.toLowerCase()).sort();
|
||||
return sortedA.every((topic, index) => topic === sortedB[index]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Bring the Gitea repository's description and topics in line with GitHub.
|
||||
*
|
||||
* Runs after migration and on every sync. GitHub is authoritative whenever it
|
||||
* can be reached; the stored `repository.description` is only a fallback (no
|
||||
* token, or a transient GitHub failure), and an empty fallback is never
|
||||
* written so a blip upstream cannot wipe a description Gitea already has.
|
||||
* A refreshed description is written back to the local row so the UI and any
|
||||
* token-less path see the same value Gitea does.
|
||||
*
|
||||
* `current` is what Gitea holds right now, for callers that already fetched
|
||||
* the repo. Values that already match are not rewritten.
|
||||
*
|
||||
* Every step degrades to a warning: metadata must never fail a mirror run.
|
||||
*/
|
||||
export const syncRepositoryMetadataToGitea = async ({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner,
|
||||
giteaRepoName,
|
||||
giteaToken,
|
||||
current,
|
||||
}: {
|
||||
config: Partial<Config>;
|
||||
octokit: Octokit;
|
||||
/** Null when no GitHub token is available. */
|
||||
octokit: Octokit | null;
|
||||
repository: Repository;
|
||||
giteaOwner: string;
|
||||
giteaRepoName: string;
|
||||
giteaToken: string;
|
||||
current?: { description?: string | null; topics?: string[] | null } | null;
|
||||
}): Promise<void> => {
|
||||
const giteaBaseUrl = config.giteaConfig?.url;
|
||||
if (!giteaBaseUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repoApiUrl = `${giteaBaseUrl}/api/v1/repos/${giteaOwner}/${giteaRepoName}`;
|
||||
const target = `${giteaOwner}/${giteaRepoName}`;
|
||||
const repoApiUrl = `${giteaBaseUrl}/api/v1/repos/${target}`;
|
||||
const authHeaders = {
|
||||
Authorization: `token ${giteaToken}`,
|
||||
};
|
||||
const description = repository.description?.trim() || "";
|
||||
|
||||
try {
|
||||
await httpPatch(
|
||||
repoApiUrl,
|
||||
{ description },
|
||||
authHeaders
|
||||
);
|
||||
const upstream = octokit
|
||||
? await fetchGitHubRepositoryMetadata({ octokit, repository })
|
||||
: null;
|
||||
|
||||
const storedDescription = repository.description ?? null;
|
||||
const description =
|
||||
(upstream ? upstream.description : storedDescription)?.trim() || "";
|
||||
|
||||
if (upstream && repository.id && upstream.description !== storedDescription) {
|
||||
try {
|
||||
await db
|
||||
.update(repositories)
|
||||
.set({ description: upstream.description, updatedAt: new Date() })
|
||||
.where(eq(repositories.id, repository.id));
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Metadata] Failed to store the refreshed description for ${repository.fullName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!upstream && !description) {
|
||||
console.log(
|
||||
`[Metadata] Synced description for ${repository.fullName} to ${giteaOwner}/${giteaRepoName}`
|
||||
`[Metadata] No description available for ${repository.fullName} (GitHub not consulted); leaving ${target} unchanged`
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Metadata] Failed to sync description for ${repository.fullName} to ${giteaOwner}/${giteaRepoName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
} else if (current && (current.description ?? "").trim() === description) {
|
||||
console.log(
|
||||
`[Metadata] Description for ${repository.fullName} already matches ${target}; skipping`
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
await httpPatch(repoApiUrl, { description }, authHeaders);
|
||||
console.log(
|
||||
`[Metadata] Synced description for ${repository.fullName} to ${target}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Metadata] Failed to sync description for ${repository.fullName} to ${target}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.giteaConfig?.addTopics === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTopics = await fetchGitHubTopics({ octokit, repository });
|
||||
if (sourceTopics === null) {
|
||||
if (!octokit) {
|
||||
console.log(
|
||||
`[Metadata] Skipping topic sync for ${repository.fullName}: no GitHub token`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!upstream?.topics) {
|
||||
console.warn(
|
||||
`[Metadata] Skipping topic sync for ${repository.fullName} because GitHub topics could not be fetched.`
|
||||
);
|
||||
@@ -509,22 +590,25 @@ const syncRepositoryMetadataToGitea = async ({
|
||||
}
|
||||
|
||||
const topics = normalizeTopicsForGitea(
|
||||
sourceTopics,
|
||||
upstream.topics,
|
||||
config.giteaConfig?.topicPrefix
|
||||
);
|
||||
|
||||
try {
|
||||
await httpPut(
|
||||
`${repoApiUrl}/topics`,
|
||||
{ topics },
|
||||
authHeaders
|
||||
);
|
||||
if (current?.topics && sameTopicSet(current.topics, topics)) {
|
||||
console.log(
|
||||
`[Metadata] Synced ${topics.length} topic(s) for ${repository.fullName} to ${giteaOwner}/${giteaRepoName}`
|
||||
`[Metadata] Topics for ${repository.fullName} already match ${target}; skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await httpPut(`${repoApiUrl}/topics`, { topics }, authHeaders);
|
||||
console.log(
|
||||
`[Metadata] Synced ${topics.length} topic(s) for ${repository.fullName} to ${target}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Metadata] Failed to sync topics for ${repository.fullName} to ${giteaOwner}/${giteaRepoName}: ${
|
||||
`[Metadata] Failed to sync topics for ${repository.fullName} to ${target}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
@@ -940,6 +1024,7 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: targetRepoName,
|
||||
releaseLimit: mirrorOptions.releaseLimit,
|
||||
});
|
||||
metadataState.components.releases = true;
|
||||
metadataUpdated = true;
|
||||
@@ -1697,6 +1782,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
repository,
|
||||
giteaOwner: orgName,
|
||||
giteaRepoName: targetRepoName,
|
||||
releaseLimit: mirrorOptions.releaseLimit,
|
||||
});
|
||||
metadataState.components.releases = true;
|
||||
metadataUpdated = true;
|
||||
@@ -2992,12 +3078,19 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
config,
|
||||
giteaOwner,
|
||||
giteaRepoName,
|
||||
releaseLimit: releaseLimitOverride,
|
||||
}: {
|
||||
octokit: Octokit;
|
||||
repository: Repository;
|
||||
config: Partial<Config>;
|
||||
giteaOwner?: string;
|
||||
giteaRepoName?: string;
|
||||
/**
|
||||
* Already-resolved limit (global -> org -> repo). Callers that went through
|
||||
* `resolveMirrorOptionsForRepository` pass it in; when absent it is resolved
|
||||
* here so direct callers still honor per-repo overrides.
|
||||
*/
|
||||
releaseLimit?: number;
|
||||
}) {
|
||||
if (
|
||||
!config.giteaConfig?.defaultOwner ||
|
||||
@@ -3035,8 +3128,13 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
throw new Error(`Repository ${repository.name} does not exist in Gitea at ${repoOwner}. Please ensure the repository is mirrored first.`);
|
||||
}
|
||||
|
||||
// Get release limit from config (default to 10)
|
||||
const releaseLimit = Math.max(1, Math.floor(config.giteaConfig?.releaseLimit || 10));
|
||||
// How many of the newest releases to keep. Per-repo and per-org overrides
|
||||
// win over the global setting (#361); the resolver guarantees a positive
|
||||
// integer. Everything below the limit is created/updated, everything beyond
|
||||
// it is pruned from Gitea by the retention pass at the end.
|
||||
const releaseLimit =
|
||||
normalizeReleaseLimit(releaseLimitOverride) ??
|
||||
(await resolveMirrorOptionsForRepository({ config, repository })).releaseLimit;
|
||||
|
||||
// GitHub API max per page is 100; paginate until we reach the configured limit.
|
||||
const releases: Awaited<
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
looksLikeUrl,
|
||||
parseGitHubOwnerReference,
|
||||
parseGitHubRepoReference,
|
||||
} from "./github-url";
|
||||
|
||||
describe("parseGitHubRepoReference", () => {
|
||||
it("parses the browser URL", () => {
|
||||
expect(parseGitHubRepoReference("https://github.com/vercel/next.js")).toEqual({
|
||||
owner: "vercel",
|
||||
repo: "next.js",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses clone and SSH remotes, dropping the .git suffix", () => {
|
||||
expect(
|
||||
parseGitHubRepoReference("https://github.com/vercel/next.js.git")
|
||||
).toEqual({ owner: "vercel", repo: "next.js" });
|
||||
expect(
|
||||
parseGitHubRepoReference("git@github.com:vercel/next.js.git")
|
||||
).toEqual({ owner: "vercel", repo: "next.js" });
|
||||
expect(
|
||||
parseGitHubRepoReference("ssh://git@github.com/vercel/next.js.git")
|
||||
).toEqual({ owner: "vercel", repo: "next.js" });
|
||||
});
|
||||
|
||||
it("ignores everything after the repo in a deep link", () => {
|
||||
expect(
|
||||
parseGitHubRepoReference(
|
||||
"https://github.com/vercel/next.js/tree/canary/packages/next"
|
||||
)
|
||||
).toEqual({ owner: "vercel", repo: "next.js" });
|
||||
expect(
|
||||
parseGitHubRepoReference("https://github.com/vercel/next.js/issues/123")
|
||||
).toEqual({ owner: "vercel", repo: "next.js" });
|
||||
});
|
||||
|
||||
it("accepts a bare host and the owner/repo shorthand", () => {
|
||||
expect(parseGitHubRepoReference("github.com/vercel/next.js")).toEqual({
|
||||
owner: "vercel",
|
||||
repo: "next.js",
|
||||
});
|
||||
expect(parseGitHubRepoReference("vercel/next.js")).toEqual({
|
||||
owner: "vercel",
|
||||
repo: "next.js",
|
||||
});
|
||||
});
|
||||
|
||||
it("tolerates wrapping, trailing slashes, and query strings", () => {
|
||||
expect(
|
||||
parseGitHubRepoReference(" <https://github.com/vercel/next.js/> ")
|
||||
).toEqual({ owner: "vercel", repo: "next.js" });
|
||||
expect(
|
||||
parseGitHubRepoReference("https://github.com/vercel/next.js?tab=readme")
|
||||
).toEqual({ owner: "vercel", repo: "next.js" });
|
||||
});
|
||||
|
||||
it("rejects input that does not name a repository", () => {
|
||||
expect(parseGitHubRepoReference("")).toBeNull();
|
||||
expect(parseGitHubRepoReference("https://github.com/vercel")).toBeNull();
|
||||
expect(parseGitHubRepoReference("https://github.com/orgs/vercel")).toBeNull();
|
||||
expect(parseGitHubRepoReference("https://github.com/settings/tokens")).toBeNull();
|
||||
expect(parseGitHubRepoReference("not a url at all")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGitHubOwnerReference", () => {
|
||||
it("parses profile, org, and bare-name forms", () => {
|
||||
expect(parseGitHubOwnerReference("https://github.com/microsoft")).toBe(
|
||||
"microsoft"
|
||||
);
|
||||
expect(
|
||||
parseGitHubOwnerReference("https://github.com/orgs/microsoft/repositories")
|
||||
).toBe("microsoft");
|
||||
expect(parseGitHubOwnerReference("microsoft")).toBe("microsoft");
|
||||
expect(parseGitHubOwnerReference("github.com/microsoft/")).toBe("microsoft");
|
||||
});
|
||||
|
||||
it("takes the owner from a repository URL", () => {
|
||||
expect(parseGitHubOwnerReference("https://github.com/vercel/next.js")).toBe(
|
||||
"vercel"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects reserved GitHub paths and empty input", () => {
|
||||
expect(parseGitHubOwnerReference("https://github.com/settings")).toBeNull();
|
||||
expect(parseGitHubOwnerReference("")).toBeNull();
|
||||
expect(parseGitHubOwnerReference(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeUrl", () => {
|
||||
it("separates pasted references from plain names", () => {
|
||||
expect(looksLikeUrl("https://github.com/vercel/next.js")).toBe(true);
|
||||
expect(looksLikeUrl("git@github.com:vercel/next.js.git")).toBe(true);
|
||||
expect(looksLikeUrl("vercel/next.js")).toBe(true);
|
||||
expect(looksLikeUrl("next.js")).toBe(false);
|
||||
expect(looksLikeUrl("microsoft")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Parsing for the GitHub references people paste into the add dialogs.
|
||||
*
|
||||
* Accepts what someone actually has on their clipboard: a browser URL, a clone
|
||||
* URL, an SSH remote, or the `owner/repo` shorthand. Deep links are fine too,
|
||||
* since the owner and repo sit at the front of the path and everything after
|
||||
* them (tree/blob/issues/...) is noise for our purposes.
|
||||
*/
|
||||
|
||||
/** Path segments that follow github.com but are not an account name. */
|
||||
const RESERVED_ROOT_SEGMENTS = new Set([
|
||||
"about",
|
||||
"apps",
|
||||
"collections",
|
||||
"contact",
|
||||
"customer-stories",
|
||||
"enterprise",
|
||||
"events",
|
||||
"explore",
|
||||
"features",
|
||||
"issues",
|
||||
"login",
|
||||
"marketplace",
|
||||
"notifications",
|
||||
"pricing",
|
||||
"pulls",
|
||||
"search",
|
||||
"security",
|
||||
"settings",
|
||||
"signup",
|
||||
"sponsors",
|
||||
"topics",
|
||||
"trending",
|
||||
]);
|
||||
|
||||
/** Trailing segments GitHub appends to a repo URL that we can safely ignore. */
|
||||
const GIT_SUFFIX = /\.git$/i;
|
||||
|
||||
function stripWrapping(input: string): string {
|
||||
// Markdown/angle-bracket wrapping survives a lot of copy-paste paths.
|
||||
return input
|
||||
.trim()
|
||||
.replace(/^<|>$/g, "")
|
||||
.replace(/^["']|["']$/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce any accepted form to its path segments.
|
||||
* Returns null when the input is not a GitHub-shaped reference at all.
|
||||
*/
|
||||
function toPathSegments(input: string): string[] | null {
|
||||
let value = stripWrapping(input);
|
||||
if (!value) return null;
|
||||
|
||||
// git@github.com:owner/repo.git -> owner/repo.git
|
||||
const sshMatch = value.match(/^(?:ssh:\/\/)?git@[^:/]+[:/](.+)$/i);
|
||||
if (sshMatch) {
|
||||
value = sshMatch[1];
|
||||
} else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) || /^[^/\s]+\.[^/\s]+\//.test(value)) {
|
||||
// A full URL, or a bare host like "github.com/owner/repo".
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
|
||||
? value
|
||||
: `https://${value}`;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(withScheme);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
value = url.pathname;
|
||||
}
|
||||
|
||||
const segments = value
|
||||
.split("/")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return segments.length > 0 ? segments : null;
|
||||
}
|
||||
|
||||
function isUsableAccount(segment: string | undefined): segment is string {
|
||||
if (!segment) return false;
|
||||
if (RESERVED_ROOT_SEGMENTS.has(segment.toLowerCase())) return false;
|
||||
// GitHub account names: alphanumerics and hyphens.
|
||||
return /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(segment);
|
||||
}
|
||||
|
||||
function isUsableRepoName(segment: string | undefined): segment is string {
|
||||
if (!segment) return false;
|
||||
return /^[A-Za-z0-9._-]+$/.test(segment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull `{ owner, repo }` out of a repository reference.
|
||||
* Returns null when the input does not name a repository.
|
||||
*/
|
||||
export function parseGitHubRepoReference(
|
||||
input: string
|
||||
): { owner: string; repo: string } | null {
|
||||
const segments = toPathSegments(input);
|
||||
if (!segments) return null;
|
||||
|
||||
// /orgs/<name>/... never names a repository.
|
||||
if (segments[0]?.toLowerCase() === "orgs") return null;
|
||||
|
||||
const [owner, rawRepo] = segments;
|
||||
if (!isUsableAccount(owner)) return null;
|
||||
|
||||
const repo = rawRepo?.replace(GIT_SUFFIX, "");
|
||||
if (!isUsableRepoName(repo)) return null;
|
||||
|
||||
return { owner, repo };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull an organization (or user) name out of an account reference.
|
||||
* Accepts the profile URL, the /orgs/ form, and a bare name.
|
||||
*/
|
||||
export function parseGitHubOwnerReference(input: string): string | null {
|
||||
const segments = toPathSegments(input);
|
||||
if (!segments) return null;
|
||||
|
||||
const candidate =
|
||||
segments[0]?.toLowerCase() === "orgs" ? segments[1] : segments[0];
|
||||
|
||||
return isUsableAccount(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
/** True when the input looks like a URL rather than a plain name. */
|
||||
export function looksLikeUrl(input: string): boolean {
|
||||
const value = stripWrapping(input);
|
||||
return (
|
||||
/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ||
|
||||
/^git@/i.test(value) ||
|
||||
value.includes("/")
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
DEFAULT_RELEASE_LIMIT,
|
||||
getMirrorOverrideGating,
|
||||
hasMirrorOverrides,
|
||||
listOverriddenKeys,
|
||||
MIRROR_GATING_REASONS,
|
||||
MIRROR_OVERRIDE_KEYS,
|
||||
mirrorOptionsToFlags,
|
||||
normalizeMirrorOverrides,
|
||||
normalizeReleaseLimit,
|
||||
parseMirrorOverrides,
|
||||
resolveMirrorOptions,
|
||||
STARRED_CLAMPED_KEYS,
|
||||
@@ -53,10 +56,11 @@ describe("resolveMirrorOptions precedence", () => {
|
||||
repository: makeRepo(),
|
||||
});
|
||||
|
||||
for (const value of Object.values(resolved)) {
|
||||
expect(typeof value).toBe("boolean");
|
||||
for (const key of MIRROR_OVERRIDE_KEYS) {
|
||||
expect(typeof resolved[key]).toBe("boolean");
|
||||
}
|
||||
expect(resolved.lfs).toBe(false);
|
||||
expect(resolved.releaseLimit).toBe(DEFAULT_RELEASE_LIMIT);
|
||||
});
|
||||
|
||||
test("organization override beats global config", () => {
|
||||
@@ -608,3 +612,202 @@ describe("mirrorOptionsToFlags - client/server shape contract", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseLimit override", () => {
|
||||
test("falls back to the global release limit, then the default", () => {
|
||||
expect(
|
||||
resolveMirrorOptions({
|
||||
config: makeConfig({ releaseLimit: 25 }),
|
||||
repository: makeRepo(),
|
||||
}).releaseLimit
|
||||
).toBe(25);
|
||||
expect(
|
||||
resolveMirrorOptions({ config: makeConfig({}), repository: makeRepo() })
|
||||
.releaseLimit
|
||||
).toBe(DEFAULT_RELEASE_LIMIT);
|
||||
});
|
||||
|
||||
test("organization override beats global, repository override beats organization", () => {
|
||||
const config = makeConfig({ releaseLimit: 25 });
|
||||
|
||||
expect(
|
||||
resolveMirrorOptions({
|
||||
config,
|
||||
repository: makeRepo(),
|
||||
orgOverrides: { releaseLimit: 5 },
|
||||
}).releaseLimit
|
||||
).toBe(5);
|
||||
expect(
|
||||
resolveMirrorOptions({
|
||||
config,
|
||||
repository: makeRepo({ mirrorOverrides: { releaseLimit: 2 } }),
|
||||
orgOverrides: { releaseLimit: 5 },
|
||||
}).releaseLimit
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
test("an unusable limit at one tier falls through instead of clamping to 1", () => {
|
||||
const config = makeConfig({ releaseLimit: 25 });
|
||||
|
||||
expect(
|
||||
resolveMirrorOptions({
|
||||
config,
|
||||
repository: makeRepo({ mirrorOverrides: { releaseLimit: 0 } }),
|
||||
orgOverrides: { releaseLimit: 5 },
|
||||
}).releaseLimit
|
||||
).toBe(5);
|
||||
expect(
|
||||
resolveMirrorOptions({
|
||||
config,
|
||||
repository: makeRepo({ mirrorOverrides: { releaseLimit: -3 } }),
|
||||
}).releaseLimit
|
||||
).toBe(25);
|
||||
expect(
|
||||
resolveMirrorOptions({
|
||||
config: makeConfig({ releaseLimit: 0 }),
|
||||
repository: makeRepo(),
|
||||
}).releaseLimit
|
||||
).toBe(DEFAULT_RELEASE_LIMIT);
|
||||
});
|
||||
|
||||
test("fractional limits are floored", () => {
|
||||
expect(
|
||||
resolveMirrorOptions({
|
||||
config: makeConfig({}),
|
||||
repository: makeRepo({ mirrorOverrides: { releaseLimit: 3.9 } }),
|
||||
}).releaseLimit
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
test("null means inherit and does not disturb the boolean flags", () => {
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig({ releaseLimit: 7, lfs: true }),
|
||||
repository: makeRepo({
|
||||
mirrorOverrides: { releaseLimit: null, lfs: false },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resolved.releaseLimit).toBe(7);
|
||||
expect(resolved.lfs).toBe(false);
|
||||
});
|
||||
|
||||
test("a bad limit does not take the other overrides down with it", () => {
|
||||
// The schema accepts any number precisely so that a stored out-of-range
|
||||
// limit degrades to inherit for that field alone. The #361 LFS opt-out
|
||||
// stored next to it must survive.
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig({ lfs: true, releaseLimit: 25 }),
|
||||
repository: makeRepo({
|
||||
mirrorOverrides: { lfs: false, releaseLimit: -1 },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resolved.lfs).toBe(false);
|
||||
expect(resolved.releaseLimit).toBe(25);
|
||||
});
|
||||
|
||||
test("the starred clamp leaves the limit alone; releases are already forced off", () => {
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig(
|
||||
{ mirrorReleases: true, releaseLimit: 7 },
|
||||
{ starredCodeOnly: true }
|
||||
),
|
||||
repository: makeRepo({
|
||||
isStarred: true,
|
||||
mirrorOverrides: { releaseLimit: 3 },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resolved.mirrorReleases).toBe(false);
|
||||
expect(resolved.releaseLimit).toBe(3);
|
||||
});
|
||||
|
||||
test("helpers treat a usable limit as a pinned override", () => {
|
||||
expect(hasMirrorOverrides({ releaseLimit: 5 })).toBe(true);
|
||||
expect(hasMirrorOverrides({ releaseLimit: 0 })).toBe(false);
|
||||
expect(hasMirrorOverrides({ releaseLimit: null })).toBe(false);
|
||||
expect(listOverriddenKeys({ lfs: false, releaseLimit: 5 })).toEqual([
|
||||
"lfs",
|
||||
"releaseLimit",
|
||||
]);
|
||||
expect(normalizeMirrorOverrides({ releaseLimit: 5.7 })).toEqual({
|
||||
releaseLimit: 5,
|
||||
});
|
||||
expect(normalizeMirrorOverrides({ releaseLimit: 0 })).toBeNull();
|
||||
expect(normalizeMirrorOverrides({ lfs: false, releaseLimit: 0 })).toEqual({
|
||||
lfs: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizeReleaseLimit accepts positive finite numbers only", () => {
|
||||
expect(normalizeReleaseLimit(10)).toBe(10);
|
||||
expect(normalizeReleaseLimit(2.5)).toBe(2);
|
||||
expect(normalizeReleaseLimit(0)).toBeUndefined();
|
||||
expect(normalizeReleaseLimit(0.4)).toBeUndefined();
|
||||
expect(normalizeReleaseLimit(-1)).toBeUndefined();
|
||||
expect(normalizeReleaseLimit(NaN)).toBeUndefined();
|
||||
expect(normalizeReleaseLimit(Infinity)).toBeUndefined();
|
||||
expect(normalizeReleaseLimit("5")).toBeUndefined();
|
||||
expect(normalizeReleaseLimit(null)).toBeUndefined();
|
||||
expect(normalizeReleaseLimit(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("mirrorOptionsToFlags carries the global limit for the Inherit hint", () => {
|
||||
expect(
|
||||
mirrorOptionsToFlags({ mirrorReleases: true, releaseLimit: 42 }).releaseLimit
|
||||
).toBe(42);
|
||||
expect(mirrorOptionsToFlags(null).releaseLimit).toBe(DEFAULT_RELEASE_LIMIT);
|
||||
});
|
||||
|
||||
test("the global limit survives the DB -> API -> flags round trip", async () => {
|
||||
const { mapDbToUiConfig } = await import("./config-mapper");
|
||||
const apiShape = mapDbToUiConfig({
|
||||
githubConfig: { owner: "acme" },
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "t",
|
||||
defaultOwner: "acme",
|
||||
mirrorReleases: true,
|
||||
releaseLimit: 33,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(mirrorOptionsToFlags(apiShape.mirrorOptions).releaseLimit).toBe(33);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMirrorOverrideGating - release limit follows releases", () => {
|
||||
test("limit is disabled while releases resolve to off", () => {
|
||||
const gating = getMirrorOverrideGating({
|
||||
targetKind: "repository",
|
||||
effective: { mirrorReleases: false },
|
||||
});
|
||||
expect(gating.releaseLimit).toBe(MIRROR_GATING_REASONS.releasesOff);
|
||||
});
|
||||
|
||||
test("limit is editable while releases resolve to on", () => {
|
||||
const gating = getMirrorOverrideGating({
|
||||
targetKind: "repository",
|
||||
effective: { mirrorReleases: true },
|
||||
});
|
||||
expect(gating.releaseLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
test("the starred reason wins when that is what disabled releases", () => {
|
||||
const gating = getMirrorOverrideGating({
|
||||
targetKind: "repository",
|
||||
isStarred: true,
|
||||
starredCodeOnly: true,
|
||||
effective: { mirrorReleases: true },
|
||||
});
|
||||
expect(gating.releaseLimit).toBe(MIRROR_GATING_REASONS.starredCodeOnly);
|
||||
});
|
||||
|
||||
test("applies to organizations too", () => {
|
||||
const gating = getMirrorOverrideGating({
|
||||
targetKind: "organization",
|
||||
effective: { mirrorReleases: false },
|
||||
});
|
||||
expect(gating.releaseLimit).toBe(MIRROR_GATING_REASONS.releasesOff);
|
||||
});
|
||||
});
|
||||
@@ -20,8 +20,43 @@ export const MIRROR_OVERRIDE_KEYS = [
|
||||
|
||||
export type MirrorOverrideKey = (typeof MIRROR_OVERRIDE_KEYS)[number];
|
||||
|
||||
/** Fully resolved mirror options: every flag has a definite boolean value. */
|
||||
export type ResolvedMirrorOptions = Record<MirrorOverrideKey, boolean>;
|
||||
/**
|
||||
* The numeric mirror options that can be overridden per organization and per
|
||||
* repository. Kept apart from the boolean flags because every consumer of
|
||||
* MIRROR_OVERRIDE_KEYS (resolver, gating, dialog tri-states) assumes booleans.
|
||||
*/
|
||||
export const MIRROR_OVERRIDE_LIMIT_KEYS = ["releaseLimit"] as const;
|
||||
|
||||
export type MirrorOverrideLimitKey = (typeof MIRROR_OVERRIDE_LIMIT_KEYS)[number];
|
||||
|
||||
/** Matches `giteaConfigSchema.releaseLimit`'s default. */
|
||||
export const DEFAULT_RELEASE_LIMIT = 10;
|
||||
|
||||
/**
|
||||
* Fully resolved mirror options: every flag has a definite boolean value and
|
||||
* every limit a definite positive integer.
|
||||
*/
|
||||
export type ResolvedMirrorOptions = Record<MirrorOverrideKey, boolean> &
|
||||
Record<MirrorOverrideLimitKey, number>;
|
||||
|
||||
/**
|
||||
* The values the tiers above an override resolve to right now. Used by the
|
||||
* dialog to label what "Inherit" means for each row.
|
||||
*/
|
||||
export type InheritedMirrorOptions = Partial<Record<MirrorOverrideKey, boolean>> &
|
||||
Partial<Record<MirrorOverrideLimitKey, number>>;
|
||||
|
||||
/**
|
||||
* Sanitize a release limit from any tier into a positive integer, or
|
||||
* `undefined` when the value is absent or unusable (so the next tier out is
|
||||
* consulted). Floors fractional input rather than rejecting it, matching what
|
||||
* `mirrorGitHubReleasesToGitea` has always done with the global value.
|
||||
*/
|
||||
export function normalizeReleaseLimit(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||||
const limit = Math.floor(value);
|
||||
return limit >= 1 ? limit : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags the starred-code-only clamp forces off.
|
||||
@@ -91,6 +126,7 @@ export function mirrorOptionsToFlags(
|
||||
| {
|
||||
mirrorLFS?: boolean;
|
||||
mirrorReleases?: boolean;
|
||||
releaseLimit?: number;
|
||||
mirrorMetadata?: boolean;
|
||||
metadataComponents?: {
|
||||
issues?: boolean;
|
||||
@@ -102,7 +138,7 @@ export function mirrorOptionsToFlags(
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
): Partial<Record<MirrorOverrideKey, boolean>> {
|
||||
): InheritedMirrorOptions {
|
||||
const components = mirrorOptions?.metadataComponents;
|
||||
|
||||
// Straight through: each field is the raw stored value the runtime reads.
|
||||
@@ -114,6 +150,8 @@ export function mirrorOptionsToFlags(
|
||||
mirrorPullRequests: !!components?.pullRequests,
|
||||
mirrorLabels: !!components?.labels,
|
||||
mirrorMilestones: !!components?.milestones,
|
||||
releaseLimit:
|
||||
normalizeReleaseLimit(mirrorOptions?.releaseLimit) ?? DEFAULT_RELEASE_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,10 +160,13 @@ export const MIRROR_GATING_REASONS = {
|
||||
starredCodeOnly:
|
||||
"Starred repos mirror code only (Advanced Options > starred code only)",
|
||||
labelsFollowIssues: "Issues mirroring already syncs labels",
|
||||
releasesOff: "Releases are not being mirrored",
|
||||
} as const;
|
||||
|
||||
/** key -> reason it cannot take effect. Absent key means editable. */
|
||||
export type MirrorOverrideGating = Partial<Record<MirrorOverrideKey, string>>;
|
||||
export type MirrorOverrideGating = Partial<
|
||||
Record<MirrorOverrideKey | MirrorOverrideLimitKey, string>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Decide which toggles cannot take effect, and why.
|
||||
@@ -139,9 +180,12 @@ export type MirrorOverrideGating = Partial<Record<MirrorOverrideKey, string>>;
|
||||
* - `shouldMirrorLabels` in gitea.ts / gitea-enhanced.ts is
|
||||
* `mirrorLabels && !mirrorIssues`, so labels cannot take effect while issues
|
||||
* are being mirrored (the issue path already reconciles labels)
|
||||
* - `releaseLimit` is only read inside the release mirror, so it cannot take
|
||||
* effect while `mirrorReleases` resolves to off
|
||||
*
|
||||
* `effective` is the value each flag currently resolves to including the
|
||||
* in-progress edit, so the labels gate reacts live as issues is toggled.
|
||||
* in-progress edit, so the labels and release-limit gates react live as
|
||||
* issues / releases are toggled.
|
||||
*/
|
||||
export function getMirrorOverrideGating({
|
||||
targetKind,
|
||||
@@ -152,7 +196,7 @@ export function getMirrorOverrideGating({
|
||||
targetKind: "repository" | "organization";
|
||||
isStarred?: boolean;
|
||||
starredCodeOnly?: boolean;
|
||||
effective: Partial<Record<MirrorOverrideKey, boolean>>;
|
||||
effective: InheritedMirrorOptions;
|
||||
}): MirrorOverrideGating {
|
||||
const gating: MirrorOverrideGating = {};
|
||||
|
||||
@@ -172,10 +216,22 @@ export function getMirrorOverrideGating({
|
||||
gating.mirrorLabels = MIRROR_GATING_REASONS.labelsFollowIssues;
|
||||
}
|
||||
|
||||
// The release limit rides along with releases: inherit the starred reason
|
||||
// when that is what disabled releases, otherwise report releases being off.
|
||||
// Only an explicit `false` counts; an unknown value leaves the field open.
|
||||
if (gating.mirrorReleases) {
|
||||
gating.releaseLimit = gating.mirrorReleases;
|
||||
} else if (effective.mirrorReleases === false) {
|
||||
gating.releaseLimit = MIRROR_GATING_REASONS.releasesOff;
|
||||
}
|
||||
|
||||
return gating;
|
||||
}
|
||||
|
||||
export const MIRROR_OVERRIDE_LABELS: Record<MirrorOverrideKey, string> = {
|
||||
export const MIRROR_OVERRIDE_LABELS: Record<
|
||||
MirrorOverrideKey | MirrorOverrideLimitKey,
|
||||
string
|
||||
> = {
|
||||
lfs: "Git LFS files",
|
||||
wiki: "Wiki",
|
||||
mirrorReleases: "Releases",
|
||||
@@ -184,6 +240,7 @@ export const MIRROR_OVERRIDE_LABELS: Record<MirrorOverrideKey, string> = {
|
||||
mirrorPullRequests: "Pull requests",
|
||||
mirrorLabels: "Labels",
|
||||
mirrorMilestones: "Milestones",
|
||||
releaseLimit: "Release limit",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -218,24 +275,30 @@ export function parseMirrorOverrides(
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/** True when the overrides object actually pins at least one flag. */
|
||||
/** True when the overrides object actually pins at least one field. */
|
||||
export function hasMirrorOverrides(value: unknown): boolean {
|
||||
const overrides = parseMirrorOverrides(value);
|
||||
if (!overrides) return false;
|
||||
return MIRROR_OVERRIDE_KEYS.some((key) => overrides[key] != null);
|
||||
return listOverriddenKeys(value).length > 0;
|
||||
}
|
||||
|
||||
/** The keys an overrides object pins, for badges and summaries. */
|
||||
export function listOverriddenKeys(value: unknown): MirrorOverrideKey[] {
|
||||
export function listOverriddenKeys(
|
||||
value: unknown
|
||||
): (MirrorOverrideKey | MirrorOverrideLimitKey)[] {
|
||||
const overrides = parseMirrorOverrides(value);
|
||||
if (!overrides) return [];
|
||||
return MIRROR_OVERRIDE_KEYS.filter((key) => overrides[key] != null);
|
||||
return [
|
||||
...MIRROR_OVERRIDE_KEYS.filter((key) => overrides[key] != null),
|
||||
...MIRROR_OVERRIDE_LIMIT_KEYS.filter(
|
||||
(key) => normalizeReleaseLimit(overrides[key]) !== undefined
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip flags that are null/undefined so only genuine pins are stored, and
|
||||
* collapse an empty result to null. Keeps "no overrides" as a single canonical
|
||||
* representation instead of `{}` vs `{lfs: null}` vs NULL.
|
||||
* Strip fields that are null/undefined (or, for limits, unusable) so only
|
||||
* genuine pins are stored, and collapse an empty result to null. Keeps "no
|
||||
* overrides" as a single canonical representation instead of `{}` vs
|
||||
* `{lfs: null}` vs NULL.
|
||||
*/
|
||||
export function normalizeMirrorOverrides(
|
||||
value: unknown
|
||||
@@ -248,6 +311,10 @@ export function normalizeMirrorOverrides(
|
||||
const flag = overrides[key];
|
||||
if (typeof flag === "boolean") cleaned[key] = flag;
|
||||
}
|
||||
for (const key of MIRROR_OVERRIDE_LIMIT_KEYS) {
|
||||
const limit = normalizeReleaseLimit(overrides[key]);
|
||||
if (limit !== undefined) cleaned[key] = limit;
|
||||
}
|
||||
|
||||
return Object.keys(cleaned).length > 0 ? cleaned : null;
|
||||
}
|
||||
@@ -255,8 +322,10 @@ export function normalizeMirrorOverrides(
|
||||
/**
|
||||
* Resolve the effective mirror options for one repository.
|
||||
*
|
||||
* Precedence is per flag, most specific tier wins:
|
||||
* repository override -> organization override -> global config -> false
|
||||
* Precedence is per field, most specific tier wins:
|
||||
* repository override -> organization override -> global config -> default
|
||||
* where the default is `false` for flags and DEFAULT_RELEASE_LIMIT for the
|
||||
* release limit.
|
||||
*
|
||||
* `starredCodeOnly` is applied last as a hard clamp. It is a "code only, no
|
||||
* metadata" switch for starred repos, so it forces every metadata flag off
|
||||
@@ -295,6 +364,15 @@ export function resolveMirrorOptions({
|
||||
}
|
||||
}
|
||||
|
||||
// Same precedence for the release limit. An unusable value at any tier
|
||||
// (0, negative, NaN) falls through to the next one instead of being clamped
|
||||
// up to 1, so a bad override cannot silently shrink a repo to one release.
|
||||
resolved.releaseLimit =
|
||||
normalizeReleaseLimit(repo?.releaseLimit) ??
|
||||
normalizeReleaseLimit(org?.releaseLimit) ??
|
||||
normalizeReleaseLimit(globalConfig?.releaseLimit) ??
|
||||
DEFAULT_RELEASE_LIMIT;
|
||||
|
||||
// Starred repos with starredCodeOnly mirror code and nothing else. This
|
||||
// clamp intentionally outranks explicit per-repo overrides: the setting
|
||||
// exists to stop starred repos from dragging in metadata wholesale.
|
||||
|
||||
Reference in new issue
Block a user