Compare commits

...
14 Commits
Author SHA1 Message Date
Arunavo Ray b1d2ea4d6d chore: bump version to 3.29.0
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 10:07:51 +05:30
Arunavo Ray e7270a6ca3 feat(ui): rebuild the activity rows and add current-state stats
A row spent three stacked lines on a status, a repo name and a Show
Details button, leaving most of its width empty. It now follows the
dashboard's Recent Activity shape - status circle, label, time - and uses
the width for the repository or organization and the message, with the
whole row toggling the details pane. Rows went from about 170px to 69px,
so ten fit where six did.

The status to icon and colour mapping was a chain of ternaries written
out twice, once for mobile and once for desktop; it is now one table,
shared with the new stat chips.

Those chips fill the empty half of the filter row and report the current
state of each repository or organization rather than counting events, so
a repo that failed and then synced counts once, as synced. Each one
filters the log to that status.

Two virtualizer bugs surfaced while measuring the new rows:

- measureElement reads the row index from a data-index attribute that was
  never set, so every measurement was dropped and rows only ever used
  estimateSize. That is why the old estimates were hand-tuned to the
  markup.
- Expanding a row called virtualizer.measure(), which discards every
  measurement rather than re-measuring the row that changed.

Search filtering also ran twice, once in ActivityLog and again in
ActivityList over the list it had already filtered. It happens once now,
which is also what lets the toolbar show a count that matches the list.

Also drops the status dot from the dashboard repository rows, which
repeated the status pill beside it without its label.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 10:07:47 +05:30
Arunavo Ray 4ac2c4323c fix(ui): hand over to the compact toolbar at lg, and let lists size themselves
All three list pages switched from the compact toolbar to the full one at
sm (640px), so a tablet in portrait got the desktop row with six controls
and the search box squeezed down to its icon. The compact layout already
carries every filter in its drawer, so the handover moves to lg (1024px)
and tablets get a usable search box.

Lists also sized themselves off the viewport with hard-coded pixel
offsets - max-h-[calc(100dvh-231px)] and -276px. Those went stale the
moment a toolbar changed height, which is what put a second scrollbar on
the page next to the table's own. Both now flex into the space the
toolbar leaves, so the visible row count follows the window instead of a
fixed guess: the repository table shows 13 rows at 800px and 23 at
1400px, where it used to stop at the same count either way.

Also drops the bottom status bar from both lists. Its count moves up into
the empty left half of the filter row, and its live indicator was saying
what the header's LIVE button already says.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 10:07:35 +05:30
Arunavo Ray cadeb64dce fix(ui): give the app a single scroll region
main was min-h-screen, so it could grow past the viewport, while the
content section sized itself with h-[calc(100dvh-4.55rem)] - a guess at
the header's height. Whenever the header came out taller than that
guess, the body scrolled behind the section's own scroll and every page
showed two scrollbars.

The shell is now pinned to the viewport and the section takes whatever
the header actually leaves, so the magic offset is gone and the height
is correct whatever the header does.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 10:07:25 +05:30
Arunavo Ray 7fcce46821 feat(ui): accept a GitHub URL in the add repository and organization dialogs
Both dialogs asked for the name and owner as separate fields, so adding
something you were looking at on GitHub meant reading the URL and
retyping it in pieces.

Each dialog now has a GitHub URL field above an OR divider; filling it
populates the fields below. Pasting a URL into the name field splits it
as well, rather than dropping the whole URL into one box.

Parsing handles what people actually have on the clipboard: browser
URLs, clone URLs, SSH remotes, the owner/repo shorthand, a bare host,
and deep links into a repo. It drops the .git suffix, tolerates trailing
slashes, query strings and angle-bracket wrapping, and rejects reserved
GitHub paths like /settings so they cannot be read as an account name.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 09:47:13 +05:30
Arunavo Ray 64e3b18e1e fix(ui): rework the repositories filter bar
The toolbar carried six controls plus Mirror All and overflowed on
narrower desktop windows, and the owner and organization pickers did not
match the dropdowns beside them.

- Moves the status, mirror options and sort dropdowns down to the row
  that carries "Showing N of M" and Clear filters, right-aligned. They
  live in RepositoryTable now because that is where the filtered count
  is computed, and the row renders in both the loading and loaded states
  so the controls do not appear only once data arrives.
- Makes the owner and organization triggers match the Select triggers.
  They kept a double-chevron icon and the outline Button styling; the
  Select trigger classes are now exported from ui/select so both use one
  definition instead of a copied class string. They stay comboboxes
  rather than Selects because those lists grow with the repository count
  and need the search box.
- Widens both to 200px (w-50), which fits longer owner names.
- Hides the moved dropdowns below sm. The mobile filter sheet already
  covers all five filters, so on mobile they were duplicates sitting
  loose on the page.

Also fixes hasAnyFilter, which used val?.toString(), yielding undefined
for an unset filter. undefined !== "" is true, so an unfiltered view
reported "Showing 697 of 697 repositories / Clear filters" and a
"Filters applied" footer with nothing filtered.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 09:47:05 +05:30
Arunavo Ray b3e8c69566 fix(ui): make the dashboard Repositories and Recent Activity cards match
The two cards sat at whatever height their own content needed, so they
ended at different points whenever the lists were different lengths.
That is the normal state: repositories and mirror jobs come from
independent tables, and activity accumulates on every sync while the
repository list only grows when a repo is added.

- Drops items-start from the row so the columns stretch to the row
  height, in both the skeleton and the loaded state so the layout does
  not shift on load.
- Adds h-full to both cards. Without it the column stretches but the
  card inside still hugs its content, so removing items-start alone
  changes nothing on screen.
- Aligns the activity row padding with the repository rows (py-3 to
  py-3.5) so the rows line up when the two lists do have equal counts.

Verified in the browser at 8 vs 8 and 8 vs 5 items: both cards measure
657px in each case.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 09:30:34 +05:30
Arunavo Ray f06f6695bb chore: bump version to 3.28.2
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 09:08:09 +05:30
Arunavo Ray a000073a68 fix(ui): rebuild the Identity Providers card to match Sign-in Methods
The card used shadcn Card/CardHeader while its neighbour used the plain
settings markup, so the two headers were different heights and their
dividers did not line up. Rebuilt it on the same markup: icon, title,
divider, body, footer.

- Drops the header description, which wrapped under the Add provider
  button and collided with it.
- Moves Add provider into the header row. The header uses py-3 rather
  than py-4 because the h-8 button makes the row taller than a text-only
  one; both headers now measure 56px.
- Removes mr-2 from the Plus icons. The button already applies its own
  gap, so mr-2 stacked an extra 8px and pushed the label off-centre.
- Gives the provider detail labels weight and a fixed 96px column.
  Everything was one muted weight with no hierarchy, and "Organization:"
  overflowed the old 80px column so its value broke the alignment.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 09:08:05 +05:30
Arunavo Ray 2355284d27 chore: bump version to 3.28.1
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 08:57:39 +05:30
Arunavo Ray 6f3727c47e fix(ui): tighten settings card labels so they fit on one line
Database Maintenance footer said "Last cleanup"/"Next cleanup", which
wrapped onto two lines next to a full timestamp. The card is already
titled Database Maintenance, so "Last run"/"Next run" says the same
thing and fits.

Same for the Smart backup tile: "Snapshot only on history rewrites"
wrapped, and "history" is redundant since a force-push is the only
rewrite the sync sees.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 08:57:36 +05:30
Arunavo Ray 1bb096aa93 chore: bump version to 3.28.0 2026-08-24 09:26:07 +05:30
ARUNAVO RAY feb13ff58a feat(mirror): per-repo release limit, description sync on every sync, tidier mobile cards (#370)
Follow-up to #362 and #365 for the remaining points raised in #361.

- Add `releaseLimit` to the per-repository and per-organization mirror
  overrides (repo > org > global > 10), surface it in the Mirror Options
  dialog with gating and validation, and pass the resolved value into the
  release mirror. Tags need no equivalent: Gitea's git mirror fetches every
  ref and they cost nothing beyond the commits already in the clone.
- Reconcile the Gitea description and topics with GitHub on every sync, not
  only at migration time. Read both live in one call, write the refreshed
  description back to the local row, and skip writes that would not change
  anything. Mirrors created before #224 or whose upstream description
  changed since now pick it up on the next sync.
- Rework the mobile repository card: name, Custom badge and menu on one
  vertically centred row, secondary badges on their own row below, status
  and last sync stacked on the right.
2026-08-24 09:10:45 +05:30
ARUNAVO RAY 41592cb8fd fix(ci): skip Docker Scout on fork PRs and add a Trivy scan for every PR (#369)
Fork PRs (and Dependabot PRs) never receive repository secrets, so the
Docker Hub login ran with empty credentials and Docker Scout then died
with "user githubactions not entitled to use Docker Scout". Every
external contributor got a red docker check they could not do anything
about (#368, #356, #357, #359).

- Gate the Docker Hub login and both Scout PR steps on the PR coming
  from this repo, the same guard the login/push/comment steps already use.
- Add a Trivy scan of the locally loaded image on every PR. It needs no
  credentials, so forks get real vulnerability feedback. Output goes to
  the job summary because GITHUB_TOKEN is read-only on fork PRs.
- Only upload the Scout SARIF when a file exists, so fork PRs stop
  logging "Path does not exist: scout-results.sarif".
- Update the workflows README, which still described a docker-scan.yml
  that no longer exists.

Not using pull_request_target on purpose: it would run untrusted PR code
with the Docker Hub token available.
2026-08-24 07:52:41 +05:30
30 changed files with 2680 additions and 1242 deletions

No files matched your search

+10 -17
View File
@@ -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.
+49 -7
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.27.2",
"version": "3.29.0",
"engines": {
"bun": ">=1.2.9"
},
+224 -237
View File
@@ -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>
);
}
+167 -77
View File
@@ -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>
))}
+2 -2
View File
@@ -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"
/>
+1 -1
View File
@@ -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.",
},
+144 -42
View File
@@ -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);
}
+373 -378
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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>
+1 -9
View File
@@ -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>
+3 -3
View File
@@ -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"
/>
+15 -97
View File
@@ -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."
/>
);
}
+256 -182
View File
@@ -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
+5 -4
View File
@@ -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
View File
@@ -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>;
+78
View File
@@ -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,
}
);
+33
View File
@@ -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;
+335
View File
@@ -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
View File
@@ -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<
+101
View File
@@ -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);
});
});
+138
View File
@@ -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("/")
);
}
+205 -2
View File
@@ -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);
});
});
+96 -18
View File
@@ -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.