Compare commits

...
37 Commits
Author SHA1 Message Date
Arunavo Ray d4db47c76e chore: bump version to 3.26.2 2026-08-07 17:03:41 +05:30
Arunavo Ray 91eb195494 fix(ui): smooth marquee scrolling and widen its hover target
Animating text-indent forced a relayout every frame, which made the
marquee visibly choppy. The scroll is now a compositor-driven transform.
The content stays plain inline text while at rest so the native ellipsis
still renders, and swaps to an inline-block only for the duration of the
animation.

The hover target is now the whole repository cell instead of just the
text, via a shared MarqueeTrigger wrapper. Name and path scroll together
at the same speed, so the motion reads as one block.
2026-08-07 13:38:47 +05:30
Arunavo Ray e0c39b697f chore: bump version to 3.26.1 2026-08-07 13:23:51 +05:30
Arunavo Ray 03fce1e299 fix(ui): keep repo name and path on one line with hover marquee
Long repository names and owner/repo paths in the repositories table
wrapped to multiple lines, breaking row alignment. Both now truncate
with an ellipsis and scroll horizontally on hover to reveal the hidden
part.

The new MarqueeText component animates text-indent instead of a
transform because ellipsis rendering only works on inline content, and
a transform would require an inline-block wrapper.
2026-08-07 13:23:48 +05:30
Arunavo Ray 7fc53cfad8 Revert "fix(security): upgrade better-auth family to 1.7.0-rc.4"
This reverts commit 2f6af22e25.
2026-08-06 07:19:13 +05:30
Arunavo Ray b33e7d5596 fix(security): patch image CVEs and unbreak Scout SARIF uploads
- Build git-lfs with Go 1.25.12 (stdlib CVEs fixed in 1.25.10) and pull
  golang.org/x/net past the fix for CVE-2026-39821; x/net 0.54.0 was
  flagged critical in the image scan.
- Cap SARIF relatedLocations at 100 per result before upload. GitHub
  rejects SARIF with >1000 related locations per result, and Docker
  Scout exceeds that for common OS packages, so every upload since May
  had failed silently (continue-on-error) and the Security tab was
  frozen on a stale scan. With uploads flowing again, already-fixed
  alerts (samlify, libgnutls via the existing apt-get upgrade) close on
  the next scan.
2026-08-06 07:00:03 +05:30
Arunavo Ray 2f6af22e25 fix(security): upgrade better-auth family to 1.7.0-rc.4
Fixes the @better-auth/oauth-provider advisory (unbound resource
indicators could yield access tokens for unauthorized audiences,
Dependabot #55). No patched 1.6.x exists; 1.7.0-rc.4 is the first
patched line.

The 1.7 oauth-provider expects a wider schema: new nullable columns on
oauth_clients / oauth_access_tokens / oauth_refresh_tokens /
oauth_consents (back-channel logout, DPoP, resource indicators, refresh
token rotation) and three new tables (oauth_resources,
oauth_client_resources, oauth_client_assertions). Migration 0014 is
purely additive; validate-migrations gains the matching upgrade fixture.
2026-08-06 07:00:03 +05:30
Arunavo Ray 185946984b chore: bump version to 3.26.0 2026-08-06 06:43:47 +05:30
Arunavo Ray bcc9cf0120 feat(github): cap ETag cache by size and scope tokenless clients
Follow-up to #356. The conditional-request store was bounded only by
entry count; a single 100-PR page can run to a few hundred KB, so 5000
entries could grow to gigabytes. The store now also tracks approximate
body bytes (64MB budget by default) and evicts oldest-first until both
caps hold. A body larger than the whole budget is not cached at all.

Clients created with only a token (the metadata mirroring path) all
shared the "default" cache scope across users. They now fall back to a
SHA-256 hash of the token, so distinct tokens never share entries and
the raw token never appears in cache keys.
2026-08-06 06:43:40 +05:30
Josh FreeandCopilot 2b455354f9 feat(github): reuse ETags across syncs via conditional requests (#356)
* feat(github): reuse ETags across syncs via conditional requests

The mirror re-lists every repository's pull requests on each scheduled
sync with `state: "all"`, and the Octokit client was created with the
throttling plugin but no conditional-request/ETag layer. Every sync
therefore re-downloaded unchanged data as full 200s and spent full
rate-limit budget.

Add an ETag cache wired into `createGitHubClient`: for each GET it
replays the previously stored `If-None-Match`, so GitHub returns
`304 Not Modified` (which does not count against the token's primary
rate limit) when nothing changed, and the cached body is reused. The
store is process-lifetime and scoped per user so ETags survive the
per-sync client re-creation without leaking data across tokens. Non-GET
requests are untouched.

Refs #355

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79

* test(github): cover conditional-request ETag cache

Add unit tests driving a real Octokit instance with a stubbed fetch:
the second GET replays `If-None-Match`, a `304` is transparently served
from cache as a 200 with the same body, responses without an ETag are
re-fetched, non-GET requests are never made conditional, and cache
entries are isolated by scope.

Refs #355

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79

* fix: key conditional-request cache by expanded URL

The request hook built the cache key from requestOptions.url, which is
still the route template (/repos/{owner}/{repo}/pulls) at hook time.
Every repo therefore shared one entry per user + endpoint, so with more
than one repo per user the stored ETag never matched and the 304 path
stopped firing. Expand the route via octokit.request.endpoint.parse
before building the key, and add a two-repo regression test that fails
on the shared-key behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
2026-08-06 06:36:31 +05:30
Arunavo Ray 428c97441c chore: bump version to 3.25.0 2026-08-03 11:59:44 +05:30
Arunavo Ray edf9c14be0 fix: honor DATABASE_URL for the SQLite database location
The variable was defined and documented but never read; the database
path was hardcoded to <cwd>/data/gitea-mirror.db. It now accepts
sqlite://, the legacy file: scheme, or a plain path, with relative
paths resolving against the working directory and the parent directory
created on demand. Defaults are unchanged, including the compose files
that pass the historical default value through. Docs updated to match.
2026-08-03 11:59:44 +05:30
ARUNAVO RAY 5c49191d61 Move canonical docs to the website (#354)
* docs: move canonical documentation to the website

The website now hosts the full documentation at /docs with a proper docs
layout: sidebar navigation, on-page table of contents, mobile nav, theme
support, canonical and OG meta, and overflow-safe code blocks and tables.
Ten pages, all rewritten from the current code rather than copied from
the old in-app docs: quickstart, deployment (Docker, Helm, Nix, LXC,
bare metal), configuration, environment variable reference,
notifications (all four providers including webhook payload signing),
authentication (including header auth), force-push protection,
architecture, advanced, and custom CA certificates.

This fixes every inaccuracy found in the docs audit: the wrong
raylabs/gitea-mirror image name, JWT_SECRET presented as the live auth
secret instead of BETTER_AUTH_SECRET, the missing auth env vars, both
wrong DATABASE_URL defaults, the contradicting starred-org default, and
health endpoint fields the API deliberately does not return.

The in-app /docs pages are retired: a stub redirects old bookmarks to
the website, and the sidebar and 404 links point there directly. The
markdown files under docs/ stay as the versioned offline reference;
NOTIFICATIONS.md now covers Gotify and Webhook. README links the docs
site, mentions notifications, and drops stale version markers. The app
viewport meta gains initial-scale=1.

* docs: mark new-repo notification as unimplemented, bump helm appVersion to 3.24.0
2026-08-03 11:56:51 +05:30
Arunavo Ray 63a4c9359a chore: bump version to 3.24.0 2026-08-03 11:29:01 +05:30
ARUNAVO RAY 882e147504 Redesign dashboard and configuration screens (#353)
* feat(ui): redesign dashboard and configuration screens

New settings design language built from the design/giteamirror.pen file:
cards with icon headers and status footers, header-level enable switches,
toggle switches instead of checkboxes, uppercase section titles, selection
tiles with icon chips and a check on the active option, segmented controls,
and an indigo accent. Implemented via shared primitives in
src/components/config/settings-ui.tsx and applied across:

- Automation: header switches, schedule card, one-line auto-mirror copy
  with info tooltip, full-width Repository Cleanup card with Skip/Archive/
  Delete tiles and dry run row
- Notifications: segmented provider picker (ntfy/Apprise/Gotify/Webhook),
  events card with per-event switches
- Connections: GitHub/Gitea connection cards with token creation guide and
  field helpers, Repository Selection and Mirror Content cards covering
  every mirror option, Organization Structure card with strategy tiles,
  destructive update protection tiles (BETA label removed)
- Authentication: sign-in methods status card, identity providers restyle
- Dashboard: flatter stat cards, icon panel headers, indigo view-all links

All existing state handling, autosave and API behavior is unchanged.
Light mode keeps working via theme tokens. README and website screenshots
regenerated, docs references to renamed cards updated.

* fix(ui): design polish pass from local review

- Recent Activity rows get status icon circles (check, sync, sparkles, alert)
- Connections tab restructured: connection cards share a stretched grid row
  so GitHub and Gitea stay equal height; Mirror Content moved to the right
  column; forms split into placeable cards via a part prop
- Token guide panel: link moved to header as icon, larger text; redundant
  card footers removed (scopes line, test-connection hint)
- Repository Selection gains a footer note; retention explanation moved
  below the selector
- Authentication tab matches the design: side-by-side cards, row dividers,
  disabled state-reflecting switches with info hints, footers; SSO dialog
  restyled (segmented protocol tabs, field labels, indigo primary)
- Import GitHub Data button is the indigo primary; disabled state is muted
- Time format menu redesigned (locale pill, live examples, live clock in
  the trigger); theme switcher moved to sidebar as icon segmented control,
  system preference now persists correctly; legacy ModeToggle removed
- Automation timezone pill no longer shows stored legacy UTC as a choice
- Config tab bar wraps 2x2 on narrow screens instead of overflowing
- README, website and PR screenshots regenerated
2026-08-03 11:27:45 +05:30
Arunavo Ray da75f10f26 chore: bump version to 3.23.0 2026-08-03 08:06:31 +05:30
Arunavo Ray e7758badbf feat: add generic webhook notification provider (#352)
Adds Webhook alongside ntfy, Apprise and Gotify: posts a JSON payload
(title, message, type, timestamp) to any URL, with an optional signing
secret that adds an X-Webhook-Signature header (HMAC-SHA256 of the body,
sha256=<hex>) so receivers can verify authenticity. Secret is encrypted
at rest like the other provider tokens. Settings UI section, provider
and service tests included. No database migration needed.
2026-08-03 08:06:26 +05:30
Arunavo Ray 69198c047a chore: bump version to 3.22.0 2026-08-01 10:08:47 +05:30
Arunavo Ray 4f55cb406c ci(nix): make the bun.nix drift check blocking
The check added in #350 only echoed a message (with backticks that bash
executed as command substitution, mangling it) and never failed the job,
so a stale bun.nix would still pass CI. Fail fast right after the
regenerate step instead, with a GitHub error annotation and the diffstat.
2026-08-01 10:08:31 +05:30
Jordan Dominion c7932faecb fix(nix): regenerate bun.nix from bun.lock (#350)
bun.nix (last regenerated in #298) drifted from bun.lock after the dependency updates in #348, leaving the Nix flake build broken: 254 packages missing and 195 stale entries. Regenerated with bun2nix; all 929 entries now match bun.lock exactly.

Also adds a CI step that detects drift after the regenerate step (made blocking in a follow-up commit).

Thanks @Cyberboss.
2026-08-01 10:07:51 +05:30
Arunavo Ray e40eaabf4c docs(www): fix inaccurate claims in use-case and comparison pages
- Remove references to nonexistent /api/export and /api/repos/:id/logs endpoints
- Correct storage model: mirrored repos and LFS live in Gitea, not the data/ volume
- Fix default sync interval (daily, not 1 hour) and startup log line
- Add write:organization to required Gitea token scopes
- Remove nonexistent metrics endpoint and per-repo interval claims from Helm page
- Fix rate-limit advice (limits are per account, not per IP/token)
- Refresh stale comparison content (outage dates, BackHub/Rewind acquisition)
- Add missing git clone step to comparison quick start
2026-07-29 08:26:08 +05:30
云与原 204922570d feat: add Gotify as a notification provider (#337)
Adds Gotify alongside ntfy and Apprise: new provider module posting to {url}/message with X-Gotify-Key auth, configurable default priority (errors always send at priority 8), token encrypted at rest like the other providers, settings UI section, and provider + service tests. No database migration needed.
2026-07-16 22:20:15 +05:30
Arunavo Ray 97d98b82c6 chore: bump version to 3.21.0 2026-07-16 21:52:01 +05:30
ARUNAVO RAY 8b81ffa975 chore(deps): security fixes and dependency updates across app and www (#348)
better-auth 1.6.23 (fixes GHSA high stored XSS via javascript: redirect_uri), esbuild >=0.28.1 override for www (GHSA low), Astro 7 / @astrojs/node 11 / @astrojs/react 6 / @astrojs/mdx 7 / lucide-react 1.x (inline GitHub icon replaces removed brand icon) and all in-range updates on both the app and the website. The remaining @better-auth/oauth-provider medium advisory is patched upstream only in 1.7-rc and will be picked up when 1.7 stable lands.
2026-07-16 21:51:41 +05:30
Arunavo Ray bb57b52de3 test: isolate bulk-mirror destination tests in a child process
bun's mock.module and the globalThis.fetch swap are process-wide; on CI
(bun 1.3.13) this file's mocks of @/lib/db and @/lib/gitea-enhanced leaked
into gitea-enhanced.test.ts and stuck-status-recovery tests, failing main.
The file now registers nothing in the shared test process and instead
re-runs itself via bun test in a child process where the mocks are contained.
2026-07-16 21:46:08 +05:30
Arunavo Ray b3aad9d80f test: make org ids order-independent in bulk-mirror destination tests
The previous call-order counter diverged between the mocked flow and the
assertions when bun re-instantiates mock factories (green on bun 1.3.6
locally, red on 1.3.13 in CI). Ids are now a pure function of the org name.
2026-07-16 21:35:56 +05:30
Arunavo Ray 3b8625634c docs: add time format toggle screenshot 2026-07-16 21:02:21 +05:30
ARUNAVO RAY bdbfa762af feat(ui): add 12h/24h time format option with locale-aware default (#342) (#346)
Timestamps now follow the browser locale by default (previously hardcoded en-US/12-hour), with a clock toggle in the header for Auto / 12-hour / 24-hour, persisted in localStorage.

Fixes #342
2026-07-16 20:57:48 +05:30
ARUNAVO RAY f922bcc618 fix: recover repositories stuck in syncing/mirroring after crashes (#339) (#347)
Adds stuck-status recovery: repositories (and orgs) stranded in an in-flight status by a crash/restart are reset to failed with an explanation, on container start and every scheduler tick, guarded by the existing 2h liveness window.

Fixes #339
2026-07-16 20:57:38 +05:30
ARUNAVO RAY c5b331c041 fix: stop config saves from resetting env-configured GITEA_MIRROR_INTERVAL (#338) (#345)
Config saves now preserve every field the settings form doesn't expose (mirror interval and other env-only options) instead of resetting them to defaults.

Fixes #338
2026-07-16 20:57:29 +05:30
Arunavo Ray 5c33a5547b test: cover bulk org mirror destination routing (#343) + clarify mixed-strategy log
- Add behavioral tests exercising mirrorGitHubOrgToGitea end-to-end down to
  the migrate HTTP payload: org-level override, per-repo override, mixed
  strategy uid, starred-repo mode, and preserve/single-org/flat-user
  no-override regression paths. All four bug-scenario tests fail on main
  and pass with PR #344 applied.
- Fix the top-level log that claimed 'flat-user strategy' when the mixed
  strategy falls into the same branch.
2026-07-16 20:55:15 +05:30
Yuzu 537aae952d fix: honor destination overrides in bulk org mirroring and crash recovery (#343) (#344)
Routes the bulk Mirror Organization path and crash recovery through the canonical destination resolver (getGiteaRepoOwnerAsync), so org-level and per-repo destination overrides are honored, the mixed strategy no longer sends org repos to the user's personal account (previously uid was dropped from the migrate payload and Gitea defaulted to the authenticated user), and starred repos follow starred-repo mode even when swept up in a bulk org mirror.

Fixes #343
2026-07-16 20:54:58 +05:30
Arunavo Ray 40efb9b83a chore(www): point site URLs to gitea-mirror.raylabs.io
The old giteamirror.com domain is being retired to avoid paying for
per-project domains. Repoint all canonical URLs, og:url, the homepage
siteUrl, robots.txt sitemap ref, and sitemap.xml loc from the old
gitea-mirror.com domain to the new gitea-mirror.raylabs.io site.
2026-07-06 00:40:27 +05:30
Arunavo Ray 06bfb49e0e chore: bump version to 3.20.4 2026-07-02 15:46:20 +05:30
ARUNAVO RAY b5e0c58708 fix: stop false-positive orphan archiving and heal sync 405s on archived-* renamed mirrors (#331) (#336)
Three related fixes for the "repos keep getting archived and then fail to
sync with HTTP 405" report:

1. Orphan cleanup no longer archives on bulk-list absence alone.
   Repos added via the "+" Add Repository dialog (foreign owner, not
   starred) can never appear in the authenticated bulk fetches, so every
   cleanup cycle deterministically flagged them as orphaned and archived
   them. identifyOrphanedRepositories() now runs a targeted per-repo
   confirmation (starred check or repos.get) and only treats a clean 404
   as gone; any other outcome fails safe.

2. The archived-* rename is persisted. archiveGiteaRepo() now returns
   the actual post-rename name and the cleanup service records it in
   mirroredLocation, so the DB no longer points at a name that only
   301-redirects.

3. Sync self-heals repos renamed in Gitea/Forgejo. Requests to a
   renamed repo get a 301; fetch follows it, downgrading POST to GET,
   which lands on the POST-only mirror-sync endpoint as a 405. The sync
   candidate loop now adopts the canonical owner/name from the GET
   response body before POSTing, tries an archived-{name} fallback for
   archived repos (guarded by an original_url source match), and keeps
   archived repos archived: no mirror-interval PATCH, status stays
   'archived' per the documented Manual Sync contract.

Also hardens mirrorGitHubReleasesToGitea to derive GitHub coordinates
from fullName so Gitea-side names can never leak into GitHub API calls.

Verified end-to-end on Forgejo 15.0.3 (rootless): pre-fix reproduces the
exact 405; post-fix the stale-name sync succeeds (GET stale -> 301, GET
canonical -> 200, POST canonical mirror-sync -> 200), archived repos keep
interval "0s" with no PATCH issued, and non-archived renamed repos heal
and get the configured interval applied.
2026-07-02 15:46:00 +05:30
Arunavo Ray 74606f0a5f chore: bump version to 3.20.3 2026-07-01 08:13:15 +05:30
ARUNAVO RAY 187ecc5d60 fix: correctly mirror Gitea release titles and issue/PR labels (#334 + sibling) (#335)
* fix(releases): send Gitea release title as `name`, not `title` (#334)

Gitea/Forgejo expose the release title through the JSON field `name`
(the API Go struct is `Title string `+"`"+`json:"name"`+"`"+`). The release
create and update payloads sent `title:` instead, which Gitea silently
ignores, so every mirrored release landed with a blank title.

Verified live against Gitea 1.24.7: a POST/PATCH with `title` yields
`name: ""`; the same call with `name` sets the title correctly. The
update path also self-heals previously-mirrored releases whose names were
left blank, since the existing-vs-expected name comparison already drives
a PATCH.

Adds gita-release-name.test.ts, which drives the real
mirrorGitHubReleasesToGitea create/update paths with a mocked fetch and
asserts the payload carries `name` (and never `title`).

* fix(issues): reconcile labels on issue/PR update via the labels sub-resource (#334 sibling)

Gitea/Forgejo's `EditIssueOption` has no `labels` field (only
`CreateIssueOption` does), so a `labels` key in a `PATCH .../issues/{index}`
body is silently dropped — the same silent-ignore class as the release
`title` vs `name` bug. The issue and PR-as-issue update paths sent `labels`
in the PATCH body, so label changes never propagated onto already-mirrored
issues (and a deadlock-orphaned issue recovered via PATCH never got its
labels).

Fix: add `reconcileGiteaIssueLabels`, which replaces the label set via
`PUT .../issues/{index}/labels` (idempotent — adds new, removes deleted).
Call it on the two issue update paths and the two PR-issue update paths,
and drop the dead `labels` key from those PATCH bodies. Labels on freshly
created issues still come from CreateIssueOption on the POST.

Verified live against Gitea 1.24.7 (PATCH ignores labels; PUT applies them)
and end-to-end (a drifted mirrored issue reconciled from no-labels to its
GitHub label set). Adds gitea-issue-labels.test.ts driving the real
mirrorGitRepoIssuesToGitea update path; the test carries a self-contained
http-client mock so it is immune to another suite's global module mock.

* test: make #334 regression tests deterministic via pure payload builders

The prior tests drove the real mirror functions with a global `fetch` mock.
That is order/version-fragile: another suite installs a process-global
`mock.module("@/lib/http-client")`, and bun 1.3.13 (CI) runs test files
concurrently, so `globalThis.fetch` races across files and
`isRepoPresentInGitea` (raw fetch) intermittently sees the wrong mock —
green locally on bun 1.3.6, red in CI.

Extract the payload construction into pure, exported builders and assert on
those instead (the repo's existing `classify*` pattern): buildGiteaReleasePayload
(create+update send `name`, never `title`), buildGiteaIssueEditPayload (edit
body never carries `labels`), buildGiteaIssueLabelsPayload (labels sub-resource
body). Behavior is unchanged — the builders return the exact same objects the
call sites built inline — and the fixes remain verified live on Gitea 1.24.7.
2026-07-01 08:12:36 +05:30
120 changed files with 19821 additions and 7492 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 45 KiB

+15
View File
@@ -251,6 +251,21 @@ jobs:
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
# 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
# ("rejecting SARIF, as there are more related locations per result than
# allowed"). Cap them so uploads flow and stale alerts can auto-close.
- name: Sanitize Scout SARIF (cap relatedLocations)
if: always()
continue-on-error: true
run: |
if [ -f scout-results.sarif ]; then
jq '(.runs[]?.results[]?) |= (if .relatedLocations then .relatedLocations |= .[:100] else . end)' \
scout-results.sarif > scout-results.sanitized.sarif
mv scout-results.sanitized.sarif scout-results.sarif
fi
# Upload security scan results to GitHub Security tab
- name: Upload Docker Scout scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
+8
View File
@@ -46,6 +46,14 @@ jobs:
- name: Regenerate bun.nix from bun.lock
run: nix run --accept-flake-config github:nix-community/bun2nix -- -o bun.nix
- name: Fail if committed bun.nix is out of date
run: |
if ! git diff --quiet -- bun.nix; then
echo '::error file=bun.nix::bun.nix is out of date! Run `nix run github:nix-community/bun2nix -- -o bun.nix` (requires Nix) and commit the result.'
git diff --stat -- bun.nix
exit 1
fi
- name: Check flake
run: nix flake check --accept-flake-config
+2 -1
View File
@@ -32,7 +32,7 @@ FROM debian:trixie-slim AS git-lfs-builder
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
wget ca-certificates git make \
&& rm -rf /var/lib/apt/lists/*
ARG GO_VERSION=1.25.9
ARG GO_VERSION=1.25.12
ARG GIT_LFS_VERSION=3.7.1
RUN ARCH="$(dpkg --print-architecture)" \
&& wget -qO /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" \
@@ -44,6 +44,7 @@ ENV GOTOOLCHAIN=local
RUN git clone --branch "v${GIT_LFS_VERSION}" --depth 1 https://github.com/git-lfs/git-lfs.git /tmp/git-lfs \
&& cd /tmp/git-lfs \
&& go get golang.org/x/crypto@latest \
&& go get golang.org/x/net@latest \
&& go mod tidy \
&& make \
&& install -m 755 /tmp/git-lfs/bin/git-lfs /usr/local/bin/git-lfs
+10 -7
View File
@@ -21,6 +21,8 @@ docker compose -f docker-compose.alt.yml up -d
First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the web interface!
📖 **[Full documentation](https://gitea-mirror.raylabs.io/docs/)** - quickstart, deployment, configuration, notifications, SSO and more. Offline copies live in [`docs/`](docs/).
<p align="center">
<img src=".github/assets/dashboard.png" alt="Dashboard" width="600" />
<img src=".github/assets/dashboard_mobile.png" alt="Dashboard Mobile" width="200" />
@@ -33,15 +35,16 @@ First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the
- 🏢 Mirror entire organizations with flexible strategies
- 🎯 Custom destination control for repos and organizations
- 📦 **Git LFS support** - Mirror large files with Git LFS
- 📝 **Metadata mirroring** - Issues, pull requests (as issues), labels, milestones, wiki
- 📝 **Metadata mirroring** - Issues, pull requests (as issues), labels, milestones, releases, wiki
- 🚫 **Repository ignore** - Mark specific repos to skip
- 🔐 Secure authentication with Better Auth (email/password, SSO, OIDC)
- 🔐 Secure authentication with Better Auth (email/password, SSO, OIDC, header auth)
- 🔔 **[Notifications](docs/NOTIFICATIONS.md)** - ntfy, Apprise, Gotify, or any webhook with signed payloads
- 📊 Real-time dashboard with activity logs
- ⏱️ Scheduled automatic mirroring with configurable intervals
- 🔄 **Auto-discovery** - Automatically import new GitHub repositories (v3.4.0+)
- 🧹 **Repository cleanup** - Auto-remove repos deleted from GitHub (v3.4.0+)
- 🎯 **Proper mirror intervals** - Respects configured sync intervals (v3.4.0+)
- 🛡️ **[Force-push protection](docs/FORCE_PUSH_PROTECTION.md)** - Smart detection with backup-on-demand or block-and-approve modes (Beta)
- 🔄 **Auto-discovery** - Automatically import new GitHub repositories
- 🧹 **Repository cleanup** - Auto-remove repos deleted from GitHub
- 🎯 **Proper mirror intervals** - Respects configured sync intervals
- 🛡️ **[Force-push protection](docs/FORCE_PUSH_PROTECTION.md)** - Smart detection with backup-on-demand or block-and-approve modes
- 🗑️ Automatic database cleanup with configurable retention
- 🐳 Dockerized with multi-arch support (AMD64/ARM64)
@@ -49,7 +52,7 @@ First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the
<div align="center">
<img src=".github/assets/repositories.png" alt="Repositories" width="600" />
<img src=".github/assets/repositories_mobile.png" alt="Rrepositories Mobile" width="200" />
<img src=".github/assets/repositories_mobile.png" alt="Repositories Mobile" width="200" />
</div>
<div align="center">
+463 -255
View File
File diff suppressed because it is too large. Load diff
+861 -625
View File
File diff suppressed because it is too large. Load diff
+7974 -500
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -38,7 +38,7 @@ If detection itself fails (GitHub rate limits, network errors, API outages), syn
## Backup Strategies
Configure via **Settings → GitHub Configuration → Destructive Update Protection**.
Configure via **Configuration → Connections → Destructive Update Protection**.
| Strategy | What It Does | Storage Cost | Best For |
|---|---|---|---|
+48 -1
View File
@@ -54,13 +54,60 @@ Gitea Mirror supports push notifications for mirror events. You can be alerted w
- Optionally set a **Tag** to only notify specific Apprise services
- Leave empty to notify all configured services
### 3. Gotify (Direct)
[Gotify](https://gotify.net/) is a simple self-hosted push notification server.
**Setup:**
1. In Gotify, create an application and copy its token
2. In Gitea Mirror, go to **Configuration > Notifications**
3. Enable notifications and select **Gotify**
4. Set the **Server URL** to your Gotify instance (e.g., `https://gotify.example.com`)
5. Paste the **Application token**
6. Pick a **Default priority** (0 to 10; error notifications are always sent at priority 8)
The token is encrypted at rest like all other provider credentials.
### 4. Webhook (Generic)
Sends a plain JSON POST to any URL you control. Works with n8n, Home Assistant, or any service that accepts a generic webhook.
**Setup:**
1. In Gitea Mirror, go to **Configuration > Notifications**
2. Enable notifications and select **Webhook**
3. Set the **Webhook URL** to your endpoint
4. Optionally set a **Signing secret**
**Payload:**
```json
{
"title": "Mirror Failed: my-repo",
"message": "Repository my-repo failed to mirror",
"type": "sync_error",
"timestamp": "2026-08-03T02:33:29.707Z"
}
```
**Signature verification:**
When a signing secret is set, every request includes an `X-Webhook-Signature` header containing `sha256=<hex>`, an HMAC-SHA256 digest of the raw request body. Verify it on the receiving end:
```js
const crypto = require("node:crypto");
const expected = "sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const valid = crypto.timingSafeEqual(
Buffer.from(signatureHeader), Buffer.from(expected));
```
The secret is encrypted at rest.
## Event Types
| Event | Default | Description |
|-------|---------|-------------|
| Sync errors | On | A mirror job failed |
| Sync success | Off | A mirror job completed successfully |
| New repo discovered | Off | A new GitHub repo was auto-imported during scheduled sync |
| New repo discovered | Off | Not yet implemented; the toggle is disabled in the UI |
## Testing
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: gitea-mirror
description: Kubernetes helm chart for gitea-mirror
type: application
version: 0.0.1
appVersion: 3.7.2
version: 0.0.2
appVersion: "3.24.0"
icon: https://github.com/RayLabsHQ/gitea-mirror/blob/main/.github/assets/logo.png
keywords:
- git
+41 -41
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.20.2",
"version": "3.26.2",
"engines": {
"bun": ">=1.2.9"
},
@@ -55,40 +55,40 @@
},
"dependencies": {
"@astrojs/check": "^0.9.9",
"@astrojs/mdx": "5.0.0",
"@astrojs/node": "10.1.4",
"@astrojs/react": "^5.0.7",
"@better-auth/oauth-provider": "1.6.11",
"@better-auth/sso": "1.6.11",
"@astrojs/mdx": "^7.0.3",
"@astrojs/node": "^11.0.2",
"@astrojs/react": "^6.0.1",
"@better-auth/oauth-provider": "1.6.23",
"@better-auth/sso": "1.6.23",
"@octokit/plugin-throttling": "^11.0.3",
"@octokit/rest": "^22.0.1",
"@radix-ui/react-accordion": "^1.2.13",
"@radix-ui/react-avatar": "^1.1.12",
"@radix-ui/react-checkbox": "^1.3.4",
"@radix-ui/react-collapsible": "^1.1.13",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-hover-card": "^1.1.16",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-popover": "^1.1.16",
"@radix-ui/react-progress": "^1.1.9",
"@radix-ui/react-radio-group": "^1.4.0",
"@radix-ui/react-scroll-area": "^1.2.11",
"@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.14",
"@radix-ui/react-tooltip": "^1.2.9",
"@tailwindcss/vite": "^4.3.1",
"@radix-ui/react-accordion": "^1.2.16",
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-checkbox": "^1.3.7",
"@radix-ui/react-collapsible": "^1.1.16",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-hover-card": "^1.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-popover": "^1.1.19",
"@radix-ui/react-progress": "^1.1.12",
"@radix-ui/react-radio-group": "^1.4.3",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-tooltip": "^1.2.12",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.2",
"@tanstack/react-virtual": "^3.14.6",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^6.4.6",
"astro": "^7.1.0",
"bcryptjs": "^3.0.3",
"better-auth": "1.6.11",
"better-auth": "1.6.23",
"buffer": "^6.0.3",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
@@ -96,37 +96,37 @@
"cmdk": "^1.1.1",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"fuse.js": "^7.4.2",
"fuse.js": "^7.5.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.577.0",
"nanoid": "^5.1.11",
"lucide-react": "^1.24.0",
"nanoid": "^6.0.0",
"next-themes": "^0.4.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-icons": "^5.6.0",
"react-icons": "^5.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"uuid": "^13.0.2",
"typescript": "^7.0.2",
"uuid": "^14.0.1",
"vaul": "^1.1.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/bcryptjs": "^3.0.0",
"@types/bun": "^1.3.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.9.3",
"@types/node": "^26.1.1",
"@types/uuid": "^11.0.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-react": "^6.0.3",
"drizzle-kit": "^0.31.10",
"jsdom": "^28.1.0",
"tsx": "^4.22.4",
"vitest": "^4.1.8"
"jsdom": "^29.1.1",
"tsx": "^4.23.1",
"vitest": "^4.1.10"
},
"packageManager": "bun@1.3.10"
}
+21 -1
View File
@@ -13,6 +13,7 @@
*/
import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from "../src/lib/recovery";
import { resetStuckMirrorStatuses } from "../src/lib/stuck-status-recovery";
// Parse command line arguments
const args = process.argv.slice(2);
@@ -41,10 +42,29 @@ async function runStartupRecovery() {
}, timeout);
});
// Reset repositories/organizations stuck in an in-flight status
// ("mirroring"/"syncing") from a previous run (issue #339). This must
// happen BEFORE the needsRecovery early-exit below: scheduler-driven
// syncs create no resilient job records, so a crash mid-scheduled-sync
// leaves stuck repo rows but NO interrupted jobs — the early exit would
// skip them forever. The app is not running while this script executes,
// so every in-flight row is an orphan by definition (this script's own
// process start is the cutoff). Never throws.
console.log('Checking for repositories stuck in an in-flight status...');
const stuckReset = await resetStuckMirrorStatuses();
if (stuckReset.repositories > 0 || stuckReset.organizations > 0) {
console.log(
`✅ Reset ${stuckReset.repositories} stuck repositor${stuckReset.repositories === 1 ? 'y' : 'ies'} ` +
`and ${stuckReset.organizations} stuck organization(s) to "failed" for retry.`
);
} else {
console.log('✅ No stuck repository/organization statuses found.');
}
// Check if recovery is needed first
console.log('Checking if recovery is needed...');
const needsRecovery = await hasJobsNeedingRecovery();
if (!needsRecovery) {
console.log('✅ No jobs need recovery. Startup can proceed.');
process.exit(0);
+1 -1
View File
@@ -60,7 +60,7 @@ export function NotFound() {
<span className="text-xs">Config</span>
</a>
<a
href={withBase("/docs")}
href="https://gitea-mirror.raylabs.io/docs/"
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
>
<BookOpen className="h-5 w-5 text-muted-foreground" />
+4
View File
@@ -6,6 +6,7 @@ import { Button } from '../ui/button';
import { RefreshCw, Check, X, Loader2, Import } from 'lucide-react';
import { Card } from '../ui/card';
import { formatDate, getStatusColor } from '@/lib/utils';
import { useTimeFormat } from '@/hooks/useTimeFormat';
import { Skeleton } from '../ui/skeleton';
import type { FilterParams } from '@/types/filter';
import {
@@ -32,6 +33,9 @@ export default function ActivityList({
filter,
setFilter,
}: ActivityListProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const [expandedItems, setExpandedItems] = useState<Set<string>>(
() => new Set(),
);
+296 -398
View File
@@ -1,7 +1,4 @@
import { useEffect, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -10,30 +7,37 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
Clock,
Database,
RefreshCw,
Calendar,
Activity,
Zap,
Info,
Archive,
ArchiveRestore,
CircleOff,
Globe,
History,
Info,
Trash2,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config";
import { formatDate } from "@/lib/utils";
import { useTimeFormat } from "@/hooks/useTimeFormat";
import {
buildClockCronExpression,
getNextCronOccurrence,
} from "@/lib/utils/schedule-utils";
import {
SettingsCard,
SectionTitle,
SwitchRow,
OptionTile,
StatusFooterItem,
CardDivider,
CardSection,
} from "./settings-ui";
interface AutomationSettingsProps {
scheduleConfig: ScheduleConfig;
@@ -81,6 +85,30 @@ function getCleanupFrequencyText(retentionSeconds: number): string {
return "weekly";
}
const orphanActions = [
{
value: "skip" as const,
label: "Skip",
description: "Leave the mirror untouched",
icon: CircleOff,
info: "Orphaned mirrors stay exactly as they are and keep their sync settings.",
},
{
value: "archive" as const,
label: "Archive",
description: "Kept read-only with an archived prefix",
icon: Archive,
info: "Renames the mirror with an archived- prefix and disables automatic syncs. Nothing is lost; use Manual Sync to refresh.",
},
{
value: "delete" as const,
label: "Delete",
description: "Remove the mirror from Gitea",
icon: Trash2,
info: "Permanently deletes the mirror repository from Gitea.",
},
];
export function AutomationSettings({
scheduleConfig,
cleanupConfig,
@@ -89,13 +117,21 @@ export function AutomationSettings({
isAutoSavingSchedule,
isAutoSavingCleanup,
}: AutomationSettingsProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const browserTimezone =
typeof Intl !== "undefined"
? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
: "UTC";
// Use saved timezone, but treat "UTC" as unset for users who never chose it
const effectiveTimezone = scheduleConfig.timezone || browserTimezone;
// Use saved timezone, but treat "UTC" as unset for users who never chose
// it: older versions stored UTC as a default without asking. Anyone truly
// in UTC gets the same result via their browser timezone.
const effectiveTimezone =
scheduleConfig.timezone && scheduleConfig.timezone !== "UTC"
? scheduleConfig.timezone
: browserTimezone;
const nextScheduledRun = useMemo(() => {
if (!scheduleConfig.enabled) return null;
@@ -119,429 +155,291 @@ export function AutomationSettings({
}
}, [cleanupConfig.enabled, cleanupConfig.retentionDays]);
const savingSpinner = (saving?: boolean) =>
saving ? (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
) : undefined;
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="text-lg font-semibold flex items-center gap-2">
<Zap className="h-5 w-5" />
Automation & Maintenance
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button className="ml-1 inline-flex items-center justify-center rounded-full w-4 h-4 bg-muted hover:bg-muted/80 transition-colors">
<Info className="h-3 w-3" />
<span className="sr-only">Background operations info</span>
</button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<div className="space-y-2">
<p className="font-medium">Background Operations</p>
<p className="text-xs">
These automated tasks run in the background to keep your mirrors up-to-date and maintain optimal database performance.
Choose intervals that match your workflow and repository update frequency.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Automatic Syncing Section */}
<div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-primary" />
Automatic Syncing
</h3>
{isAutoSavingSchedule && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
<div className="flex-1 flex flex-col gap-4">
<div className="flex items-start space-x-3">
<Checkbox
id="enable-auto-mirror"
checked={scheduleConfig.enabled}
className="mt-1.25"
onCheckedChange={(checked) =>
onScheduleChange({
...scheduleConfig,
enabled: !!checked,
timezone: checked ? browserTimezone : scheduleConfig.timezone,
startTime: scheduleConfig.startTime || "22:00",
clockFrequencyHours: scheduleConfig.clockFrequencyHours || 24,
scheduleMode: "clock",
})
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 items-stretch gap-6 md:grid-cols-2">
{/* Automatic Syncing */}
<SettingsCard
icon={RefreshCw}
title="Automatic Syncing"
enabled={scheduleConfig.enabled}
onEnabledChange={(checked) =>
onScheduleChange({
...scheduleConfig,
enabled: checked,
timezone: checked ? browserTimezone : scheduleConfig.timezone,
startTime: scheduleConfig.startTime || "22:00",
clockFrequencyHours: scheduleConfig.clockFrequencyHours || 24,
scheduleMode: "clock",
})
}
headerAction={savingSpinner(isAutoSavingSchedule)}
footer={
<>
<StatusFooterItem
icon={History}
label="Last sync"
value={
scheduleConfig.lastRun
? formatDate(scheduleConfig.lastRun)
: "Never"
}
/>
{scheduleConfig.enabled ? (
<StatusFooterItem
icon={Calendar}
label="Next sync"
value={
scheduleConfig.nextRun
? formatDate(scheduleConfig.nextRun)
: nextScheduledRun
? formatDate(nextScheduledRun)
: "Calculating..."
}
valueClassName="text-indigo-500"
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="enable-auto-mirror"
className="text-sm font-normal cursor-pointer"
>
Enable automatic repository syncing
</Label>
<p className="text-xs text-muted-foreground">
Periodically sync GitHub changes to Gitea
</p>
</div>
</div>
{scheduleConfig.enabled && (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Schedule
</p>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 px-2.5 py-0.5 text-[11px] text-muted-foreground">
) : (
<span className="text-xs text-muted-foreground/70">
Enable syncing to schedule updates
</span>
)}
</>
}
>
{scheduleConfig.enabled && (
<>
<CardSection>
<SectionTitle
action={
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground">
<Globe className="h-3 w-3" />
{effectiveTimezone}
</span>
}
>
Schedule
</SectionTitle>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label
htmlFor="clock-frequency"
className="text-xs font-medium text-muted-foreground"
>
Frequency
</Label>
<Select
value={String(scheduleConfig.clockFrequencyHours || 24)}
onValueChange={(value) =>
onScheduleChange({
...scheduleConfig,
scheduleMode: "clock",
clockFrequencyHours: parseInt(value, 10),
startTime: scheduleConfig.startTime || "22:00",
timezone: effectiveTimezone,
})
}
>
<SelectTrigger id="clock-frequency" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{clockFrequencies.map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label
htmlFor="clock-frequency"
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Frequency
</Label>
<Select
value={String(scheduleConfig.clockFrequencyHours || 24)}
onValueChange={(value) =>
<div className="space-y-1.5">
<Label
htmlFor="clock-start-time"
className="text-xs font-medium text-muted-foreground"
>
Start time
</Label>
<div className="relative">
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3">
<Clock className="size-4" />
</div>
<Input
id="clock-start-time"
type="time"
value={scheduleConfig.startTime || "22:00"}
onChange={(event) =>
onScheduleChange({
...scheduleConfig,
scheduleMode: "clock",
clockFrequencyHours: parseInt(value, 10),
startTime: scheduleConfig.startTime || "22:00",
startTime: event.target.value,
clockFrequencyHours:
scheduleConfig.clockFrequencyHours || 24,
timezone: effectiveTimezone,
})
}
>
<SelectTrigger id="clock-frequency" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{clockFrequencies.map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label
htmlFor="clock-start-time"
className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Start time
</Label>
<div className="relative">
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3">
<Clock className="size-4" />
</div>
<Input
id="clock-start-time"
type="time"
value={scheduleConfig.startTime || "22:00"}
onChange={(event) =>
onScheduleChange({
...scheduleConfig,
scheduleMode: "clock",
startTime: event.target.value,
clockFrequencyHours:
scheduleConfig.clockFrequencyHours || 24,
timezone: effectiveTimezone,
})
}
className="appearance-none pl-9 dark:bg-input/30 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
</div>
<div className="flex items-start space-x-3 pt-1">
<Checkbox
id="enable-auto-mirror-new"
checked={scheduleConfig.autoMirror ?? false}
className="mt-1.25"
onCheckedChange={(checked) =>
onScheduleChange({
...scheduleConfig,
autoMirror: !!checked,
})
}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="enable-auto-mirror-new"
className="text-sm font-normal cursor-pointer"
>
Auto-mirror new repositories
</Label>
<p className="text-xs text-muted-foreground">
Automatically mirror newly imported repositories on each scheduled sync. When off, new repos are imported for browsing but require a manual mirror click. (Starred repos have their own toggle in GitHub settings.)
</p>
className="appearance-none pl-9 dark:bg-input/30 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
</div>
)}
<div className="mt-auto flex items-center justify-between border-t border-border/50 pt-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
Last sync{" "}
<span className="font-medium">
{scheduleConfig.lastRun
? formatDate(scheduleConfig.lastRun)
: "Never"}
</span>
</span>
{scheduleConfig.enabled ? (
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
Next sync{" "}
<span className="font-medium text-primary">
{scheduleConfig.nextRun
? formatDate(scheduleConfig.nextRun)
: nextScheduledRun
? formatDate(nextScheduledRun)
: "Calculating..."}
</span>
</span>
) : (
<span>Enable syncing to schedule updates</span>
)}
</div>
</div>
</div>
{/* Database Cleanup Section */}
<div className="flex flex-col gap-4 p-4 border border-border rounded-lg bg-card/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
Database Maintenance
</h3>
{isAutoSavingCleanup && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
<div className="flex-1 flex flex-col gap-4">
<div className="flex items-start space-x-3">
<Checkbox
id="enable-auto-cleanup"
checked={cleanupConfig.enabled}
className="mt-1.25"
</CardSection>
<CardDivider />
<CardSection>
<SwitchRow
label="Auto-mirror new repositories"
description="Mirror repos discovered during sync automatically"
info="When off, newly discovered repos are imported for browsing and wait for a manual mirror click. Starred repos have their own toggle in GitHub settings."
checked={scheduleConfig.autoMirror ?? false}
onCheckedChange={(checked) =>
onCleanupChange({ ...cleanupConfig, enabled: !!checked })
onScheduleChange({ ...scheduleConfig, autoMirror: checked })
}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="enable-auto-cleanup"
className="text-sm font-normal cursor-pointer"
>
Enable automatic database cleanup
</Label>
<p className="text-xs text-muted-foreground">
Remove old activity logs to optimize storage
</p>
</div>
</div>
{cleanupConfig.enabled && (
<div className="space-y-5">
<div className="space-y-2">
<Label htmlFor="retention-period" className="text-sm flex items-center gap-2">
Data retention period
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs">
Activity logs and events older than this will be removed.
Cleanup frequency is automatically optimized based on your retention period.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<div className="flex items-center gap-3 mt-1.5">
<Select
value={cleanupConfig.retentionDays.toString()}
onValueChange={(value) =>
onCleanupChange({
...cleanupConfig,
retentionDays: parseInt(value, 10),
})
}
>
<SelectTrigger id="retention-period" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{retentionPeriods.map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Cleanup runs {getCleanupFrequencyText(cleanupConfig.retentionDays)}
</p>
</div>
</div>
</div>
)}
<div className="mt-auto space-y-2 pt-3 border-t border-border/50">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
Last cleanup
</span>
<span className="font-medium text-muted-foreground">
{cleanupConfig.lastRun
? formatDate(cleanupConfig.lastRun)
: "Never"}
</span>
</div>
{cleanupConfig.enabled ? (
cleanupConfig.nextRun && (
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
Next cleanup
</span>
<span className="font-medium">
{formatDate(cleanupConfig.nextRun)}
</span>
</div>
)
) : (
<div className="text-xs text-muted-foreground">
Enable automatic cleanup to optimize database storage
</div>
)}
</div>
</div>
</div>
{/* Repository Cleanup Section */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50 md:col-span-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<Archive className="h-4 w-4 text-primary" />
Repository Cleanup (orphaned mirrors)
</h3>
{isAutoSavingCleanup && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
</CardSection>
</>
)}
</div>
</SettingsCard>
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="cleanup-handle-orphans"
checked={Boolean(cleanupConfig.deleteIfNotInGitHub)}
className="mt-1.25"
onCheckedChange={(checked) =>
onCleanupChange({
...cleanupConfig,
deleteIfNotInGitHub: Boolean(checked),
})
}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="cleanup-handle-orphans"
className="text-sm font-normal cursor-pointer"
>
Handle orphaned repositories automatically
</Label>
<p className="text-xs text-muted-foreground">
Keep your Gitea backups when GitHub repos disappear. Archive is the safest option—it preserves data and disables automatic syncs.
</p>
</div>
</div>
{cleanupConfig.deleteIfNotInGitHub && (
<div className="space-y-3 ml-6">
<div className="space-y-1">
<Label htmlFor="cleanup-orphaned-action" className="text-sm font-medium">
Action for orphaned repositories
</Label>
{/* Database Maintenance */}
<SettingsCard
icon={Database}
title="Database Maintenance"
enabled={cleanupConfig.enabled}
onEnabledChange={(checked) =>
onCleanupChange({ ...cleanupConfig, enabled: checked })
}
headerAction={savingSpinner(isAutoSavingCleanup)}
className="md:h-full"
footer={
<>
<StatusFooterItem
icon={History}
label="Last cleanup"
value={
cleanupConfig.lastRun
? formatDate(cleanupConfig.lastRun)
: "Never"
}
/>
{cleanupConfig.enabled && cleanupConfig.nextRun ? (
<StatusFooterItem
icon={Calendar}
label="Next cleanup"
value={formatDate(cleanupConfig.nextRun)}
valueClassName="text-indigo-500"
/>
) : (
<span className="text-xs text-muted-foreground/70">
Old activity logs are removed automatically
</span>
)}
</>
}
>
{cleanupConfig.enabled && (
<CardSection>
<SectionTitle>Data retention</SectionTitle>
<div className="flex items-center gap-3">
<Select
value={cleanupConfig.orphanedRepoAction ?? "archive"}
value={cleanupConfig.retentionDays.toString()}
onValueChange={(value) =>
onCleanupChange({
...cleanupConfig,
orphanedRepoAction: value as DatabaseCleanupConfig["orphanedRepoAction"],
retentionDays: parseInt(value, 10),
})
}
>
<SelectTrigger id="cleanup-orphaned-action">
<SelectTrigger id="retention-period" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="archive">Archive (preserve data)</SelectItem>
<SelectItem value="skip">Skip (leave as-is)</SelectItem>
<SelectItem value="delete">Delete from Gitea</SelectItem>
{retentionPeriods.map((option) => (
<SelectItem
key={option.value}
value={option.value.toString()}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Archive renames mirror backups with an <code>archived-</code> prefix and disables automatic syncs—use Manual Sync when you want to refresh.
Cleanup runs {getCleanupFrequencyText(cleanupConfig.retentionDays)}
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label
htmlFor="cleanup-dry-run"
className="text-sm font-normal cursor-pointer"
>
Dry run (log only)
</Label>
<p className="text-xs text-muted-foreground max-w-xl">
When enabled, cleanup logs the planned action without modifying repositories.
</p>
</div>
<Switch
id="cleanup-dry-run"
checked={Boolean(cleanupConfig.dryRun)}
onCheckedChange={(checked) =>
onCleanupChange({
...cleanupConfig,
dryRun: Boolean(checked),
})
}
/>
</div>
</div>
<p className="max-w-md text-[13px] leading-relaxed text-muted-foreground">
Activity logs and events older than the retention period are
removed. Cleanup frequency adapts to the period you pick.
</p>
</CardSection>
)}
</div>
</SettingsCard>
</div>
{/* Repository Cleanup */}
<SettingsCard
icon={ArchiveRestore}
title="Repository Cleanup"
enabled={Boolean(cleanupConfig.deleteIfNotInGitHub)}
onEnabledChange={(checked) =>
onCleanupChange({ ...cleanupConfig, deleteIfNotInGitHub: checked })
}
headerAction={savingSpinner(isAutoSavingCleanup)}
footer={
<StatusFooterItem
icon={Info}
label="Runs as part of each scheduled sync"
/>
}
>
{cleanupConfig.deleteIfNotInGitHub && (
<>
<CardSection>
<SectionTitle>When a GitHub repo is deleted</SectionTitle>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{orphanActions.map((action) => (
<OptionTile
key={action.value}
icon={action.icon}
label={action.label}
description={action.description}
info={action.info}
selected={
(cleanupConfig.orphanedRepoAction ?? "archive") ===
action.value
}
onSelect={() =>
onCleanupChange({
...cleanupConfig,
orphanedRepoAction: action.value,
})
}
/>
))}
</div>
</CardSection>
<CardDivider />
<CardSection>
<SwitchRow
label="Dry run"
description="Log planned actions without changing anything"
checked={Boolean(cleanupConfig.dryRun)}
onCheckedChange={(checked) =>
onCleanupChange({ ...cleanupConfig, dryRun: checked })
}
/>
</CardSection>
</>
)}
</SettingsCard>
</div>
</CardContent>
</Card>
);
}
+66 -37
View File
@@ -1,6 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { GitHubConfigForm } from './GitHubConfigForm';
import { GiteaConfigForm } from './GiteaConfigForm';
import { GitHubMirrorSettings } from './GitHubMirrorSettings';
import { AutomationSettings } from './AutomationSettings';
import { SSOSettings } from './SSOSettings';
import { NotificationSettings } from './NotificationSettings';
@@ -686,7 +687,7 @@ export function ConfigTabs() {
? 'Import in progress'
: 'Import GitHub Data'
}
className="w-full md:w-auto"
className="w-full bg-indigo-500 text-white hover:bg-indigo-600 disabled:bg-muted disabled:text-muted-foreground md:w-auto"
>
{isSyncing ? (
<>
@@ -705,78 +706,106 @@ export function ConfigTabs() {
{/* Content section - Tabs layout */}
<Tabs defaultValue="connections" className="space-y-4">
<TabsList className="grid w-full grid-cols-4">
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 sm:grid-cols-4">
<TabsTrigger value="connections">Connections</TabsTrigger>
<TabsTrigger value="automation">Automation</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
<TabsTrigger value="sso">Authentication</TabsTrigger>
</TabsList>
<TabsContent value="connections" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-stretch">
<GitHubConfigForm
config={config.githubConfig}
setConfig={update =>
<TabsContent value="connections" className="space-y-6">
{(() => {
const githubFormProps = {
config: config.githubConfig,
setConfig: (update: React.SetStateAction<typeof config.githubConfig>) =>
setConfig(prev => ({
...prev,
githubConfig:
typeof update === 'function'
? update(prev.githubConfig)
: update,
}))
}
mirrorOptions={config.mirrorOptions}
setMirrorOptions={update =>
})),
mirrorOptions: config.mirrorOptions,
setMirrorOptions: (update: React.SetStateAction<typeof config.mirrorOptions>) =>
setConfig(prev => ({
...prev,
mirrorOptions:
typeof update === 'function'
? update(prev.mirrorOptions)
: update,
}))
}
advancedOptions={config.advancedOptions}
setAdvancedOptions={update =>
})),
advancedOptions: config.advancedOptions,
setAdvancedOptions: (update: React.SetStateAction<typeof config.advancedOptions>) =>
setConfig(prev => ({
...prev,
advancedOptions:
typeof update === 'function'
? update(prev.advancedOptions)
: update,
}))
}
giteaConfig={config.giteaConfig}
setGiteaConfig={update =>
})),
giteaConfig: config.giteaConfig,
setGiteaConfig: (update: React.SetStateAction<typeof config.giteaConfig>) =>
setConfig(prev => ({
...prev,
giteaConfig:
typeof update === 'function'
? update(prev.giteaConfig)
: update,
}))
}
onAutoSave={autoSaveGitHubConfig}
onMirrorOptionsAutoSave={autoSaveMirrorOptions}
onAdvancedOptionsAutoSave={autoSaveAdvancedOptions}
onGiteaAutoSave={autoSaveGiteaConfig}
isAutoSaving={isAutoSavingGitHub}
/>
<GiteaConfigForm
config={config.giteaConfig}
setConfig={update =>
})),
onAutoSave: autoSaveGitHubConfig,
onMirrorOptionsAutoSave: autoSaveMirrorOptions,
onAdvancedOptionsAutoSave: autoSaveAdvancedOptions,
onGiteaAutoSave: autoSaveGiteaConfig,
isAutoSaving: isAutoSavingGitHub,
};
const giteaFormProps = {
config: config.giteaConfig,
setConfig: (update: React.SetStateAction<typeof config.giteaConfig>) =>
setConfig(prev => ({
...prev,
giteaConfig:
typeof update === 'function'
? update(prev.giteaConfig)
: update,
}))
}
onAutoSave={autoSaveGiteaConfig}
isAutoSaving={isAutoSavingGitea}
githubUsername={config.githubConfig.username}
/>
</div>
})),
onAutoSave: autoSaveGiteaConfig,
isAutoSaving: isAutoSavingGitea,
githubUsername: config.githubConfig.username,
};
return (
<>
{/* Connection cards share a stretched row so they stay equal height */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<GitHubConfigForm {...githubFormProps} part="connection" />
<GiteaConfigForm {...giteaFormProps} part="connection" />
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:items-start">
<GitHubConfigForm {...githubFormProps} part="settings" />
<div className="flex flex-col gap-6">
<GiteaConfigForm {...giteaFormProps} part="organization" />
<GitHubMirrorSettings
part="content"
githubConfig={config.githubConfig}
mirrorOptions={config.mirrorOptions}
advancedOptions={config.advancedOptions}
onGitHubConfigChange={newConfig => {
setConfig(prev => ({ ...prev, githubConfig: newConfig }));
autoSaveGitHubConfig(newConfig);
}}
onMirrorOptionsChange={newOptions => {
setConfig(prev => ({ ...prev, mirrorOptions: newOptions }));
autoSaveMirrorOptions(newOptions);
}}
onAdvancedOptionsChange={newOptions => {
setConfig(prev => ({ ...prev, advancedOptions: newOptions }));
autoSaveAdvancedOptions(newOptions);
}}
/>
</div>
</div>
</>
);
})()}
</TabsContent>
<TabsContent value="automation" className="space-y-4">
+301 -292
View File
@@ -1,24 +1,33 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { githubApi } from "@/lib/api";
import type { GitHubConfig, MirrorOptions, AdvancedOptions, GiteaConfig, BackupStrategy } from "@/types/config";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { toast } from "sonner";
import { Info, ShieldAlert } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { GitHubMirrorSettings } from "./GitHubMirrorSettings";
import { Separator } from "../ui/separator";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
Activity,
CircleOff,
DatabaseBackup,
ExternalLink,
Hand,
KeyRound,
PlugZap,
ShieldAlert,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { SiGithub } from "react-icons/si";
import { GitHubMirrorSettings } from "./GitHubMirrorSettings";
import {
SettingsCard,
SectionTitle,
SwitchRow,
OptionTile,
StatusFooterItem,
CardDivider,
CardSection,
} from "./settings-ui";
interface GitHubConfigFormProps {
config: GitHubConfig;
@@ -34,9 +43,43 @@ interface GitHubConfigFormProps {
onAdvancedOptionsAutoSave?: (advancedOptions: AdvancedOptions) => Promise<void>;
onGiteaAutoSave?: (giteaConfig: GiteaConfig) => Promise<void>;
isAutoSaving?: boolean;
/** Which card group to render: the connection card, or the settings stack
* (repository selection + destructive update protection). */
part?: "connection" | "settings";
}
export function GitHubConfigForm({
const backupStrategies = [
{
value: "disabled" as const,
label: "Disabled",
description: "Force-pushes sync straight through",
icon: CircleOff,
info: "No detection or backups. Rewritten upstream history overwrites your mirror.",
},
{
value: "always" as const,
label: "Always Backup",
description: "Snapshot before every sync",
icon: DatabaseBackup,
info: "Maximum safety at the cost of disk usage.",
},
{
value: "on-force-push" as const,
label: "Smart",
description: "Snapshot only on history rewrites",
icon: Sparkles,
info: "Backs up only when a force-push is detected.",
},
{
value: "block-on-force-push" as const,
label: "Block & Approve",
description: "Hold sync until you approve",
icon: Hand,
info: "Force-pushed repos pause syncing until you approve the update.",
},
];
export function GitHubConfigForm({
config,
setConfig,
mirrorOptions,
@@ -49,7 +92,8 @@ export function GitHubConfigForm({
onMirrorOptionsAutoSave,
onAdvancedOptionsAutoSave,
onGiteaAutoSave,
isAutoSaving
isAutoSaving,
part = "connection"
}: GitHubConfigFormProps) {
const [isLoading, setIsLoading] = useState(false);
@@ -93,289 +137,254 @@ export function GitHubConfigForm({
}
};
return (
<Card className="w-full h-full flex flex-col">
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<CardTitle className="text-lg font-semibold">
GitHub Configuration
</CardTitle>
{/* Desktop: Show button in header */}
<Button
type="button"
variant="default"
onClick={testConnection}
disabled={isLoading || !config.token}
className="hidden sm:inline-flex"
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
</CardHeader>
const updateGitea = (newConfig: GiteaConfig) => {
if (!setGiteaConfig) return;
setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
};
<CardContent className="flex flex-col gap-y-6 flex-1">
<div>
<label
htmlFor="github-username"
className="block text-sm font-medium mb-1.5"
>
GitHub Username
</label>
<Input
id="github-username"
name="username"
type="text"
value={config.username}
onChange={handleChange}
placeholder="Your GitHub username"
required
className="bg-background"
/>
</div>
const backupStrategy = giteaConfig?.backupStrategy ?? "on-force-push";
<div>
<div className="flex items-center gap-2 mb-1.5">
<label
htmlFor="github-token"
className="block text-sm font-medium"
if (part === "connection") {
return (
<SettingsCard
icon={SiGithub}
title="GitHub Connection"
headerAction={
<div className="flex items-center gap-3">
{isAutoSaving && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={testConnection}
disabled={isLoading || !config.token}
>
GitHub Token
</label>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span className="inline-flex p-0.5 hover:bg-muted rounded-sm transition-colors cursor-help">
<Info className="h-3.5 w-3.5 text-muted-foreground" />
<PlugZap className="mr-1.5 h-3.5 w-3.5" />
{isLoading ? "Testing..." : "Test"}
</Button>
</div>
}
>
<CardSection>
<div className="space-y-1.5">
<Label
htmlFor="github-username"
className="text-xs font-medium text-muted-foreground"
>
Username
</Label>
<Input
id="github-username"
name="username"
type="text"
value={config.username}
onChange={handleChange}
placeholder="Your GitHub username"
required
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor="github-token"
className="text-xs font-medium text-muted-foreground"
>
Personal access token
</Label>
<Input
id="github-token"
name="token"
type="password"
value={config.token}
onChange={handleChange}
placeholder="Your GitHub token (classic)"
/>
<p className="text-[11px] text-muted-foreground/80">
Needed for private repos, organizations, and starred repositories
</p>
</div>
<div className="space-y-3 rounded-lg bg-muted/40 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-muted-foreground" />
<span className="text-[13px] font-semibold text-muted-foreground">
Creating your token
</span>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-80">
<div className="space-y-2">
<h4 className="font-medium text-sm">GitHub Token Requirements</h4>
<div className="text-sm space-y-2">
<p>
You need to create a <span className="font-medium">Classic GitHub PAT Token</span> with the following scopes:
</p>
<ul className="ml-4 space-y-1 list-disc">
<li><code className="text-xs bg-muted px-1 py-0.5 rounded">repo</code></li>
<li><code className="text-xs bg-muted px-1 py-0.5 rounded">admin:org</code></li>
</ul>
<p className="text-muted-foreground">
The organization access is required for mirroring organization repositories.
</p>
<p>
Generate tokens at{" "}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline font-medium"
>
github.com/settings/tokens
</a>
</div>
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
title="Open github.com/settings/tokens"
aria-label="Open GitHub token settings"
className="text-indigo-500 hover:text-indigo-400"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
<ol className="list-decimal space-y-1.5 pl-4 text-xs leading-relaxed text-muted-foreground">
<li>GitHub → Settings → Developer settings</li>
<li>Personal access tokens → Generate new token (classic)</li>
<li>Select the scopes below and paste the token here</li>
</ol>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-2 py-0.5 font-mono text-[11px] text-muted-foreground">
repo
</code>
<code className="rounded bg-muted px-2 py-0.5 font-mono text-[11px] text-muted-foreground">
admin:org
</code>
</div>
</div>
</CardSection>
</SettingsCard>
);
}
return (
<div className="flex flex-col gap-6">
<GitHubMirrorSettings
part="selection"
githubConfig={config}
mirrorOptions={mirrorOptions}
advancedOptions={advancedOptions}
onGitHubConfigChange={(newConfig) => {
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onMirrorOptionsChange={(newOptions) => {
setMirrorOptions(newOptions);
if (onMirrorOptionsAutoSave) onMirrorOptionsAutoSave(newOptions);
}}
onAdvancedOptionsChange={(newOptions) => {
setAdvancedOptions(newOptions);
if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions);
}}
/>
{/* Destructive Update Protection */}
{giteaConfig && setGiteaConfig && (
<SettingsCard
icon={ShieldAlert}
title="Destructive Update Protection"
footer={
<StatusFooterItem
icon={ShieldCheck}
label="Applies to Always Backup and Smart modes"
/>
}
>
<CardSection>
<SectionTitle>How to handle force-pushes on GitHub</SectionTitle>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{backupStrategies.map((opt) => (
<OptionTile
key={opt.value}
icon={opt.icon}
label={opt.label}
description={opt.description}
info={opt.info}
selected={backupStrategy === opt.value}
onSelect={() =>
updateGitea({
...giteaConfig,
backupStrategy: opt.value as BackupStrategy,
})
}
/>
))}
</div>
</CardSection>
{backupStrategy !== "disabled" && (
<>
<CardDivider />
<CardSection>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label
htmlFor="backup-retention"
className="text-xs font-medium text-muted-foreground"
>
Snapshot retention count
</Label>
<Input
id="backup-retention"
name="backupRetentionCount"
type="number"
min={1}
value={giteaConfig.backupRetentionCount ?? 5}
onChange={(e) =>
updateGitea({
...giteaConfig,
backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 5),
})
}
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor="backup-retention-days"
className="text-xs font-medium text-muted-foreground"
>
Snapshot retention days
</Label>
<Input
id="backup-retention-days"
name="backupRetentionDays"
type="number"
min={0}
value={giteaConfig.backupRetentionDays ?? 30}
onChange={(e) =>
updateGitea({
...giteaConfig,
backupRetentionDays: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
})
}
/>
<p className="text-[11px] text-muted-foreground/80">
0 = no time-based limit
</p>
</div>
<div className="space-y-1.5">
<Label
htmlFor="backup-directory"
className="text-xs font-medium text-muted-foreground"
>
Snapshot directory
</Label>
<Input
id="backup-directory"
name="backupDirectory"
type="text"
value={giteaConfig.backupDirectory || "data/repo-backups"}
onChange={(e) =>
updateGitea({ ...giteaConfig, backupDirectory: e.target.value })
}
placeholder="data/repo-backups"
/>
</div>
</div>
</HoverCardContent>
</HoverCard>
</div>
<Input
id="github-token"
name="token"
type="password"
value={config.token}
onChange={handleChange}
className="bg-background"
placeholder="Your GitHub token (classic) with repo and admin:org scopes"
/>
<p className="text-xs text-muted-foreground mt-1">
Required for private repositories, organizations, and starred
repositories.
</p>
</div>
<Separator />
<GitHubMirrorSettings
githubConfig={config}
mirrorOptions={mirrorOptions}
advancedOptions={advancedOptions}
onGitHubConfigChange={(newConfig) => {
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onMirrorOptionsChange={(newOptions) => {
setMirrorOptions(newOptions);
if (onMirrorOptionsAutoSave) onMirrorOptionsAutoSave(newOptions);
}}
onAdvancedOptionsChange={(newOptions) => {
setAdvancedOptions(newOptions);
if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions);
}}
/>
{giteaConfig && setGiteaConfig && (
<>
<Separator />
<div className="space-y-4">
<h3 className="text-sm font-medium flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-primary" />
Destructive Update Protection
<Badge variant="secondary" className="ml-2 text-[10px] px-1.5 py-0">BETA</Badge>
</h3>
<p className="text-xs text-muted-foreground">
Choose how to handle force-pushes or rewritten upstream history on GitHub.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{([
{
value: "disabled",
label: "Disabled",
desc: "No detection or backups",
},
{
value: "always",
label: "Always Backup",
desc: "Snapshot before every sync (high disk usage)",
},
{
value: "on-force-push",
label: "Smart",
desc: "Backup only on force-push",
},
{
value: "block-on-force-push",
label: "Block & Approve",
desc: "Require approval on force-push",
},
] as const).map((opt) => {
const isSelected = (giteaConfig.backupStrategy ?? "on-force-push") === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => {
const newConfig = { ...giteaConfig, backupStrategy: opt.value as BackupStrategy };
setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
}}
className={`flex flex-col items-start gap-1 rounded-lg border p-3 text-left text-sm transition-colors ${
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-input hover:bg-accent hover:text-accent-foreground"
}`}
>
<span className="font-medium">{opt.label}</span>
<span className="text-xs text-muted-foreground">{opt.desc}</span>
</button>
);
})}
</div>
{(giteaConfig.backupStrategy ?? "on-force-push") !== "disabled" && (
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label htmlFor="backup-retention" className="block text-sm font-medium mb-1.5">
Snapshot retention count
</label>
<input
id="backup-retention"
name="backupRetentionCount"
type="number"
min={1}
value={giteaConfig.backupRetentionCount ?? 5}
onChange={(e) => {
const newConfig = {
...giteaConfig,
backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 5),
};
setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
}}
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"
/>
</div>
<div>
<label htmlFor="backup-retention-days" className="block text-sm font-medium mb-1.5">
Snapshot retention days
</label>
<input
id="backup-retention-days"
name="backupRetentionDays"
type="number"
min={0}
value={giteaConfig.backupRetentionDays ?? 30}
onChange={(e) => {
const newConfig = {
...giteaConfig,
backupRetentionDays: Math.max(0, Number.parseInt(e.target.value, 10) || 0),
};
setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
}}
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"
/>
<p className="text-xs text-muted-foreground mt-1">0 = no time-based limit</p>
</div>
<div>
<label htmlFor="backup-directory" className="block text-sm font-medium mb-1.5">
Snapshot directory
</label>
<input
id="backup-directory"
name="backupDirectory"
type="text"
value={giteaConfig.backupDirectory || "data/repo-backups"}
onChange={(e) => {
const newConfig = { ...giteaConfig, backupDirectory: e.target.value };
setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
}}
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"
placeholder="data/repo-backups"
/>
</div>
</div>
{((giteaConfig.backupStrategy ?? "on-force-push") === "always" ||
(giteaConfig.backupStrategy ?? "on-force-push") === "on-force-push") && (
<label className="flex items-start gap-3 text-sm">
<input
name="blockSyncOnBackupFailure"
type="checkbox"
checked={Boolean(giteaConfig.blockSyncOnBackupFailure)}
onChange={(e) => {
const newConfig = { ...giteaConfig, blockSyncOnBackupFailure: e.target.checked };
setGiteaConfig(newConfig);
if (onGiteaAutoSave) onGiteaAutoSave(newConfig);
}}
className="mt-0.5 rounded border-input"
/>
<span>
Block sync when snapshot fails
<p className="text-xs text-muted-foreground">
Recommended for backup-first behavior. If disabled, sync continues even when snapshot creation fails.
</p>
</span>
</label>
)}
</>
)}
</div>
</>
)}
{/* Mobile: Show button at bottom */}
<Button
type="button"
variant="default"
onClick={testConnection}
disabled={isLoading || !config.token}
className="sm:hidden w-full"
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
</CardContent>
</Card>
{(backupStrategy === "always" || backupStrategy === "on-force-push") && (
<SwitchRow
label="Block sync when snapshot fails"
description="Recommended so a failed backup never lets a destructive sync through"
checked={Boolean(giteaConfig.blockSyncOnBackupFailure)}
onCheckedChange={(checked) =>
updateGitea({ ...giteaConfig, blockSyncOnBackupFailure: checked })
}
/>
)}
</CardSection>
</>
)}
</SettingsCard>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
+217 -197
View File
@@ -1,19 +1,20 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertTriangle } from "lucide-react";
import { Activity, AlertTriangle, Building2, Info, PlugZap, Server } from "lucide-react";
import { giteaApi, type GiteaServerInfo } from "@/lib/api";
import type { GiteaConfig, MirrorStrategy } from "@/types/config";
import { toast } from "sonner";
import { OrganizationStrategy } from "./OrganizationStrategy";
import { OrganizationConfiguration } from "./OrganizationConfiguration";
import { Separator } from "../ui/separator";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import {
SettingsCard,
StatusFooterItem,
CardDivider,
CardSection,
} from "./settings-ui";
interface GiteaConfigFormProps {
config: GiteaConfig;
@@ -21,12 +22,14 @@ interface GiteaConfigFormProps {
onAutoSave?: (giteaConfig: GiteaConfig) => Promise<void>;
isAutoSaving?: boolean;
githubUsername?: string;
/** Which card to render: the connection card or the organization card. */
part?: "connection" | "organization";
}
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername }: GiteaConfigFormProps) {
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername, part = "connection" }: GiteaConfigFormProps) {
const [isLoading, setIsLoading] = useState(false);
const [serverInfo, setServerInfo] = useState<GiteaServerInfo | null>(null);
// Derive the mirror strategy from existing config for backward compatibility
const getMirrorStrategy = (): MirrorStrategy => {
if (config.mirrorStrategy) return config.mirrorStrategy;
@@ -36,13 +39,15 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
if (config.organization && config.organization !== config.username) return "single-org";
return "flat-user";
};
const [mirrorStrategy, setMirrorStrategy] = useState<MirrorStrategy>(getMirrorStrategy());
// Update config when strategy changes
// Update config when strategy changes. Only the organization instance runs
// this effect so a second (connection) instance can't double-fire autosave.
useEffect(() => {
if (part !== "organization") return;
const newConfig = { ...config };
switch (mirrorStrategy) {
case "preserve":
newConfig.preserveOrgStructure = true;
@@ -75,7 +80,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
}
break;
}
setConfig(newConfig);
if (onAutoSave) {
onAutoSave(newConfig);
@@ -149,191 +154,206 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
}
};
if (part === "connection") {
return (
<SettingsCard
icon={Server}
title="Gitea Connection"
headerAction={
<div className="flex items-center gap-3">
{isAutoSaving && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={testConnection}
disabled={isLoading || !config.url || !config.token}
>
<PlugZap className="mr-1.5 h-3.5 w-3.5" />
{isLoading ? "Testing..." : "Test"}
</Button>
</div>
}
footer={
serverInfo ? (
<StatusFooterItem
icon={Info}
label={`${serverInfo.type === "forgejo" ? "Forgejo" : "Gitea"} ${serverInfo.version} detected`}
/>
) : undefined
}
>
<CardSection>
{serverInfo?.type === "forgejo" && serverInfo.hasMirrorCredBug && (
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
Forgejo {serverInfo.version} has a known mirror-credential bug
</AlertTitle>
<AlertDescription>
<p>
Pull-mirror credentials sent via Forgejo's migrate API aren't persisted on this version, so subsequent syncs of private repos fail with <code className="text-xs font-mono bg-amber-100 dark:bg-amber-900/40 px-1 py-0.5 rounded">terminal prompts disabled</code>. Fixed in Forgejo 15.0.0 (
<a
href="https://codeberg.org/forgejo/forgejo/pulls/11909"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
PR #11909
</a>
).
</p>
<p>
Upgrade Forgejo to 15.0.0 or later, then delete and re-mirror affected repos — or open each repo's Settings → Mirror Settings in Forgejo and re-enter the GitHub token once.
</p>
</AlertDescription>
</Alert>
)}
<div className="space-y-1.5">
<Label
htmlFor="gitea-username"
className="text-xs font-medium text-muted-foreground"
>
Username
</Label>
<Input
id="gitea-username"
name="username"
type="text"
value={config.username}
onChange={handleChange}
placeholder="Your Gitea username"
required
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor="gitea-url"
className="text-xs font-medium text-muted-foreground"
>
Server URL
</Label>
<Input
id="gitea-url"
name="url"
type="url"
value={config.url}
onChange={handleChange}
placeholder="https://your-gitea-instance.com"
required
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor="gitea-external-url"
className="text-xs font-medium text-muted-foreground"
>
External URL (optional)
</Label>
<Input
id="gitea-external-url"
name="externalUrl"
type="url"
value={config.externalUrl || ""}
onChange={handleChange}
placeholder="https://gitea.example.com"
/>
<p className="text-[11px] text-muted-foreground/80">
Dashboard links only, syncing always uses the server URL
</p>
</div>
<div className="space-y-1.5">
<Label
htmlFor="gitea-token"
className="text-xs font-medium text-muted-foreground"
>
Access token
</Label>
<Input
id="gitea-token"
name="token"
type="password"
value={config.token}
onChange={handleChange}
placeholder="Your Gitea access token"
required
/>
<p className="text-[11px] text-muted-foreground/80">
Create one in Gitea under Settings → Applications
</p>
</div>
</CardSection>
</SettingsCard>
);
}
return (
<Card className="w-full h-full flex flex-col">
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<CardTitle className="text-lg font-semibold">
Gitea Configuration
</CardTitle>
{/* Desktop: Show button in header */}
<Button
type="button"
variant="default"
onClick={testConnection}
disabled={isLoading || !config.url || !config.token}
className="hidden sm:inline-flex"
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-y-6 flex-1">
{serverInfo?.type === "forgejo" && serverInfo.hasMirrorCredBug && (
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
Forgejo {serverInfo.version} has a known mirror-credential bug
</AlertTitle>
<AlertDescription>
<p>
Pull-mirror credentials sent via Forgejo's migrate API aren't persisted on this version, so subsequent syncs of private repos fail with <code className="text-xs font-mono bg-amber-100 dark:bg-amber-900/40 px-1 py-0.5 rounded">terminal prompts disabled</code>. Fixed in Forgejo 15.0.0 (
<a
href="https://codeberg.org/forgejo/forgejo/pulls/11909"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
PR #11909
</a>
).
</p>
<p>
Upgrade Forgejo to 15.0.0 or later, then delete and re-mirror affected repos — or open each repo's Settings → Mirror Settings in Forgejo and re-enter the GitHub token once.
</p>
</AlertDescription>
</Alert>
)}
<div>
<label
htmlFor="gitea-username"
className="block text-sm font-medium mb-1.5"
>
Gitea Username
</label>
<input
id="gitea-username"
name="username"
type="text"
value={config.username}
onChange={handleChange}
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"
placeholder="Your Gitea username"
required
<>
{/* Organization Structure */}
<SettingsCard
icon={Building2}
title="Organization Structure"
footer={
<StatusFooterItem
icon={Info}
label="Strategy applies to newly mirrored repositories"
/>
</div>
<div>
<label
htmlFor="gitea-url"
className="block text-sm font-medium mb-1.5"
>
Gitea URL
</label>
<input
id="gitea-url"
name="url"
type="url"
value={config.url}
onChange={handleChange}
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"
placeholder="https://your-gitea-instance.com"
required
}
>
<CardSection>
<OrganizationStrategy
strategy={mirrorStrategy}
destinationOrg={config.organization}
starredReposOrg={config.starredReposOrg}
starredReposMode={config.starredReposMode}
onStrategyChange={setMirrorStrategy}
githubUsername={githubUsername}
giteaUsername={config.username}
/>
</div>
<div>
<label
htmlFor="gitea-external-url"
className="block text-sm font-medium mb-1.5"
>
Gitea External URL (optional)
</label>
<input
id="gitea-external-url"
name="externalUrl"
type="url"
value={config.externalUrl || ""}
onChange={handleChange}
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"
placeholder="https://gitea.example.com"
</CardSection>
<CardDivider />
<CardSection>
<OrganizationConfiguration
strategy={mirrorStrategy}
destinationOrg={config.organization}
starredReposOrg={config.starredReposOrg}
starredReposMode={config.starredReposMode}
personalReposOrg={config.personalReposOrg}
visibility={config.visibility}
onDestinationOrgChange={(org) => {
const newConfig = { ...config, organization: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onStarredReposOrgChange={(org) => {
const newConfig = { ...config, starredReposOrg: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onStarredReposModeChange={(mode) => {
const newConfig = { ...config, starredReposMode: mode };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onPersonalReposOrgChange={(org) => {
const newConfig = { ...config, personalReposOrg: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onVisibilityChange={(visibility) => {
const newConfig = { ...config, visibility };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
/>
<p className="text-xs text-muted-foreground mt-1">
Used only for dashboard links. API sync still uses Gitea URL.
</p>
</div>
<div>
<label
htmlFor="gitea-token"
className="block text-sm font-medium mb-1.5"
>
Gitea Token
</label>
<input
id="gitea-token"
name="token"
type="password"
value={config.token}
onChange={handleChange}
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"
placeholder="Your Gitea access token"
required
/>
<p className="text-xs text-muted-foreground mt-1">
Create a token in your Gitea instance under Settings &gt;
Applications.
</p>
</div>
<Separator />
<OrganizationStrategy
strategy={mirrorStrategy}
destinationOrg={config.organization}
starredReposOrg={config.starredReposOrg}
starredReposMode={config.starredReposMode}
onStrategyChange={setMirrorStrategy}
githubUsername={githubUsername}
giteaUsername={config.username}
/>
<Separator />
<OrganizationConfiguration
strategy={mirrorStrategy}
destinationOrg={config.organization}
starredReposOrg={config.starredReposOrg}
starredReposMode={config.starredReposMode}
personalReposOrg={config.personalReposOrg}
visibility={config.visibility}
onDestinationOrgChange={(org) => {
const newConfig = { ...config, organization: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onStarredReposOrgChange={(org) => {
const newConfig = { ...config, starredReposOrg: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onStarredReposModeChange={(mode) => {
const newConfig = { ...config, starredReposMode: mode };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onPersonalReposOrgChange={(org) => {
const newConfig = { ...config, personalReposOrg: org };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onVisibilityChange={(visibility) => {
const newConfig = { ...config, visibility };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
/>
{/* Mobile: Show button at bottom */}
<Button
type="button"
variant="default"
onClick={testConnection}
disabled={isLoading || !config.url || !config.token}
className="sm:hidden w-full"
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
</CardContent>
</Card>
</CardSection>
</SettingsCard>
</>
);
}
+331 -204
View File
@@ -1,5 +1,4 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
@@ -9,12 +8,20 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Bell, Activity, Send } from "lucide-react";
import { Bell, Activity, Send, Info } from "lucide-react";
import { toast } from "sonner";
import type { NotificationConfig } from "@/types/config";
import { withBase } from "@/lib/base-path";
import {
SettingsCard,
SectionTitle,
SwitchRow,
SegmentedControl,
StatusFooterItem,
CardDivider,
CardSection,
} from "./settings-ui";
interface NotificationSettingsProps {
notificationConfig: NotificationConfig;
@@ -22,6 +29,42 @@ interface NotificationSettingsProps {
isAutoSaving?: boolean;
}
type Provider = "ntfy" | "apprise" | "gotify" | "webhook";
const providerOptions: { value: Provider; label: string }[] = [
{ value: "ntfy", label: "Ntfy.sh" },
{ value: "apprise", label: "Apprise" },
{ value: "gotify", label: "Gotify" },
{ value: "webhook", label: "Webhook" },
];
function Field({
id,
label,
required,
helper,
children,
}: {
id: string;
label: string;
required?: boolean;
helper?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-xs font-medium text-muted-foreground">
{label}
{required && <span className="text-destructive"> *</span>}
</Label>
{children}
{helper && (
<p className="text-[11px] text-muted-foreground/80">{helper}</p>
)}
</div>
);
}
export function NotificationSettings({
notificationConfig,
onNotificationChange,
@@ -53,69 +96,71 @@ export function NotificationSettings({
};
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="text-lg font-semibold flex items-center gap-2">
<Bell className="h-5 w-5" />
Notifications
{isAutoSaving && (
<Activity className="h-4 w-4 animate-spin text-muted-foreground ml-2" />
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Enable/disable toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifications-enabled" className="text-sm font-medium cursor-pointer">
Enable notifications
</Label>
<p className="text-xs text-muted-foreground">
Receive alerts when mirror jobs complete or fail
</p>
</div>
<Switch
id="notifications-enabled"
checked={notificationConfig.enabled}
onCheckedChange={(checked) =>
onNotificationChange({ ...notificationConfig, enabled: checked })
}
/>
</div>
{notificationConfig.enabled && (
<>
{/* Provider selector */}
<div className="space-y-2">
<Label htmlFor="notification-provider" className="text-sm font-medium">
Notification provider
</Label>
<Select
value={notificationConfig.provider}
onValueChange={(value: "ntfy" | "apprise") =>
onNotificationChange({ ...notificationConfig, provider: value })
}
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-2">
<SettingsCard
icon={Bell}
title="Notifications"
enabled={notificationConfig.enabled}
onEnabledChange={(checked) =>
onNotificationChange({ ...notificationConfig, enabled: checked })
}
headerAction={
isAutoSaving ? (
<Activity className="h-4 w-4 animate-spin text-muted-foreground" />
) : undefined
}
footer={
notificationConfig.enabled ? (
<>
<StatusFooterItem
icon={Info}
label="Sends a test message with your current settings"
/>
<Button
size="sm"
onClick={handleTestNotification}
disabled={isTesting}
className="bg-indigo-500 text-white hover:bg-indigo-600"
>
<SelectTrigger id="notification-provider">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ntfy">Ntfy.sh</SelectItem>
<SelectItem value="apprise">Apprise API</SelectItem>
</SelectContent>
</Select>
</div>
{isTesting ? (
<>
<Activity className="mr-2 h-3.5 w-3.5 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 h-3.5 w-3.5" />
Send test
</>
)}
</Button>
</>
) : (
<StatusFooterItem
icon={Info}
label="Get alerted when mirror jobs complete or fail"
/>
)
}
>
{notificationConfig.enabled && (
<CardSection>
<SectionTitle>Provider</SectionTitle>
<SegmentedControl
options={providerOptions}
value={notificationConfig.provider}
onChange={(value) =>
onNotificationChange({ ...notificationConfig, provider: value })
}
/>
{/* Ntfy configuration */}
{notificationConfig.provider === "ntfy" && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Ntfy.sh Settings</h3>
<div className="space-y-2">
<Label htmlFor="ntfy-url" className="text-sm">
Server URL
</Label>
<div className="space-y-4 pt-2">
<Field
id="ntfy-url"
label="Server URL"
helper="Use https://ntfy.sh for the public server or your self-hosted instance URL"
>
<Input
id="ntfy-url"
type="url"
@@ -133,15 +178,13 @@ export function NotificationSettings({
})
}
/>
<p className="text-xs text-muted-foreground">
Use https://ntfy.sh for the public server or your self-hosted instance URL
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-topic" className="text-sm">
Topic <span className="text-destructive">*</span>
</Label>
</Field>
<Field
id="ntfy-topic"
label="Topic"
required
helper="Choose a unique topic name. Anyone with the topic name can subscribe."
>
<Input
id="ntfy-topic"
placeholder="gitea-mirror"
@@ -158,15 +201,12 @@ export function NotificationSettings({
})
}
/>
<p className="text-xs text-muted-foreground">
Choose a unique topic name. Anyone with the topic name can subscribe.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-token" className="text-sm">
Access token (optional)
</Label>
</Field>
<Field
id="ntfy-token"
label="Access token (optional)"
helper="Required if your ntfy server uses authentication"
>
<Input
id="ntfy-token"
type="password"
@@ -185,15 +225,12 @@ export function NotificationSettings({
})
}
/>
<p className="text-xs text-muted-foreground">
Required if your ntfy server uses authentication
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-priority" className="text-sm">
Default priority
</Label>
</Field>
<Field
id="ntfy-priority"
label="Default priority"
helper='Error notifications always use "high" priority regardless of this setting'
>
<Select
value={notificationConfig.ntfy?.priority || "default"}
onValueChange={(value: "min" | "low" | "default" | "high" | "urgent") =>
@@ -219,22 +256,18 @@ export function NotificationSettings({
<SelectItem value="urgent">Urgent</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Error notifications always use "high" priority regardless of this setting
</p>
</div>
</Field>
</div>
)}
{/* Apprise configuration */}
{notificationConfig.provider === "apprise" && (
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Apprise API Settings</h3>
<div className="space-y-2">
<Label htmlFor="apprise-url" className="text-sm">
Server URL <span className="text-destructive">*</span>
</Label>
<div className="space-y-4 pt-2">
<Field
id="apprise-url"
label="Server URL"
required
helper="URL of your Apprise API server (e.g., http://apprise:8000)"
>
<Input
id="apprise-url"
type="url"
@@ -251,15 +284,13 @@ export function NotificationSettings({
})
}
/>
<p className="text-xs text-muted-foreground">
URL of your Apprise API server (e.g., http://apprise:8000)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="apprise-token" className="text-sm">
Token / path <span className="text-destructive">*</span>
</Label>
</Field>
<Field
id="apprise-token"
label="Token / path"
required
helper="The Apprise API configuration token or key"
>
<Input
id="apprise-token"
placeholder="gitea-mirror"
@@ -275,15 +306,12 @@ export function NotificationSettings({
})
}
/>
<p className="text-xs text-muted-foreground">
The Apprise API configuration token or key
</p>
</div>
<div className="space-y-2">
<Label htmlFor="apprise-tag" className="text-sm">
Tag filter (optional)
</Label>
</Field>
<Field
id="apprise-tag"
label="Tag filter (optional)"
helper="Optional tag to filter which Apprise services receive notifications"
>
<Input
id="apprise-tag"
placeholder="all"
@@ -300,96 +328,195 @@ export function NotificationSettings({
})
}
/>
<p className="text-xs text-muted-foreground">
Optional tag to filter which Apprise services receive notifications
</p>
</div>
</Field>
</div>
)}
{/* Event toggles */}
<div className="space-y-4 p-4 border border-border rounded-lg bg-card/50">
<h3 className="text-sm font-medium">Notification Events</h3>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-sync-error" className="text-sm font-normal cursor-pointer">
Sync errors
</Label>
<p className="text-xs text-muted-foreground">
Notify when a mirror job fails
</p>
</div>
<Switch
id="notify-sync-error"
checked={notificationConfig.notifyOnSyncError}
onCheckedChange={(checked) =>
onNotificationChange({ ...notificationConfig, notifyOnSyncError: checked })
}
/>
{notificationConfig.provider === "gotify" && (
<div className="space-y-4 pt-2">
<Field
id="gotify-url"
label="Server URL"
required
helper="URL of your Gotify server"
>
<Input
id="gotify-url"
type="url"
placeholder="https://gotify.example.com"
value={notificationConfig.gotify?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: e.target.value,
token: notificationConfig.gotify?.token || "",
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
</Field>
<Field
id="gotify-token"
label="Application token"
required
helper="Create an application in Gotify and paste its token here"
>
<Input
id="gotify-token"
type="password"
placeholder="A1b2C3d4..."
value={notificationConfig.gotify?.token || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: e.target.value,
priority: notificationConfig.gotify?.priority ?? 5,
},
})
}
/>
</Field>
<Field
id="gotify-priority"
label="Default priority (0-10)"
helper="Error notifications always use priority 8 regardless of this setting"
>
<Input
id="gotify-priority"
type="number"
min={0}
max={10}
value={notificationConfig.gotify?.priority ?? 5}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
gotify: {
...notificationConfig.gotify!,
url: notificationConfig.gotify?.url || "",
token: notificationConfig.gotify?.token || "",
priority: Math.min(10, Math.max(0, Number(e.target.value) || 0)),
},
})
}
/>
</Field>
</div>
)}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-sync-success" className="text-sm font-normal cursor-pointer">
Sync success
</Label>
<p className="text-xs text-muted-foreground">
Notify when a mirror job completes successfully
</p>
</div>
<Switch
id="notify-sync-success"
checked={notificationConfig.notifyOnSyncSuccess}
onCheckedChange={(checked) =>
onNotificationChange({ ...notificationConfig, notifyOnSyncSuccess: checked })
}
/>
{notificationConfig.provider === "webhook" && (
<div className="space-y-4 pt-2">
<Field
id="webhook-url"
label="Webhook URL"
required
helper="Notifications are sent as a JSON POST with title, message, type, and timestamp fields"
>
<Input
id="webhook-url"
type="url"
placeholder="https://example.com/hooks/gitea-mirror"
value={notificationConfig.webhook?.url || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
webhook: {
...notificationConfig.webhook,
url: e.target.value,
},
})
}
/>
</Field>
<Field
id="webhook-secret"
label="Signing secret (optional)"
helper="If set, requests include an X-Webhook-Signature header with an HMAC-SHA256 hex digest of the body (sha256=...)"
>
<Input
id="webhook-secret"
type="password"
placeholder="whsec_..."
value={notificationConfig.webhook?.secret || ""}
onChange={(e) =>
onNotificationChange({
...notificationConfig,
webhook: {
...notificationConfig.webhook,
url: notificationConfig.webhook?.url || "",
secret: e.target.value,
},
})
}
/>
</Field>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-new-repo" className="text-sm font-normal cursor-pointer text-muted-foreground">
New repository discovered (coming soon)
</Label>
<p className="text-xs text-muted-foreground">
Notify when a new GitHub repository is auto-imported
</p>
</div>
<Switch
id="notify-new-repo"
checked={notificationConfig.notifyOnNewRepo}
disabled
onCheckedChange={(checked) =>
onNotificationChange({ ...notificationConfig, notifyOnNewRepo: checked })
}
/>
</div>
</div>
{/* Test button */}
<div className="flex justify-end">
<Button
variant="outline"
onClick={handleTestNotification}
disabled={isTesting}
>
{isTesting ? (
<>
<Activity className="h-4 w-4 animate-spin mr-2" />
Sending...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Send Test Notification
</>
)}
</Button>
</div>
</>
)}
</CardSection>
)}
</CardContent>
</Card>
</SettingsCard>
{notificationConfig.enabled && (
<SettingsCard
icon={Activity}
title="Notification Events"
footer={
<StatusFooterItem
icon={Info}
label="Error notifications are always sent at high priority"
/>
}
>
<CardSection>
<SwitchRow
label="Sync errors"
description="Notify when a mirror job fails"
checked={notificationConfig.notifyOnSyncError}
onCheckedChange={(checked) =>
onNotificationChange({
...notificationConfig,
notifyOnSyncError: checked,
})
}
/>
</CardSection>
<CardDivider />
<CardSection>
<SwitchRow
label="Sync success"
description="Notify when a mirror job completes successfully"
checked={notificationConfig.notifyOnSyncSuccess}
onCheckedChange={(checked) =>
onNotificationChange({
...notificationConfig,
notifyOnSyncSuccess: checked,
})
}
/>
</CardSection>
<CardDivider />
<CardSection>
<SwitchRow
label="New repository discovered"
description="Notify when a new GitHub repository is auto-imported"
badge="SOON"
checked={notificationConfig.notifyOnNewRepo}
disabled
onCheckedChange={(checked) =>
onNotificationChange({
...notificationConfig,
notifyOnNewRepo: checked,
})
}
/>
</CardSection>
</SettingsCard>
)}
</div>
);
}
@@ -50,16 +50,13 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<MonitorCog className="h-4 w-4" />
Organization Configuration
</h4>
</div>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Organization configuration
</span>
<div className="space-y-2">
<Label className="text-sm font-normal flex items-center gap-2">
Starred Repository Destination
<Label className="text-xs font-medium text-muted-foreground flex items-center gap-2">
Starred repository destination
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
@@ -71,43 +68,43 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
</Tooltip>
</TooltipProvider>
</Label>
<div className="rounded-lg border bg-muted/20 p-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
onClick={() => onStarredReposModeChange("dedicated-org")}
aria-pressed={activeStarredMode === "dedicated-org"}
className={cn(
"text-left px-3 py-2 rounded-md border text-sm transition-all",
activeStarredMode === "dedicated-org"
? "bg-accent border-accent-foreground/30 ring-1 ring-accent-foreground/20 font-medium shadow-sm"
: "bg-background hover:bg-accent/50 border-input"
)}
>
Dedicated Organization
</button>
<button
type="button"
onClick={() => onStarredReposModeChange("preserve-owner")}
aria-pressed={activeStarredMode === "preserve-owner"}
className={cn(
"text-left px-3 py-2 rounded-md border text-sm transition-all",
activeStarredMode === "preserve-owner"
? "bg-accent border-accent-foreground/30 ring-1 ring-accent-foreground/20 font-medium shadow-sm"
: "bg-background hover:bg-accent/50 border-input"
)}
>
Preserve Source Owner/Org
</button>
</div>
<p className="mt-2 px-1 text-xs text-muted-foreground">
{
<div className="flex w-full gap-1 rounded-lg bg-muted p-1" role="tablist">
<button
type="button"
role="tab"
aria-selected={activeStarredMode === "dedicated-org"}
onClick={() => onStarredReposModeChange("dedicated-org")}
className={cn(
"flex h-8 flex-1 items-center justify-center rounded-md text-xs transition-colors",
activeStarredMode === "dedicated-org"
? "All starred repositories go to a single destination organization."
: "Starred repositories keep their original GitHub Owner/Org destination."
}
</p>
? "bg-background font-medium text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Dedicated organization
</button>
<button
type="button"
role="tab"
aria-selected={activeStarredMode === "preserve-owner"}
onClick={() => onStarredReposModeChange("preserve-owner")}
className={cn(
"flex h-8 flex-1 items-center justify-center rounded-md text-xs transition-colors",
activeStarredMode === "preserve-owner"
? "bg-background font-medium text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Preserve source owner
</button>
</div>
<p className="text-[11px] text-muted-foreground/80">
{
activeStarredMode === "dedicated-org"
? "All starred repositories go to a single destination organization"
: "Starred repositories keep their original GitHub Owner/Org destination"
}
</p>
</div>
{/* First row - Organization inputs */}
@@ -199,7 +196,7 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
</Tooltip>
</TooltipProvider>
</Label>
<div className="grid grid-cols-3 gap-2">
<div className="flex w-full gap-1 rounded-lg bg-muted p-1" role="tablist">
{visibilityOptions.map((option) => {
const Icon = option.icon;
const isSelected = visibility === option.value;
@@ -209,20 +206,18 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
<TooltipTrigger asChild>
<button
type="button"
role="tab"
aria-selected={isSelected}
onClick={() => onVisibilityChange(option.value)}
className={cn(
"flex items-center justify-between px-3 py-2 rounded-md text-sm transition-all",
"border group",
"flex h-8 flex-1 items-center justify-center gap-1.5 rounded-md text-xs transition-colors",
isSelected
? "bg-accent border-accent-foreground/20"
: "bg-background hover:bg-accent/50 border-input"
? "bg-background font-medium text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<div className="flex items-center gap-2">
<Icon className="h-3.5 w-3.5" />
<span>{option.label}</span>
</div>
<Info className="h-3 w-3 text-muted-foreground opacity-50 group-hover:opacity-100 transition-opacity hidden sm:inline-block" />
<Icon className="h-3 w-3" />
{option.label}
</button>
</TooltipTrigger>
<TooltipContent>
+75 -82
View File
@@ -1,7 +1,5 @@
import React from "react";
import { Card } from "@/components/ui/card";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Info, GitBranch, FolderTree, Star, Building2, User, Building } from "lucide-react";
import { CircleCheck, Info, GitBranch, FolderTree, Star, Building2, User } from "lucide-react";
import {
HoverCard,
HoverCardContent,
@@ -291,13 +289,9 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex-1">
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<Building className="h-4 w-4" />
Organization Strategy
</h4>
<p className="text-xs text-muted-foreground">
Choose how your repositories will be organized in Gitea
</p>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Organization strategy
</span>
</div>
<div className="flex-shrink-0">
@@ -363,84 +357,83 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
</div>
</div>
<RadioGroup value={strategy} onValueChange={onStrategyChange}>
<div className="grid grid-cols-1 2xl:grid-cols-2 gap-4">
{(Object.entries(strategyConfig) as [MirrorStrategy, typeof strategyConfig.preserve][]).map(([key, config]) => {
const isSelected = strategy === key;
const Icon = config.icon;
<div className="grid grid-cols-1 2xl:grid-cols-2 gap-3" role="radiogroup">
{(Object.entries(strategyConfig) as [MirrorStrategy, typeof strategyConfig.preserve][]).map(([key, config]) => {
const isSelected = strategy === key;
const Icon = config.icon;
return (
<div key={key}>
<label htmlFor={key} className="cursor-pointer">
<Card
return (
<button
key={key}
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => onStrategyChange(key)}
className={cn(
"flex items-start gap-3 rounded-lg border p-3.5 text-left transition-colors",
isSelected
? "border-indigo-500 bg-indigo-500/10"
: "border-border hover:border-muted-foreground/40"
)}
>
<span
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg",
isSelected
? "bg-indigo-500/20 text-indigo-400"
: "bg-muted text-muted-foreground"
)}
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="flex-1 min-w-0 space-y-1">
<span className="flex items-center gap-1.5">
<span
className={cn(
"relative",
isSelected && `${config.borderColor} border-2`,
!isSelected && "border-muted"
"text-[13px] font-medium leading-none",
isSelected ? "text-foreground" : "text-muted-foreground"
)}
>
<div className="p-3 sm:p-4">
<div className="flex items-start gap-3">
<RadioGroupItem
value={key}
id={key}
className="mt-1"
{config.title}
</span>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span
className="inline-flex cursor-help text-muted-foreground/50 hover:text-muted-foreground"
onClick={(e) => e.stopPropagation()}
>
<Info className="h-3.5 w-3.5" />
</span>
</HoverCardTrigger>
<HoverCardContent side="left" align="center" className="w-[500px]">
<div className="space-y-3">
<h4 className="font-medium text-sm">Repository Mapping Preview</h4>
<MappingPreview
strategy={key}
config={config}
destinationOrg={destinationOrg}
starredReposOrg={starredReposOrg}
starredReposMode={starredReposMode}
githubUsername={githubUsername}
giteaUsername={giteaUsername}
/>
<div className={cn(
"rounded-lg p-2 flex-shrink-0",
isSelected ? config.bgColor : "bg-muted dark:bg-muted/50"
)}>
<Icon className={cn(
"h-4 w-4",
isSelected ? config.color : "text-muted-foreground dark:text-muted-foreground/70"
)} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<h4 className="font-medium text-sm">{config.title}</h4>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{config.description}
</p>
</div>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span
className="inline-flex p-1 sm:p-1.5 hover:bg-muted rounded-md transition-colors cursor-help flex-shrink-0 ml-2"
onClick={(e) => e.stopPropagation()}
>
<Info className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
</span>
</HoverCardTrigger>
<HoverCardContent side="left" align="center" className="w-[500px]">
<div className="space-y-3">
<h4 className="font-medium text-sm">Repository Mapping Preview</h4>
<MappingPreview
strategy={key}
config={config}
destinationOrg={destinationOrg}
starredReposOrg={starredReposOrg}
starredReposMode={starredReposMode}
githubUsername={githubUsername}
giteaUsername={giteaUsername}
/>
</div>
</HoverCardContent>
</HoverCard>
</div>
</div>
</div>
</div>
</Card>
</label>
</div>
);
})}
</div>
</RadioGroup>
</HoverCardContent>
</HoverCard>
</span>
<span className="block text-[11px] leading-relaxed text-muted-foreground">
{config.description}
</span>
</span>
{isSelected && (
<CircleCheck className="h-4 w-4 shrink-0 text-indigo-500" />
)}
</button>
);
})}
</div>
</div>
);
};
+86 -81
View File
@@ -8,13 +8,14 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { apiRequest, showErrorToast } from '@/lib/utils';
import { toast } from 'sonner';
import { Plus, Trash2, Loader2, AlertCircle, Shield, Edit2 } from 'lucide-react';
import { Plus, Trash2, Loader2, AlertCircle, Info, KeyRound, Shield, ShieldCheck, Edit2 } from 'lucide-react';
import { Skeleton } from '../ui/skeleton';
import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { MultiSelect } from '@/components/ui/multi-select';
import { withBase } from '@/lib/base-path';
import { SwitchRow, accentSwitch } from './settings-ui';
function isTrustedIssuer(issuer: string, allowedHosts: string[]): boolean {
try {
@@ -289,91 +290,75 @@ export function SSOSettings() {
}
return (
<div className="space-y-6">
{/* Header with status indicators */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold">Authentication & SSO</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure how users authenticate with your application
</p>
<div className="grid grid-cols-1 items-stretch gap-6 lg:grid-cols-2">
{/* Authentication Methods Overview */}
<div className="flex flex-col overflow-hidden rounded-xl border border-border bg-card">
<div className="flex items-center gap-3 px-6 py-4">
<KeyRound className="h-5 w-5 text-muted-foreground" />
<h3 className="text-base font-semibold">Sign-in Methods</h3>
</div>
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${providers.length > 0 ? 'bg-green-500' : 'bg-muted'}`} />
<span className="text-sm text-muted-foreground">
{providers.length} Provider{providers.length !== 1 ? 's' : ''} configured
</span>
<div className="border-t border-border" />
<div className="flex-1 divide-y divide-border p-6">
{/* These methods are not toggleable from the UI, so the switches
reflect real state and explain where each is controlled. */}
<SwitchRow
className="py-4 first:pt-0 last:pb-0"
label="Email & Password"
badge="DEFAULT"
description="Built-in accounts, always available"
info="Always enabled so you can never lock yourself out."
checked
disabled
onCheckedChange={() => {}}
/>
<SwitchRow
className="py-4 first:pt-0 last:pb-0"
label="Single Sign-On (OIDC)"
description="Sign in through an external identity provider"
info={
providers.length > 0
? `${providers.length} provider${providers.length !== 1 ? 's' : ''} configured`
: "Turns on automatically when you add an identity provider."
}
checked={providers.length > 0}
disabled
onCheckedChange={() => {}}
/>
<SwitchRow
className="py-4 first:pt-0 last:pb-0"
label="Header Authentication"
description="Trust identity headers from your reverse proxy"
info="Controlled by the HEADER_AUTH environment variables. See the environment variable docs."
checked={headerAuthEnabled}
disabled
onCheckedChange={() => {}}
/>
</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">
<Shield className="h-3.5 w-3.5" />
Only enable header auth behind a trusted reverse proxy
</div>
</div>
{/* Authentication Methods Overview */}
<Card>
<CardHeader>
<CardTitle className="text-lg font-semibold">Active Authentication Methods</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Email & Password - Always enabled */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm font-medium">Email & Password</span>
<Badge variant="secondary" className="text-xs">Default</Badge>
</div>
<span className="text-xs text-muted-foreground">Always enabled</span>
</div>
{/* Header Authentication Status */}
{headerAuthEnabled && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm font-medium">Header Authentication</span>
<Badge variant="secondary" className="text-xs">Auto-login</Badge>
</div>
<span className="text-xs text-muted-foreground">Via reverse proxy</span>
</div>
)}
{/* SSO Providers Status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${providers.length > 0 ? 'bg-green-500' : 'bg-muted'}`} />
<span className="text-sm font-medium">SSO/OIDC Providers</span>
</div>
<span className="text-xs text-muted-foreground">
{providers.length > 0 ? `${providers.length} provider${providers.length !== 1 ? 's' : ''} configured` : 'Not configured'}
</span>
</div>
</div>
{/* Header Auth Info */}
{headerAuthEnabled && (
<Alert className="mt-4">
<Shield className="h-4 w-4" />
<AlertDescription className="text-xs">
Header authentication is enabled. Users authenticated by your reverse proxy will be automatically logged in.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* SSO Providers */}
<Card>
<Card className="flex h-full flex-col">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg font-semibold">External Identity Providers</CardTitle>
<CardDescription className="text-sm">
Connect external OIDC/OAuth providers (Google, Azure AD, etc.) to allow users to sign in with their existing accounts
</CardDescription>
<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>
</div>
<Dialog open={showProviderDialog} onOpenChange={setShowProviderDialog}>
<DialogTrigger asChild>
<Button>
<Button size="sm" className="bg-indigo-500 text-white hover:bg-indigo-600">
<Plus className="h-4 w-4 mr-2" />
Add Provider
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">
@@ -385,11 +370,21 @@ export function SSOSettings() {
: 'Configure an external identity provider for user authentication'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-1 -mx-1">
<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="grid w-full grid-cols-2 sticky top-0 z-10 bg-background">
<TabsTrigger value="oidc">OIDC / OAuth2</TabsTrigger>
<TabsTrigger value="saml">SAML 2.0</TabsTrigger>
<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 */}
@@ -513,6 +508,7 @@ export function SSOSettings() {
id="pkce"
checked={providerForm.pkce}
onCheckedChange={(checked) => setProviderForm(prev => ({ ...prev, pkce: checked }))}
className={accentSwitch}
/>
<Label htmlFor="pkce">Enable PKCE</Label>
</div>
@@ -609,7 +605,11 @@ export function SSOSettings() {
>
Cancel
</Button>
<Button onClick={createProvider} disabled={addingProvider}>
<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" />
@@ -624,7 +624,7 @@ export function SSOSettings() {
</Dialog>
</div>
</CardHeader>
<CardContent>
<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">
@@ -725,6 +725,11 @@ export function SSOSettings() {
</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>
</Card>
</div>
);
@@ -2,6 +2,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "../ui/checkbox";
import type { ScheduleConfig } from "@/types/config";
import { formatDate } from "@/lib/utils";
import { useTimeFormat } from "@/hooks/useTimeFormat";
import {
Select,
SelectContent,
@@ -24,6 +25,9 @@ export function ScheduleConfigForm({
onAutoSave,
isAutoSaving = false,
}: ScheduleConfigFormProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
+341
View File
@@ -0,0 +1,341 @@
import type { ComponentType, ReactNode } from "react";
type IconComponent = ComponentType<{ className?: string }>;
import { CircleCheck, Info } from "lucide-react";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
/** Indigo accent used across the redesigned settings surfaces. */
export const accentSwitch =
"data-[state=checked]:bg-indigo-500 dark:data-[state=checked]:bg-indigo-500";
interface SettingsCardProps {
icon: IconComponent;
title: string;
/** Renders a switch in the header; the card is toggled as a whole. */
enabled?: boolean;
onEnabledChange?: (enabled: boolean) => void;
/** Custom element on the right side of the header (e.g. a Test button). */
headerAction?: ReactNode;
footer?: ReactNode;
className?: string;
children: ReactNode;
}
export function SettingsCard({
icon: Icon,
title,
enabled,
onEnabledChange,
headerAction,
footer,
className,
children,
}: SettingsCardProps) {
return (
<div
className={cn(
"flex flex-col rounded-xl border border-border bg-card overflow-hidden",
className
)}
>
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<h3 className="text-base font-semibold">{title}</h3>
</div>
<div className="flex items-center gap-3">
{headerAction}
{onEnabledChange && (
<Switch
checked={enabled}
onCheckedChange={onEnabledChange}
className={accentSwitch}
aria-label={`Enable ${title.toLowerCase()}`}
/>
)}
</div>
</div>
<div className="border-t border-border" />
<div className="flex-1 flex flex-col">{children}</div>
{footer && (
<>
<div className="border-t border-border" />
<div className="flex items-center justify-between gap-4 px-6 py-3.5">
{footer}
</div>
</>
)}
</div>
);
}
export function SectionTitle({
children,
badge,
action,
}: {
children: ReactNode;
badge?: string;
action?: ReactNode;
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{children}
</span>
{badge && (
<span className="rounded-full bg-indigo-500/10 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-indigo-500">
{badge}
</span>
)}
</div>
{action}
</div>
);
}
export function InfoHint({ content }: { content: ReactNode }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help text-muted-foreground/50 hover:text-muted-foreground">
<Info className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{content}</TooltipContent>
</Tooltip>
);
}
interface OptionRowProps {
icon?: IconComponent;
label: string;
description?: string;
info?: ReactNode;
badge?: string;
/** Control rendered on the right (switch, pill, input...). */
right?: ReactNode;
className?: string;
}
export function OptionRow({
icon: Icon,
label,
description,
info,
badge,
right,
className,
}: OptionRowProps) {
return (
<div className={cn("flex items-center gap-4", className)}>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
{Icon && <Icon className="h-3.5 w-3.5 text-muted-foreground" />}
<span className="text-sm font-medium leading-none">{label}</span>
{badge && (
<span className="rounded-full bg-indigo-500/10 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-indigo-500">
{badge}
</span>
)}
{info && <InfoHint content={info} />}
</div>
{description && (
<p className="text-[13px] text-muted-foreground">{description}</p>
)}
</div>
{right}
</div>
);
}
/** Switch row shorthand used by most option lists. */
export function SwitchRow(
props: Omit<OptionRowProps, "right"> & {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
/** Extra element rendered between text and switch (e.g. "Latest 10"). */
extra?: ReactNode;
}
) {
const { checked, onCheckedChange, disabled, extra, ...rest } = props;
return (
<OptionRow
{...rest}
right={
<div className="flex items-center gap-3">
{extra}
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
className={accentSwitch}
aria-label={rest.label}
/>
</div>
}
/>
);
}
interface OptionTileProps {
icon: IconComponent;
label: string;
description: string;
selected: boolean;
onSelect: () => void;
info?: ReactNode;
disabled?: boolean;
}
/** Selection tile ("variant C"): icon chip, label with info, description,
* and a check mark only on the selected tile. */
export function OptionTile({
icon: Icon,
label,
description,
selected,
onSelect,
info,
disabled,
}: OptionTileProps) {
return (
<button
type="button"
onClick={onSelect}
disabled={disabled}
aria-pressed={selected}
className={cn(
"flex flex-1 items-start gap-3 rounded-lg border p-3.5 text-left transition-colors",
selected
? "border-indigo-500 bg-indigo-500/10"
: "border-border hover:border-muted-foreground/40",
disabled && "opacity-50 cursor-not-allowed"
)}
>
<span
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg",
selected
? "bg-indigo-500/20 text-indigo-400"
: "bg-muted text-muted-foreground"
)}
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="flex-1 min-w-0 space-y-1">
<span className="flex items-center gap-1.5">
<span
className={cn(
"text-[13px] font-medium leading-none",
selected ? "text-foreground" : "text-muted-foreground"
)}
>
{label}
</span>
{info && <InfoHint content={info} />}
</span>
<span className="block text-[11px] leading-relaxed text-muted-foreground">
{description}
</span>
</span>
{selected && (
<CircleCheck className="h-4 w-4 shrink-0 text-indigo-500" />
)}
</button>
);
}
interface SegmentedControlProps<T extends string> {
options: { value: T; label: string; icon?: IconComponent }[];
value: T;
onChange: (value: T) => void;
className?: string;
}
export function SegmentedControl<T extends string>({
options,
value,
onChange,
className,
}: SegmentedControlProps<T>) {
return (
<div
className={cn(
"flex w-full gap-1 rounded-lg bg-muted p-1",
className
)}
role="tablist"
>
{options.map((opt) => {
const active = opt.value === value;
const OptIcon = opt.icon;
return (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange(opt.value)}
className={cn(
"flex h-8 flex-1 items-center justify-center gap-1.5 rounded-md text-xs transition-colors",
active
? "bg-background font-medium text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{OptIcon && <OptIcon className="h-3 w-3" />}
{opt.label}
</button>
);
})}
</div>
);
}
export function StatusFooterItem({
icon: Icon,
label,
value,
valueClassName,
}: {
icon: IconComponent;
label: string;
value?: ReactNode;
valueClassName?: string;
}) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground/70">
<Icon className="h-3.5 w-3.5" />
<span>{label}</span>
{value !== undefined && (
<span className={cn("font-medium text-muted-foreground", valueClassName)}>
{value}
</span>
)}
</div>
);
}
/** Divider between sections inside a SettingsCard. */
export function CardDivider() {
return <div className="border-t border-border" />;
}
/** Standard padded section body inside a SettingsCard. */
export function CardSection({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={cn("space-y-4 p-6", className)}>{children}</div>;
}
+7 -9
View File
@@ -17,6 +17,8 @@ import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
import { withBase } from "@/lib/base-path";
import { formatShortDateTime } from "@/lib/utils/time-format";
import { useTimeFormat } from "@/hooks/useTimeFormat";
// Helper function to format last sync time
function formatLastSyncTime(date: Date | null): string {
@@ -45,17 +47,11 @@ function formatLastSyncTime(date: Date | null): string {
}
// Helper function to format full timestamp
// Locale-aware and respects the user's 12h/24h time format preference.
function formatFullTimestamp(date: Date | null): string {
if (!date) return "";
return new Date(date).toLocaleString("en-US", {
month: "2-digit",
day: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: true
}).replace(',', '');
return formatShortDateTime(date).replace(',', '');
}
export function Dashboard() {
@@ -64,6 +60,8 @@ export function Dashboard() {
const isPageVisible = usePageVisibility();
const { isFullyConfigured } = useConfigStatus();
const { navigationKey } = useNavigation();
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
const [repositories, setRepositories] = useState<Repository[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
+51 -21
View File
@@ -1,21 +1,52 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { MirrorJob } from "@/lib/db/schema";
import { formatDate, getStatusColor } from "@/lib/utils";
import { formatDate } from "@/lib/utils";
import { Button } from "../ui/button";
import { Activity, Clock } from "lucide-react";
import {
Activity,
CircleCheck,
Clock,
RefreshCw,
Sparkles,
TriangleAlert,
} from "lucide-react";
import { withBase } from "@/lib/base-path";
import { useTimeFormat } from "@/hooks/useTimeFormat";
interface RecentActivityProps {
activities: MirrorJob[];
}
function activityIcon(status: MirrorJob["status"]) {
switch (status) {
case "mirrored":
case "synced":
return { Icon: CircleCheck, color: "text-green-500" };
case "mirroring":
case "syncing":
return { Icon: RefreshCw, color: "text-indigo-400" };
case "imported":
return { Icon: Sparkles, color: "text-muted-foreground" };
case "failed":
return { Icon: TriangleAlert, color: "text-red-500" };
default:
return { Icon: Activity, color: "text-muted-foreground" };
}
}
export function RecentActivity({ activities }: RecentActivityProps) {
// Re-render timestamps when the user changes the 12h/24h preference.
useTimeFormat();
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent Activity</CardTitle>
<Button variant="outline" asChild>
<a href={withBase("/activity")}>View All</a>
<CardTitle className="flex items-center gap-3 text-base font-semibold">
<Activity className="h-5 w-5 text-muted-foreground" />
Recent Activity
</CardTitle>
<Button variant="ghost" size="sm" asChild className="text-indigo-500 hover:text-indigo-600">
<a href={withBase("/activity")}>View all</a>
</Button>
</CardHeader>
<CardContent>
@@ -37,25 +68,24 @@ export function RecentActivity({ activities }: RecentActivityProps) {
</div>
) : (
<div className="flex flex-col divide-y divide-border">
{activities.map((activity, index) => (
<div 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(
activity.status
)}`}
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">
{activity.message}
{activities.map((activity, index) => {
const { Icon, color } = activityIcon(activity.status);
return (
<div key={index} className="flex items-center gap-x-3 py-3">
<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>
<div className="text-xs text-muted-foreground mt-1">
{formatDate(activity.timestamp)}
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium">
{activity.message}
</div>
<div className="text-xs text-muted-foreground mt-1">
{formatDate(activity.timestamp)}
</div>
</div>
</div>
</div>
))}
);
})}
</div>
)}
</CardContent>
+6 -3
View File
@@ -41,9 +41,12 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Repositories</CardTitle>
<Button variant="outline" asChild>
<a href={withBase("/repositories")}>View All</a>
<CardTitle className="flex items-center gap-3 text-base font-semibold">
<GitFork className="h-5 w-5 text-muted-foreground" />
Repositories
</CardTitle>
<Button variant="ghost" size="sm" asChild className="text-indigo-500 hover:text-indigo-600">
<a href={withBase("/repositories")}>View all</a>
</Button>
</CardHeader>
<CardContent>
+17 -13
View File
@@ -1,4 +1,3 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface StatusCardProps {
@@ -17,17 +16,22 @@ export function StatusCard({
className,
}: StatusCardProps) {
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<div className="h-4 w-4 text-muted-foreground">{icon}</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
)}
</CardContent>
</Card>
<div
className={cn(
"flex flex-col gap-2.5 rounded-xl border border-border bg-card p-5",
className
)}
>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
{title}
</span>
<span className="text-muted-foreground/60">{icon}</span>
</div>
<div className="text-2xl font-semibold leading-none">{value}</div>
{description && (
<p className="text-xs text-muted-foreground/80">{description}</p>
)}
</div>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { ModeToggle } from "@/components/theme/ModeToggle";
import { TimeFormatToggle } from "@/components/layout/TimeFormatToggle";
import { Skeleton } from "@/components/ui/skeleton";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus";
@@ -125,7 +125,7 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
</Button>
)}
<ModeToggle />
<TimeFormatToggle />
{isLoading ? <AuthButtonsSkeleton /> : <AccountMenu />}
</div>
+13 -2
View File
@@ -3,6 +3,7 @@ import { cn } from "@/lib/utils";
import { ExternalLink } from "lucide-react";
import { links } from "@/data/Sidebar";
import { VersionInfo } from "./VersionInfo";
import { ThemeSwitcher } from "@/components/theme/ThemeSwitcher";
import {
Tooltip,
TooltipContent,
@@ -164,7 +165,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
Check out the documentation for help with setup and configuration.
</p>
<a
href={withBase("/docs")}
href="https://gitea-mirror.raylabs.io/docs/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs md:text-xs text-primary hover:underline py-2 md:py-0"
@@ -178,7 +179,7 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<a
href={withBase("/docs")}
href="https://gitea-mirror.raylabs.io/docs/"
target="_blank"
rel="noopener noreferrer"
className={cn(
@@ -196,6 +197,16 @@ export function Sidebar({ className, onNavigate, isOpen, isCollapsed = false, on
</TooltipProvider>
</div>
<div className={cn(
"mt-3 flex items-center justify-between",
isCollapsed ? "md:hidden xl:flex" : "flex"
)}>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/70">
Theme
</span>
<ThemeSwitcher />
</div>
<div className={cn(
"mt-1",
isCollapsed ? "md:hidden xl:block" : "block"
)}>
<VersionInfo />
+128
View File
@@ -0,0 +1,128 @@
import * as React from "react";
import { Clock, CircleCheck, Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useTimeFormat } from "@/hooks/useTimeFormat";
import type { TimeFormatPreference } from "@/lib/utils/time-format";
function formatNow(now: Date, preference: TimeFormatPreference): string {
const options: Intl.DateTimeFormatOptions = {
hour: "numeric",
minute: "2-digit",
};
if (preference === "12h") options.hour12 = true;
if (preference === "24h") options.hour12 = false;
return new Intl.DateTimeFormat(undefined, options).format(now);
}
export function TimeFormatToggle() {
const { preference, setPreference } = useTimeFormat();
const [now, setNow] = React.useState(() => new Date());
const locale =
typeof navigator !== "undefined" ? navigator.language : undefined;
// Keep the trigger clock fresh.
React.useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 30_000);
return () => clearInterval(timer);
}, []);
const options: {
value: TimeFormatPreference;
label: string;
example: string;
mono: boolean;
}[] = [
{
value: "auto",
label: "Auto",
example: formatNow(now, "auto"),
mono: true,
},
{ value: "12h", label: "12-hour", example: formatNow(now, "12h"), mono: true },
{ value: "24h", label: "24-hour", example: formatNow(now, "24h"), mono: true },
];
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="lg"
className="gap-2 px-3"
title="Time format"
>
<Clock className="h-[1.1rem] w-[1.1rem]" />
<span className="font-mono text-xs text-muted-foreground">
{formatNow(now, preference)}
</span>
<span className="sr-only">Toggle time format</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-70 p-1.5">
<DropdownMenuLabel className="flex items-center justify-between gap-3">
<span className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
Time format
</span>
{locale && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-[11px] font-normal text-muted-foreground">
<Globe className="h-3 w-3" />
{locale}
</span>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{options.map((option) => {
const selected = preference === option.value;
return (
<DropdownMenuItem
key={option.value}
onClick={() => setPreference(option.value)}
className={cn(
"gap-2.5 rounded-lg px-2.5 py-2.5",
selected && "bg-muted/60"
)}
>
<div className="flex flex-1 items-center gap-2">
{option.value === "auto" && (
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span
className={cn(
"text-[13px] leading-none",
selected && "font-semibold"
)}
>
{option.label}
</span>
</div>
<span
className={cn(
"text-xs text-muted-foreground",
option.mono && "font-mono"
)}
>
{option.example}
</span>
<CircleCheck
className={cn(
"h-4 w-4 text-indigo-500",
selected ? "opacity-100" : "opacity-0"
)}
/>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
+11 -10
View File
@@ -26,6 +26,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { InlineDestinationEditor } from "./InlineDestinationEditor";
import { MarqueeText, MarqueeTrigger } from "@/components/ui/marquee-text";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { withBase } from "@/lib/base-path";
@@ -755,29 +756,29 @@ export default function RepositoryTable({
</div>
{/* Repository */}
<div className="h-full py-3 flex items-center gap-2 flex-[2.3]">
<div className="flex-1">
<div className="font-medium flex items-center gap-1">
{repo.name}
<MarqueeTrigger className="h-full py-3 flex items-center gap-2 flex-[2.3] min-w-0">
<div className="flex-1 min-w-0">
<div className="font-medium flex items-center gap-1 min-w-0">
<MarqueeText>{repo.name}</MarqueeText>
{repo.isStarred && (
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
<Star className="h-3 w-3 shrink-0 fill-yellow-500 text-yellow-500" />
)}
</div>
<div className="text-xs text-muted-foreground">
<MarqueeText className="text-xs text-muted-foreground">
{repo.fullName}
</div>
</MarqueeText>
</div>
{repo.isPrivate && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
<span className="ml-2 shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs">
Private
</span>
)}
{repo.isForked && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
<span className="ml-2 shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs">
Fork
</span>
)}
</div>
</MarqueeTrigger>
{/* Owner */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">{repo.owner}</p>
-52
View File
@@ -1,52 +0,0 @@
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ModeToggle() {
const [theme, setThemeState] = React.useState<"light" | "dark" | "system">(
"light"
);
React.useEffect(() => {
const isDarkMode = document.documentElement.classList.contains("dark");
setThemeState(isDarkMode ? "dark" : "light");
}, []);
React.useEffect(() => {
const isDark =
theme === "dark" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList[isDark ? "add" : "remove"]("dark");
}, [theme]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg" className="has-[>svg]:px-3">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setThemeState("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setThemeState("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+4 -10
View File
@@ -22,19 +22,13 @@ const runtimeBasePath = normalizeBasePath(process.env.BASE_URL);
window[BASE_PATH_WINDOW_KEY] = runtimeBasePath;
const getThemePreference = () => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
if (typeof localStorage !== 'undefined') {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') return stored;
}
// "system" or nothing stored: follow the OS.
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const isDark = getThemePreference() === 'dark';
document.documentElement.classList[isDark ? 'add' : 'remove']('dark');
if (typeof localStorage !== 'undefined') {
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
}
</script>
+93
View File
@@ -0,0 +1,93 @@
import * as React from "react";
import { Moon, Sun, Monitor } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type ThemePreference = "light" | "dark" | "system";
const OPTIONS: { value: ThemePreference; label: string; icon: typeof Sun }[] = [
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
{ value: "system", label: "System", icon: Monitor },
];
function resolveIsDark(preference: ThemePreference): boolean {
if (preference === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
return preference === "dark";
}
/** Icon-only segmented theme control for the sidebar. Persists the raw
* preference (including "system") so it survives reloads. */
export function ThemeSwitcher({ className }: { className?: string }) {
const [preference, setPreference] = React.useState<ThemePreference>("system");
React.useEffect(() => {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark" || stored === "system") {
setPreference(stored);
}
}, []);
React.useEffect(() => {
document.documentElement.classList[
resolveIsDark(preference) ? "add" : "remove"
]("dark");
if (preference !== "system") return;
// Follow OS changes live while "system" is selected.
const media = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () =>
document.documentElement.classList[media.matches ? "add" : "remove"]("dark");
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, [preference]);
const select = (value: ThemePreference) => {
setPreference(value);
localStorage.setItem("theme", value);
};
return (
<div
className={cn("flex gap-0.5 rounded-lg bg-muted p-0.5", className)}
role="radiogroup"
aria-label="Theme"
>
<TooltipProvider>
{OPTIONS.map((option) => {
const Icon = option.icon;
const active = preference === option.value;
return (
<Tooltip key={option.value} delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
role="radio"
aria-checked={active}
aria-label={option.label}
onClick={() => select(option.value)}
className={cn(
"flex h-6.5 w-7.5 items-center justify-center rounded-md transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{option.label}</TooltipContent>
</Tooltip>
);
})}
</TooltipProvider>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import {
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
const MarqueeHoverContext = createContext<boolean | null>(null);
/**
* Shared hover target: every MarqueeText inside starts scrolling when the
* pointer is anywhere over this element, so the hit area isn't limited to
* the text itself.
*/
export function MarqueeTrigger({
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
const [hovering, setHovering] = useState(false);
return (
<div
{...props}
className={className}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
<MarqueeHoverContext.Provider value={hovering}>
{children}
</MarqueeHoverContext.Provider>
</div>
);
}
interface MarqueeTextProps {
children: React.ReactNode;
className?: string;
/** Scroll speed in px/s while hovered */
speed?: number;
}
/**
* Single-line text that truncates with an ellipsis and scrolls the hidden
* portion into view on hover (sliding back on mouse leave). The scroll is a
* compositor-driven transform for smoothness; the content stays plain inline
* text while at rest because native ellipsis rendering doesn't work on the
* inline-block wrapper the transform needs.
*/
export function MarqueeText({ children, className, speed = 60 }: MarqueeTextProps) {
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLSpanElement>(null);
const groupHovering = useContext(MarqueeHoverContext);
const [selfHovering, setSelfHovering] = useState(false);
const hovering = groupHovering ?? selfHovering;
const [shift, setShift] = useState(0);
const [scrolling, setScrolling] = useState(false);
const [resting, setResting] = useState(true);
useEffect(() => {
if (hovering) {
const container = containerRef.current;
if (!container) return;
// Measured on every hover so virtualized rows and resizes stay accurate
const overflow = Math.max(0, container.scrollWidth - container.clientWidth);
if (overflow === 0) return;
setShift(overflow);
setResting(false);
// Start the transform a frame after the inline-block swap commits so
// the transition animates instead of jumping to the end position
const raf = requestAnimationFrame(() =>
requestAnimationFrame(() => setScrolling(true))
);
return () => cancelAnimationFrame(raf);
}
setScrolling(false);
// If the text never actually moved (left before the start delay ended),
// no transition runs and no transitionend will fire — rest immediately
const content = contentRef.current;
const transform = content ? getComputedStyle(content).transform : "none";
if (transform === "none" || transform === "matrix(1, 0, 0, 1, 0, 0)") {
setResting(true);
}
}, [hovering]);
return (
<div
ref={containerRef}
onMouseEnter={groupHovering === null ? () => setSelfHovering(true) : undefined}
onMouseLeave={groupHovering === null ? () => setSelfHovering(false) : undefined}
className={cn("min-w-0 overflow-hidden whitespace-nowrap", className)}
style={{ textOverflow: resting ? "ellipsis" : "clip" }}
>
<span
ref={contentRef}
onTransitionEnd={(e) => {
if (e.propertyName === "transform" && !scrolling) setResting(true);
}}
style={
resting
? undefined
: {
display: "inline-block",
willChange: "transform",
transform: scrolling ? `translateX(-${shift}px)` : "translateX(0)",
transition: scrolling
? `transform ${shift / speed}s linear 0.3s`
: "transform 0.3s ease-out",
}
}
>
{children}
</span>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useCallback, useSyncExternalStore } from "react";
import {
getTimeFormatPreference,
setTimeFormatPreference,
subscribeToTimeFormatChange,
type TimeFormatPreference,
} from "@/lib/utils/time-format";
const getServerSnapshot = (): TimeFormatPreference => "auto";
/**
* Subscribe a component to the user's 12h/24h time format preference.
*
* Any component that renders clock times should call this hook (even if it
* only needs the re-render) so timestamps update immediately when the user
* changes the preference — navigation is SPA-style, so components stay
* mounted across pages.
*/
export function useTimeFormat() {
const preference = useSyncExternalStore(
subscribeToTimeFormatChange,
getTimeFormatPreference,
getServerSnapshot
);
const setPreference = useCallback((next: TimeFormatPreference) => {
setTimeFormatPreference(next);
}, []);
return { preference, setPreference };
}
+1 -1
View File
@@ -11,7 +11,7 @@ const { title = 'Gitea Mirror' } = Astro.props;
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href={withBase('/favicon.svg')} />
<title>{title}</title>
<ThemeScript />
+22 -5
View File
@@ -8,16 +8,33 @@ import { repairDuplicateSsoColumns, restoreSsoDataAfter0013 } from "./migration-
// Skip database initialization in test environment
let db: ReturnType<typeof drizzle>;
/**
* Resolve the SQLite file location from DATABASE_URL, falling back to the
* historical default of <cwd>/data/gitea-mirror.db. Accepts the documented
* sqlite:// scheme, the legacy file: scheme, or a plain path; relative
* paths resolve against the working directory.
*/
function resolveDbPath(): string {
const url = process.env.DATABASE_URL?.trim();
if (!url) {
return path.join(process.cwd(), "data", "gitea-mirror.db");
}
const raw = url.startsWith("sqlite://")
? url.slice("sqlite://".length)
: url.startsWith("file:")
? url.slice("file:".length)
: url;
return path.resolve(raw);
}
if (process.env.NODE_ENV !== "test") {
// Define the database URL - for development we'll use a local SQLite file
const dataDir = path.join(process.cwd(), "data");
// Ensure data directory exists
const dbPath = resolveDbPath();
const dataDir = path.dirname(dbPath);
// Ensure the directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, "gitea-mirror.db");
// Create an empty database file if it doesn't exist
if (!fs.existsSync(dbPath)) {
fs.writeFileSync(dbPath, "");
+14 -1
View File
@@ -138,14 +138,27 @@ export const appriseConfigSchema = z.object({
tag: z.string().optional(),
});
export const gotifyConfigSchema = z.object({
url: z.string().default(""),
token: z.string().default(""),
priority: z.number().int().min(0).max(10).default(5),
});
export const webhookConfigSchema = z.object({
url: z.string().default(""),
secret: z.string().optional(),
});
export const notificationConfigSchema = z.object({
enabled: z.boolean().default(false),
provider: z.enum(["ntfy", "apprise"]).default("ntfy"),
provider: z.enum(["ntfy", "apprise", "gotify", "webhook"]).default("ntfy"),
notifyOnSyncError: z.boolean().default(true),
notifyOnSyncSuccess: z.boolean().default(false),
notifyOnNewRepo: z.boolean().default(false),
ntfy: ntfyConfigSchema.optional(),
apprise: appriseConfigSchema.optional(),
gotify: gotifyConfigSchema.optional(),
webhook: webhookConfigSchema.optional(),
});
export type NotificationConfig = z.infer<typeof notificationConfigSchema>;
+245
View File
@@ -0,0 +1,245 @@
/**
* Unit tests for archiveGiteaRepo's return value and sanitizeRepoNameAlphaDashDot —
* regression coverage for #331's follow-up (repos falsely flagged as orphaned
* and archived, then unreachable by "Manual Sync" because the DB's
* mirroredLocation/name were never updated to the post-rename name).
*
* archiveGiteaRepo now reports the Gitea-side name it ended up with after a
* rename (mirror path) so callers (repository-cleanup-service.ts) can persist
* it, instead of leaving the DB pointing at a name that no longer exists.
*/
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
const mockHttpGet = mock(async (_url: string, _headers?: any) => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const mockHttpPatch = mock(async (_url: string, _body?: any, _headers?: any) => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const mockHttpPost = mock(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const mockHttpDelete = mock(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const mockHttpPut = mock(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
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,
httpPost: mockHttpPost,
httpDelete: mockHttpDelete,
httpPut: mockHttpPut,
HttpError: MockHttpError,
}));
import { archiveGiteaRepo, sanitizeRepoNameAlphaDashDot } from "./gitea";
describe("sanitizeRepoNameAlphaDashDot", () => {
test("replaces disallowed characters with a dash", () => {
expect(sanitizeRepoNameAlphaDashDot("my repo!")).toBe("my-repo");
});
test("collapses consecutive disallowed characters into a single dash", () => {
expect(sanitizeRepoNameAlphaDashDot("a___b")).toBe("a-b");
});
test("trims leading and trailing separators/dots", () => {
expect(sanitizeRepoNameAlphaDashDot("--.foo.--")).toBe("foo");
});
test("leaves an already-valid AlphaDashDot name unchanged", () => {
expect(sanitizeRepoNameAlphaDashDot("valid-repo.name")).toBe("valid-repo.name");
});
});
describe("archiveGiteaRepo", () => {
const client = { url: "https://gitea.example.com", token: "test-token" };
let originalConsoleLog: typeof console.log;
let originalConsoleWarn: typeof console.warn;
let originalConsoleError: typeof console.error;
let originalConsoleDebug: typeof console.debug;
beforeEach(() => {
mockHttpGet.mockClear();
mockHttpPatch.mockClear();
mockHttpPost.mockClear();
mockHttpDelete.mockClear();
// Reset to benign defaults; individual tests override with mockImplementationOnce/mockImplementation.
mockHttpGet.mockImplementation(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
mockHttpPatch.mockImplementation(async () => ({
data: {},
status: 200,
statusText: "OK",
headers: new Headers(),
}));
originalConsoleLog = console.log;
originalConsoleWarn = console.warn;
originalConsoleError = console.error;
originalConsoleDebug = console.debug;
console.log = mock(() => {});
console.warn = mock(() => {});
console.error = mock(() => {});
console.debug = mock(() => {});
});
afterEach(() => {
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
console.error = originalConsoleError;
console.debug = originalConsoleDebug;
});
test("mirror repo rename returns the new archived name", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "my-repo", mirror: true, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const result = await archiveGiteaRepo(client, "owner", "my-repo");
expect(result).toEqual({ archivedName: "archived-my-repo" });
// Rename PATCH + mirror-interval-disable PATCH
expect(mockHttpPatch).toHaveBeenCalledTimes(2);
const renameCall = mockHttpPatch.mock.calls[0];
expect(String(renameCall[0])).toContain("/api/v1/repos/owner/my-repo");
expect(renameCall[1]).toMatchObject({ name: "archived-my-repo" });
});
test("already-archived mirror repo returns the existing name without re-renaming", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "archived-my-repo", mirror: true, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const result = await archiveGiteaRepo(client, "owner", "archived-my-repo");
expect(result).toEqual({ archivedName: "archived-my-repo" });
expect(mockHttpPatch).not.toHaveBeenCalled();
});
test("non-mirror repo archives natively and returns archivedName: null", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "regular-repo", mirror: false, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
mockHttpPatch.mockImplementationOnce(async () => ({
data: { archived: true },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const result = await archiveGiteaRepo(client, "owner", "regular-repo");
expect(result).toEqual({ archivedName: null });
expect(mockHttpPatch).toHaveBeenCalledTimes(1);
expect(mockHttpPatch.mock.calls[0][1]).toMatchObject({ archived: true });
});
test("rename PATCH failure (primary and timestamped fallback both fail) returns archivedName: null", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "my-repo", mirror: true, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
mockHttpPatch.mockImplementation(async () => {
throw new MockHttpError("Unprocessable Entity", 422, "Unprocessable Entity");
});
const result = await archiveGiteaRepo(client, "owner", "my-repo");
expect(result).toEqual({ archivedName: null });
// Primary rename attempt + timestamped fallback attempt, no interval-disable call
expect(mockHttpPatch).toHaveBeenCalledTimes(2);
});
test("mirror repo rename recovers via timestamped fallback after a primary conflict", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: { name: "my-repo", mirror: true, description: "" },
status: 200,
statusText: "OK",
headers: new Headers(),
}));
let callCount = 0;
mockHttpPatch.mockImplementation(async (url: string, body?: any) => {
callCount++;
if (callCount === 1) {
// Primary rename attempt fails (e.g. AlphaDashDot conflict)
throw new MockHttpError("conflict", 422, "Unprocessable Entity");
}
// Fallback rename attempt and the interval-disable PATCH both succeed
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
});
const result = await archiveGiteaRepo(client, "owner", "my-repo");
expect(result.archivedName).toMatch(/^archived-\d{14}-my-repo$/);
expect(mockHttpPatch).toHaveBeenCalledTimes(3);
});
test("repository not found in Gitea returns archivedName: null", async () => {
mockHttpGet.mockImplementationOnce(async () => ({
data: null,
status: 200,
statusText: "OK",
headers: new Headers(),
}));
const result = await archiveGiteaRepo(client, "owner", "missing-repo");
expect(result).toEqual({ archivedName: null });
expect(mockHttpPatch).not.toHaveBeenCalled();
});
});
+380 -5
View File
@@ -19,15 +19,19 @@ const mockCreatePreSyncBundleBackup = mock(() =>
let mockShouldCreatePreSyncBackup = false;
let mockShouldBlockSyncOnBackupFailure = true;
// Mock the database module
// Mock the database module. Every db.update(...).set(payload) is captured in
// dbUpdateSetCalls so tests can assert on what got written (e.g. archived
// repos keeping status "archived" after a Manual Sync).
const dbUpdateSetCalls: any[] = [];
const mockDb = {
insert: mock((table: any) => ({
values: mock((data: any) => Promise.resolve({ insertedId: "mock-id" }))
})),
update: mock(() => ({
set: mock(() => ({
where: mock(() => Promise.resolve())
}))
set: mock((data: any) => {
dbUpdateSetCalls.push(data);
return { where: mock(() => Promise.resolve()) };
})
}))
};
@@ -173,10 +177,74 @@ const mockHttpGet = mock(async (url: string, headers?: any) => {
headers: new Headers(),
};
}
// Only reachable at the "archived-{name}" candidate — the base name
// ("starred/broken-repo") deliberately falls through to the generic 404
// below, simulating a repo that archiveGiteaRepo already renamed in Gitea.
// original_url matches the test repository's GitHub source, so the
// fallback candidate's source-identity guard accepts it.
if (url.includes("/api/v1/repos/starred/archived-broken-repo")) {
return {
data: {
id: 792,
name: "archived-broken-repo",
mirror: true,
owner: { login: "starred" },
mirror_interval: "0h",
original_url: "https://github.com/user/broken-repo.git",
private: false,
},
status: 200,
statusText: "OK",
headers: new Headers(),
};
}
// Collision scenario: this archived mirror belongs to a DIFFERENT GitHub
// source (otheruser/collide-repo) that happens to share the base name with
// the test repository (user/collide-repo). The base name
// ("starred/collide-repo") falls through to the generic 404 below, so the
// archived-{name} fallback candidate is the only match — and its
// original_url must cause the source-identity guard to reject it.
if (url.includes("/api/v1/repos/starred/archived-collide-repo")) {
return {
data: {
id: 793,
name: "archived-collide-repo",
mirror: true,
owner: { login: "starred" },
mirror_interval: "0h",
original_url: "https://github.com/otheruser/collide-repo.git",
private: false,
},
status: 200,
statusText: "OK",
headers: new Headers(),
};
}
// Simulates Forgejo silently following a 301 redirect for a renamed repo:
// a GET for the STALE (pre-rename) path returns 200 with the repo's
// CURRENT identity in the response body (name differs from what was
// requested), exactly as Bun's fetch behaves after following Forgejo's
// redirect for a repo renamed from "renamed-repo" to
// "archived-renamed-repo". See #331 follow-up / canonical-identity
// adoption in syncGiteaRepoEnhanced.
if (url.includes("/api/v1/repos/starred/renamed-repo")) {
return {
data: {
id: 891,
name: "archived-renamed-repo",
mirror: true,
owner: { login: "starred" },
private: false,
},
status: 200,
statusText: "OK",
headers: new Headers(),
};
}
if (url.includes("/api/v1/repos/")) {
throw new MockHttpError("Not Found", 404, "Not Found");
}
// Handle org GET requests based on test context
if (url.includes("/api/v1/orgs/starred")) {
orgCheckCount++;
@@ -239,10 +307,18 @@ const mockHttpDelete = mock(async (url: string, headers?: any) => {
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
});
// Observable so tests can assert that the mirror-interval PATCH is (not)
// issued — e.g. archived repos must never have Gitea's periodic pulling
// re-enabled by a Manual Sync.
const mockHttpPatch = mock(async (url: string, body?: any, headers?: any) => {
return { data: {}, status: 200, statusText: "OK", headers: new Headers() };
});
mock.module("@/lib/http-client", () => ({
httpGet: mockHttpGet,
httpPost: mockHttpPost,
httpDelete: mockHttpDelete,
httpPatch: mockHttpPatch,
HttpError: MockHttpError
}));
@@ -284,6 +360,8 @@ describe("Enhanced Gitea Operations", () => {
mockHttpGet.mockClear();
mockHttpPost.mockClear();
mockHttpDelete.mockClear();
mockHttpPatch.mockClear();
dbUpdateSetCalls.length = 0;
mockCreatePreSyncBundleBackup.mockClear();
mockCreatePreSyncBundleBackup.mockImplementation(() =>
Promise.resolve({ bundlePath: "/tmp/mock.bundle" })
@@ -612,6 +690,303 @@ describe("Enhanced Gitea Operations", () => {
expect(String(mirrorSyncCalls[0][0])).not.toContain("/api/v1/repos/ceph/test-repo/mirror-sync");
});
test("falls back to the archived-{name} candidate when repository.status is 'archived'", async () => {
// Regression for #331 follow-up: repos archived before mirroredLocation
// was backfilled on rename (or any lingering false-positive orphan hit)
// are unreachable by name/expected-owner alone once archiveGiteaRepo has
// renamed them in Gitea to `archived-{sanitized name}`. syncGiteaRepoEnhanced
// must still find them via "Manual Sync" without manual intervention.
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: false,
},
};
const repository: Repository = {
id: "repoArchived1",
name: "broken-repo",
fullName: "user/broken-repo",
owner: "user",
cloneUrl: "https://github.com/user/broken-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("archived"),
visibility: "public",
userId: "user123",
// No mirroredLocation recorded — this repo predates the DB backfill
// added alongside archiveGiteaRepo's new return value.
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(result).toEqual({ success: true });
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(1);
expect(String(mirrorSyncCalls[0][0])).toContain(
"/api/v1/repos/starred/archived-broken-repo/mirror-sync"
);
// The base (pre-archive) name must have been probed and rejected
// (404) before falling back to the archived-{name} candidate.
const repoInfoGets = mockHttpGet.mock.calls.filter((call) =>
String(call[0]).includes("/api/v1/repos/starred/")
);
expect(
repoInfoGets.some((call) =>
String(call[0]).endsWith("/api/v1/repos/starred/broken-repo")
)
).toBe(true);
expect(
repoInfoGets.some((call) =>
String(call[0]).endsWith("/api/v1/repos/starred/archived-broken-repo")
)
).toBe(true);
});
test("adopts canonical identity from response body when GET follows a stale-name redirect", async () => {
// Regression for #331 follow-up, verified end-to-end on Forgejo 15.0.3:
// when a repo has been renamed (e.g. by the orphan-archive flow, or
// manually by a user), Forgejo answers a GET for the OLD name with a
// 301 redirect to the new name. Bun's fetch follows it silently and
// returns 200 with the repo's CURRENT data in the body, while the
// code still has the stale name it requested. If syncGiteaRepoEnhanced
// kept using the requested (stale) name for the follow-up POST
// .../mirror-sync, that POST would hit the same 301, get its method
// downgraded to GET per the WHATWG redirect spec, and the POST-only
// endpoint would return 405. This must work for non-archived repos
// too — a user renaming a repo in Forgejo manually is the general case.
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: false,
},
};
const repository: Repository = {
id: "repoRenamed1",
name: "renamed-repo",
fullName: "user/renamed-repo",
owner: "user",
cloneUrl: "https://github.com/user/renamed-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("mirrored"),
visibility: "public",
userId: "user123",
// Stale: recorded before the rename happened in Gitea/Forgejo.
mirroredLocation: "starred/renamed-repo",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(result).toEqual({ success: true });
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(1);
expect(String(mirrorSyncCalls[0][0])).toContain(
"/api/v1/repos/starred/archived-renamed-repo/mirror-sync"
);
expect(String(mirrorSyncCalls[0][0])).not.toContain(
"/api/v1/repos/starred/renamed-repo/mirror-sync"
);
});
test("keeps archived repos archived and skips the mirror-interval PATCH on Manual Sync", async () => {
// Documented contract (AutomationSettings.tsx): "Archive renames mirror
// backups with an archived- prefix and disables automatic syncs—use
// Manual Sync when you want to refresh." A successful Manual Sync of an
// archived repo must therefore refresh once WITHOUT (a) flipping status
// to "synced" (which would re-enroll it into the scheduler's auto-sync
// pool), (b) clearing the archived errorMessage annotation, or
// (c) PATCHing the mirror interval (which would re-enable Forgejo's own
// periodic pulling that archiveGiteaRepo disabled).
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: false,
// Would normally trigger the mirror-interval PATCH on every sync.
mirrorInterval: "8h",
},
};
const repository: Repository = {
id: "repoArchived2",
name: "broken-repo",
fullName: "user/broken-repo",
owner: "user",
url: "https://github.com/user/broken-repo",
cloneUrl: "https://github.com/user/broken-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("archived"),
isArchived: true,
visibility: "public",
userId: "user123",
errorMessage: "Repository archived - no longer in GitHub",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(result).toEqual({ success: true });
// The mirror-sync itself must still happen (that's the point of
// Manual Sync on an archived repo).
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(1);
expect(String(mirrorSyncCalls[0][0])).toContain(
"/api/v1/repos/starred/archived-broken-repo/mirror-sync"
);
// No mirror-interval PATCH despite config.giteaConfig.mirrorInterval.
expect(mockHttpPatch).not.toHaveBeenCalled();
// The success-path DB update (the one recording lastMirrored) keeps
// status "archived" and does not clear errorMessage.
const successUpdate = dbUpdateSetCalls.find((data) => "lastMirrored" in data);
expect(successUpdate).toBeDefined();
expect(successUpdate.status).toBe("archived");
expect("errorMessage" in successUpdate).toBe(false);
expect(successUpdate.mirroredLocation).toBe("starred/archived-broken-repo");
});
test("rejects an archived-{name} fallback candidate whose original_url points at a different source", async () => {
// Two sources sharing a base name: the user mirrors user/collide-repo,
// but starred/archived-collide-repo in Gitea is the archived mirror of
// otheruser/collide-repo. The guessed archived-{name} fallback must be
// rejected via its original_url instead of syncing (and rewriting the
// DB row of) the wrong repository. With every candidate exhausted, the
// sync fails with the not-found error.
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: false,
},
};
const repository: Repository = {
id: "repoCollide1",
name: "collide-repo",
fullName: "user/collide-repo",
owner: "user",
url: "https://github.com/user/collide-repo",
cloneUrl: "https://github.com/user/collide-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("archived"),
isArchived: true,
visibility: "public",
userId: "user123",
// No mirroredLocation — forces reliance on the guessed fallback.
createdAt: new Date(),
updatedAt: new Date(),
};
await expect(
syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
)
).rejects.toThrow("Repository collide-repo not found in Gitea. Tried locations:");
// The wrong repo must never receive a mirror-sync POST.
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(0);
// The fallback candidate WAS probed (and then rejected by the guard).
expect(
mockHttpGet.mock.calls.some((call) =>
String(call[0]).endsWith("/api/v1/repos/starred/archived-collide-repo")
)
).toBe(true);
});
test("blocks sync when pre-sync snapshot fails and blocking is enabled", async () => {
mockShouldCreatePreSyncBackup = true;
mockShouldBlockSyncOnBackupFailure = true;
+99 -6
View File
@@ -26,6 +26,7 @@ import {
strategyNeedsDetection,
} from "./repo-backup";
import { detectForcePush } from "./utils/force-push-detection";
import { sanitizeRepoNameAlphaDashDot } from "./gitea";
import {
parseRepositoryMetadataState,
serializeRepositoryMetadataState,
@@ -61,6 +62,27 @@ export interface GiteaRepoInfo {
interface SyncTargetCandidate {
owner: string;
repoName: string;
/**
* True for the guessed `archived-{name}` fallback candidate (see
* syncGiteaRepoEnhanced). Unlike the recorded mirroredLocation or the
* expected-owner candidate, this one is derived purely from the repo NAME,
* so it can collide with a different source repo that shares the same base
* name — it must pass an original_url source check before being accepted.
*/
isArchivedFallback?: boolean;
}
/**
* Normalize a git remote URL for source-identity comparison: lowercase,
* strip trailing slashes and a trailing `.git`.
*/
function normalizeSourceUrl(url: string): string {
return url
.trim()
.toLowerCase()
.replace(/\/+$/, "")
.replace(/\.git$/, "")
.replace(/\/+$/, "");
}
function parseMirroredLocation(location?: string | null): SyncTargetCandidate | null {
@@ -329,12 +351,32 @@ export async function syncGiteaRepoEnhanced({
// Resolve sync target in a backward-compatible order:
// 1) recorded mirroredLocation (actual historical mirror location)
// 2) owner derived from current strategy/config
// 3) (archived repos only) the `archived-{name}` rename that
// archiveGiteaRepo applies to mirror repos (see #331 follow-up).
// Repos archived before mirroredLocation was backfilled on rename, or
// any lingering false-positive orphan hit, would otherwise be
// unreachable from "Manual Sync" — the UI's documented way to refresh
// an archived mirror — because the recorded/expected name no longer
// exists and Gitea returns HTTP 405 for it.
const dependencies = deps ?? (await import("./gitea"));
const expectedOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
const recordedTarget = parseMirroredLocation(repository.mirroredLocation);
// Archived state can live in either field: the cleanup service sets both,
// but a failed retry can clobber `status` while `isArchived` survives.
const isArchivedRepo =
repository.status === "archived" || !!repository.isArchived;
const candidateTargets = dedupeSyncTargets([
...(recordedTarget ? [recordedTarget] : []),
{ owner: expectedOwner, repoName: repository.name },
...(isArchivedRepo
? [
{
owner: expectedOwner,
repoName: `archived-${sanitizeRepoNameAlphaDashDot(repository.name)}`,
isArchivedFallback: true,
},
]
: []),
]);
let repoOwner = expectedOwner;
@@ -360,8 +402,45 @@ export async function syncGiteaRepoEnhanced({
continue;
}
repoOwner = target.owner;
repoName = target.repoName;
// The archived-{name} fallback candidate is guessed from the repo NAME
// alone, so it can hit a DIFFERENT source's archived mirror when two
// sources share a base name (e.g. foo/tools and bar/tools both mirrored;
// foo's archived as `archived-tools`). Before accepting it, verify the
// candidate's original_url (the authoritative migration source — see
// GiteaRepoInfo) points at THIS repository's GitHub source. An empty/
// unset original_url is accepted as before (some migrations leave it
// unset). This guard deliberately does NOT apply to the recorded
// mirroredLocation or expected-name candidates.
if (
target.isArchivedFallback &&
typeof candidateInfo.original_url === "string" &&
candidateInfo.original_url.trim() !== ""
) {
const candidateSource = normalizeSourceUrl(candidateInfo.original_url);
const ownSources = [repository.cloneUrl, repository.url]
.filter((u): u is string => typeof u === "string" && u.trim() !== "")
.map(normalizeSourceUrl);
if (!ownSources.includes(candidateSource)) {
console.warn(
`[Sync] Skipping archived-name candidate ${target.owner}/${target.repoName} for ${repository.name}: its original_url (${candidateInfo.original_url}) points at a different source`
);
continue;
}
}
// Adopt the canonical identity from the response, not the requested target.
// Gitea/Forgejo answer GETs for a renamed repo's old name with a 301 that
// fetch follows silently, so `target` may be a stale pre-rename name; a
// follow-up POST (mirror-sync) to the stale path gets its method downgraded
// by the redirect and fails with 405. The response body always carries the
// repo's current name/owner (#331 follow-up, verified on Forgejo 15).
const canonicalOwner =
typeof candidateInfo.owner === "string"
? candidateInfo.owner
: candidateInfo.owner?.login;
repoOwner = canonicalOwner || target.owner;
repoName = candidateInfo.name || target.repoName;
repoInfo = candidateInfo;
break;
}
@@ -621,7 +700,13 @@ export async function syncGiteaRepoEnhanced({
// NOTE: Gitea/Forgejo's PATCH /repos/{owner}/{repo} API does not support
// updating mirror credentials (mirror_username/mirror_password). Repos that
// were originally migrated without credentials must be deleted and re-mirrored.
if (config.giteaConfig?.mirrorInterval) {
//
// Skipped for archived repos: archiveGiteaRepo deliberately disabled
// Gitea's own periodic pulling, and the documented contract
// (AutomationSettings.tsx: "Archive ... disables automatic syncs—use
// Manual Sync when you want to refresh") says a Manual Sync refreshes
// once without re-enabling any automatic syncing.
if (config.giteaConfig?.mirrorInterval && !isArchivedRepo) {
try {
console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}`;
@@ -844,14 +929,22 @@ export async function syncGiteaRepoEnhanced({
metadataState.lastSyncedAt = new Date().toISOString();
}
// Mark repo as "synced" in DB
// Mark repo as "synced" in DB — unless it's archived. The documented
// contract (AutomationSettings.tsx: "Archive ... disables automatic
// syncs—use Manual Sync when you want to refresh") means a Manual Sync
// of an archived repo must NOT re-enroll it into the scheduler's
// auto-sync pool (which selects mirrored/synced/failed/pending) nor
// clear the archived errorMessage annotation shown in the UI; it still
// records lastMirrored and the (possibly corrected) mirroredLocation.
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("synced"),
status: isArchivedRepo
? repoStatusEnum.parse("archived")
: repoStatusEnum.parse("synced"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
...(isArchivedRepo ? {} : { errorMessage: null }),
mirroredLocation: `${repoOwner}/${repoName}`,
metadata: metadataUpdated
? serializeRepositoryMetadataState(metadataState)
+52
View File
@@ -0,0 +1,52 @@
/**
* Regression test for the #334 sibling bug — labels silently dropped on issue update.
*
* Gitea/Forgejo's `EditIssueOption` has no `labels` field (only `CreateIssueOption`
* does), so a `labels` key in a `PATCH .../issues/{index}` body is silently ignored.
* The old code put `labels` in the update PATCH, so label changes never propagated
* onto already-mirrored issues. The fix builds the edit body WITHOUT labels
* (`buildGiteaIssueEditPayload`) and reconciles labels separately through the
* sub-resource `PUT .../issues/{index}/labels` (`buildGiteaIssueLabelsPayload`).
*
* Verified live against Gitea 1.24.7: PATCH with `labels` leaves the issue's labels
* unchanged; PUT to the labels sub-resource replaces them. Confirmed end-to-end that
* a drifted (label-less) mirrored issue reconciles back to its GitHub label set.
*/
import { describe, test, expect } from "bun:test";
import { buildGiteaIssueEditPayload, buildGiteaIssueLabelsPayload } from "@/lib/gitea";
describe("buildGiteaIssueEditPayload (#334 sibling)", () => {
test("edit body never carries `labels` (Gitea's EditIssueOption ignores it)", () => {
const payload = buildGiteaIssueEditPayload({
title: "[GH-ISSUE #1] Fix the thing",
body: "desc",
closed: false,
});
expect(payload).not.toHaveProperty("labels");
expect(payload).toEqual({
title: "[GH-ISSUE #1] Fix the thing",
body: "desc",
state: "open",
});
});
test("maps the closed flag to Gitea's `state`", () => {
expect(buildGiteaIssueEditPayload({ title: "t", body: "b", closed: true }).state).toBe("closed");
expect(buildGiteaIssueEditPayload({ title: "t", body: "b", closed: false }).state).toBe("open");
});
});
describe("buildGiteaIssueLabelsPayload (#334 sibling)", () => {
test("replaces the full label set with the resolved Gitea label ids", () => {
expect(buildGiteaIssueLabelsPayload([7, 9])).toEqual({ labels: [7, 9] });
});
test("sends an empty set so upstream label removals propagate", () => {
expect(buildGiteaIssueLabelsPayload([])).toEqual({ labels: [] });
});
test("treats a missing id list as an empty set (defensive)", () => {
expect(buildGiteaIssueLabelsPayload(undefined as any)).toEqual({ labels: [] });
});
});
@@ -0,0 +1,375 @@
/**
* Behavioral tests for issue #343 / PR #344:
* bulk "Mirror Organization" must honor the canonical destination
* precedence (starred mode > repo override > org override > strategy).
*
* NOTE: these tests replace @/lib/db, @/lib/gitea-enhanced, @/lib/http-client,
* @/lib/helpers, and globalThis.fetch with mocks. bun's mock.module (and the
* fetch swap) are process-wide and leak into other test files, so in the
* shared test process this file registers NOTHING: it re-runs itself in an
* isolated child process (`bun test <this file>` with GITEA_ORG_MIRROR_DEST_ISOLATED=1)
* where the mocks are safely contained.
*/
import { describe, test, expect, mock, beforeEach } from "bun:test";
const CHILD_FLAG = "GITEA_ORG_MIRROR_DEST_ISOLATED";
const isChild = !!process.env[CHILD_FLAG];
if (!isChild) {
test("bulk org mirror destination routing (#343) — isolated child suite", () => {
const res = Bun.spawnSync({
cmd: [process.execPath, "test", import.meta.path],
env: { ...process.env, [CHILD_FLAG]: "1" },
stdout: "pipe",
stderr: "pipe",
});
if (res.exitCode !== 0) {
console.error(res.stdout.toString());
console.error(res.stderr.toString());
}
expect(res.exitCode).toBe(0);
}, 60_000);
}
// ---------------------------------------------------------------------------
// Shared mutable state the module mocks read from / write to
// ---------------------------------------------------------------------------
/** Rows returned by the FIRST select on the repositories table (the orgRepos query). */
let orgRepoRows: any[] = [];
/** Row returned for getOrganizationConfig (select from organizations .limit(1)). */
let orgConfigRows: any[] = [];
/** Counts selects against the repositories table. */
let repoSelectCount = 0;
/** Every httpPost call: { url, payload }. */
let httpPostCalls: Array<{ url: string; payload: any }> = [];
/** Every org get-or-create: orgName -> deterministic id. */
let orgCreateCalls: string[] = [];
// Deterministic id derived purely from the name — must not depend on call
// order: bun may re-instantiate mock factories, so an order-dependent counter
// can diverge between the mocked flow and the test's assertions.
function orgIdFor(name: string): number {
let h = 0;
for (const c of name) h = (h * 31 + c.charCodeAt(0)) % 100_000;
return 100 + h;
}
// ---------------------------------------------------------------------------
// Module mocks (must be registered before importing ./gitea)
// ---------------------------------------------------------------------------
const repositoriesTable = { __table: "repositories" } as any;
const organizationsTable = { __table: "organizations" } as any;
function promiseWithLimit(rows: any[], limitRows?: any[]) {
const p: any = Promise.resolve(rows);
p.limit = () => Promise.resolve(limitRows ?? rows);
return p;
}
// Everything below only exists in the isolated child process — registering
// these mocks in the shared test process poisons other test files.
if (isChild) {
mock.module("@/lib/db", () => {
const mockDb = {
select: (_fields?: any) => ({
from: (table: any) => ({
where: (_cond: any) => {
if (table === repositoriesTable) {
repoSelectCount++;
// First repositories select in mirrorGitHubOrgToGitea is the
// orgRepos query; everything after (idempotency checks, name
// claims) must see no rows.
const rows = repoSelectCount === 1 ? orgRepoRows : [];
return promiseWithLimit(rows, []);
}
if (table === organizationsTable) {
return promiseWithLimit(orgConfigRows, orgConfigRows);
}
return promiseWithLimit([], []);
},
}),
}),
update: (_table: any) => ({
set: (_data: any) => ({ where: (_cond: any) => Promise.resolve() }),
}),
insert: (_table: any) => ({ values: (_data: any) => Promise.resolve() }),
delete: (_table: any) => ({ where: (_cond: any) => Promise.resolve() }),
};
return {
db: mockDb,
repositories: repositoriesTable,
organizations: organizationsTable,
configs: {},
mirrorJobs: {},
users: {},
events: {},
sessions: {},
accounts: {},
};
});
mock.module("@/lib/helpers", () => ({
createMirrorJob: mock(async () => "job-id"),
}));
const actualHttp = await import("./http-client");
const actualEnhanced = await import("./gitea-enhanced");
const actualConfigEncryption = await import("./utils/config-encryption");
mock.module("@/lib/http-client", () => ({
...actualHttp,
httpGet: mock(async (url: string) => {
throw new actualHttp.HttpError(`GET ${url} -> 404`, 404, "not found");
}),
httpPost: mock(async (url: string, payload: any) => {
httpPostCalls.push({ url, payload });
return { data: { id: 1, ...payload }, status: 201, statusText: "Created", headers: new Headers() };
}),
httpPut: mock(async () => ({ data: {}, status: 200, statusText: "OK", headers: new Headers() })),
httpPatch: mock(async () => ({ data: {}, status: 200, statusText: "OK", headers: new Headers() })),
httpDelete: mock(async () => ({ data: {}, status: 204, statusText: "No Content", headers: new Headers() })),
}));
mock.module("@/lib/gitea-enhanced", () => ({
...actualEnhanced,
getOrCreateGiteaOrgEnhanced: mock(async ({ orgName }: any) => {
orgCreateCalls.push(orgName);
return orgIdFor(orgName);
}),
getGiteaRepoInfo: mock(async () => null),
handleExistingNonMirrorRepo: mock(async () => {}),
}));
// NOTE: @/lib/utils/mirror-source-match is deliberately NOT mocked. Its real
// implementation resolves to "no existing mirror / name available" naturally
// under the db/fetch/gitea-enhanced mocks above, and module-mocking it
// poisons mirror-source-match.test.ts (bun mock.module is process-wide).
mock.module("@/lib/utils/config-encryption", () => ({
...actualConfigEncryption,
decryptConfigTokens: (config: any) => config,
}));
// isRepoPresentInGitea uses global fetch directly.
globalThis.fetch = mock(async () =>
new Response("not found", { status: 404 })
) as any;
} // end if (isChild)
const { mirrorGitHubOrgToGitea } = isChild
? await import("./gitea")
: ({ mirrorGitHubOrgToGitea: undefined as any });
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeConfig(overrides: any = {}): any {
return {
id: "config-1",
userId: "user-1",
githubConfig: {
token: "gh-token",
owner: "me",
...(overrides.githubConfig || {}),
},
giteaConfig: {
url: "https://gitea.test",
token: "gitea-token",
defaultOwner: "meuser",
addTopics: false,
...(overrides.giteaConfig || {}),
},
};
}
function makeOrg(overrides: any = {}): any {
return {
id: "org-db-1",
userId: "user-1",
name: "A",
membershipRole: "member",
isIncluded: true,
status: "imported",
repositoryCount: 1,
createdAt: new Date(),
updatedAt: new Date(),
destinationOrg: null,
...overrides,
};
}
function makeRepo(overrides: any = {}): any {
return {
id: "repo-1",
userId: "user-1",
configId: "config-1",
name: "r1",
fullName: "A/r1",
owner: "A",
organization: "A",
url: "https://github.com/A/r1",
cloneUrl: "https://github.com/A/r1.git",
isPrivate: false,
isForked: false,
forkedFrom: null,
hasIssues: false,
isStarred: false,
isArchived: false,
size: 0,
hasLFS: false,
hasSubmodules: false,
defaultBranch: "main",
visibility: "public",
status: "imported",
destinationOrg: null,
lastMirrored: null,
errorMessage: null,
mirroredLocation: "",
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
const fakeOctokit = {} as any;
function migrateCalls() {
return httpPostCalls.filter((c) => c.url.includes("/repos/migrate"));
}
beforeEach(() => {
orgRepoRows = [];
orgConfigRows = [];
repoSelectCount = 0;
httpPostCalls = [];
orgCreateCalls = [];
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe.skipIf(!isChild)("mirrorGitHubOrgToGitea destination routing (#343)", () => {
test("Scenario 1: preserve strategy honors organization destinationOrg override", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } });
const organization = makeOrg({ destinationOrg: "B" });
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
// The override org must be created; the GitHub-named org must NOT be.
expect(orgCreateCalls).toContain("B");
expect(orgCreateCalls).not.toContain("A");
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(migrates[0].payload.uid).toBe(orgIdFor("B"));
expect(migrates[0].payload.repo_name).toBe("r1");
});
test("Scenario 2: mixed strategy sends org repos to the GitHub-named org with a defined uid", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "mixed" } });
const organization = makeOrg();
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
// The main-branch bug: uid was undefined -> dropped by JSON.stringify ->
// Gitea defaulted the owner to the authenticated user.
expect(migrates[0].payload.uid).toBeDefined();
expect(migrates[0].payload.uid).toBe(orgIdFor("A"));
expect(orgCreateCalls).toContain("A");
});
test("Scenario 3: starred repo in a bulk org mirror follows starred-repo mode", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "mixed" } });
const organization = makeOrg();
orgRepoRows = [
makeRepo({ id: "repo-2", name: "tools", fullName: "A/tools", isStarred: true }),
];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(orgCreateCalls).toContain("starred");
expect(migrates[0].payload.uid).toBe(orgIdFor("starred"));
});
test("per-repo destinationOrg override beats org override and strategy", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } });
const organization = makeOrg({ destinationOrg: "B" });
orgRepoRows = [makeRepo({ destinationOrg: "C" })];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(migrates[0].payload.uid).toBe(orgIdFor("C"));
expect(orgCreateCalls).toContain("C");
});
test("regression: preserve strategy without overrides keeps GitHub org name and creates the org once", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } });
const organization = makeOrg();
orgRepoRows = [
makeRepo(),
makeRepo({ id: "repo-3", name: "r2", fullName: "A/r2", cloneUrl: "https://github.com/A/r2.git" }),
];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(2);
for (const call of migrates) {
expect(call.payload.uid).toBe(orgIdFor("A"));
}
// Pre-created once at the top; the per-repo loop must reuse it.
expect(orgCreateCalls.filter((n) => n === "A").length).toBe(1);
});
test("regression: single-org strategy without overrides routes to the configured org", async () => {
const config = makeConfig({
githubConfig: { mirrorStrategy: "single-org" },
giteaConfig: { organization: "hub" },
});
const organization = makeOrg();
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(migrates[0].payload.uid).toBe(orgIdFor("hub"));
expect(orgCreateCalls.filter((n) => n === "hub").length).toBe(1);
});
test("regression: flat-user strategy without overrides mirrors to the user account (repo_owner, no org)", async () => {
const config = makeConfig({ githubConfig: { mirrorStrategy: "flat-user" } });
const organization = makeOrg();
orgRepoRows = [makeRepo()];
orgConfigRows = [organization];
await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config });
const migrates = migrateCalls();
expect(migrates.length).toBe(1);
expect(migrates[0].payload.repo_owner).toBe("meuser");
expect(migrates[0].payload.uid).toBeUndefined();
expect(orgCreateCalls.length).toBe(0);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Regression test for #334 — "Release titles not being mirrored properly".
*
* Root cause: the release create/update payloads sent the release title under the
* JSON key `title`, but Gitea/Forgejo's release API expects `name` (the API Go
* struct is `Title string \`json:"name"\``). `title` is silently dropped, so every
* mirrored release landed with a blank name.
*
* `buildGiteaReleasePayload` is the single source of truth for both the create
* (POST) and update (PATCH) bodies. Verified live against Gitea 1.24.7: a payload
* with `title` yields `name: ""`; a payload with `name` sets the title correctly.
*/
import { describe, test, expect } from "bun:test";
import { buildGiteaReleasePayload } from "@/lib/gitea";
describe("buildGiteaReleasePayload (#334)", () => {
test("carries the release title under `name`, never `title`", () => {
const payload = buildGiteaReleasePayload(
{ tag_name: "v0.19.0", name: "v0.19.0", draft: false, prerelease: false },
"## Features\n- something"
);
expect(payload.name).toBe("v0.19.0");
expect(payload).not.toHaveProperty("title");
expect(payload).toEqual({
tag_name: "v0.19.0",
name: "v0.19.0",
body: "## Features\n- something",
draft: false,
prerelease: false,
});
});
test("falls back to tag_name when the GitHub release name is empty or null", () => {
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3", name: null }, "x").name).toBe("v1.2.3");
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3", name: "" }, "x").name).toBe("v1.2.3");
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3" }, "x").name).toBe("v1.2.3");
});
test("passes draft/prerelease/body through unchanged", () => {
const payload = buildGiteaReleasePayload(
{ tag_name: "v2.0.0", name: "Two", draft: true, prerelease: true },
"notes body"
);
expect(payload.body).toBe("notes body");
expect(payload.draft).toBe(true);
expect(payload.prerelease).toBe(true);
expect(payload.tag_name).toBe("v2.0.0");
});
});
+230 -64
View File
@@ -1995,11 +1995,20 @@ export async function mirrorGitHubOrgToGitea({
const mirrorStrategy = config.githubConfig?.mirrorStrategy ||
(config.giteaConfig?.preserveOrgStructure ? "preserve" : "flat-user");
let giteaOrgId: number;
let giteaOrgId: number | undefined;
let targetOrgName: string;
// Determine the target organization based on strategy
if (mirrorStrategy === "single-org" && config.giteaConfig?.organization) {
if (organization.destinationOrg) {
// Organization-level override takes precedence over the strategy
targetOrgName = organization.destinationOrg;
giteaOrgId = await getOrCreateGiteaOrg({
orgId: organization.id,
orgName: targetOrgName,
config,
});
console.log(`Using organization override: ${organization.name} -> ${targetOrgName}`);
} else if (mirrorStrategy === "single-org" && config.giteaConfig?.organization) {
// For single-org strategy, use the configured destination organization
targetOrgName = config.giteaConfig.organization || config.giteaConfig.defaultOwner;
giteaOrgId = await getOrCreateGiteaOrg({
@@ -2017,9 +2026,15 @@ export async function mirrorGitHubOrgToGitea({
config,
});
} else {
// For flat-user strategy, we shouldn't create organizations at all
// Skip organization creation and let individual repos be handled by getGiteaRepoOwner
console.log(`Using flat-user strategy: repos will be placed under user account`);
// flat-user: no organizations should be created at all.
// mixed: org repos resolve per-repo via getGiteaRepoOwnerAsync in the
// loop below (creating their target orgs on demand), so there is
// nothing to pre-create here either.
console.log(
mirrorStrategy === "mixed"
? `Using mixed strategy: repos will be resolved per-repo (orgs created on demand)`
: `Using flat-user strategy: repos will be placed under user account`
);
targetOrgName = config.giteaConfig?.defaultOwner || "";
}
@@ -2062,23 +2077,35 @@ export async function mirrorGitHubOrgToGitea({
`Starting mirror for repository: ${repo.name} from GitHub org ${organization.name}`
);
// Mirror the repository based on strategy
if (mirrorStrategy === "flat-user") {
// For flat-user strategy, mirror directly to user account
// Resolve per repo with the canonical precedence
const owner = await getGiteaRepoOwnerAsync({ config, repository: repoData });
if (owner === config.giteaConfig?.defaultOwner) {
await mirrorGithubRepoToGitea({
octokit,
repository: repoData,
config,
});
} else {
// For preserve and single-org strategies, use organization
} else if (owner === targetOrgName && giteaOrgId !== undefined) {
await mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository: repoData,
giteaOrgId: giteaOrgId!,
giteaOrgId,
orgName: targetOrgName,
});
} else {
const ownerOrgId = await getOrCreateGiteaOrg({
orgName: owner,
config,
});
await mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository: repoData,
giteaOrgId: ownerOrgId,
orgName: owner,
});
}
return repo;
@@ -2186,6 +2213,98 @@ export const syncGiteaRepo = async ({
}
};
/**
* Build the JSON body for creating/updating a Gitea release.
*
* Gitea/Forgejo expose the release title through the JSON field `name`, not
* `title` (the API Go struct is `Title string \`json:"name"\``); sending `title`
* is silently ignored and leaves the release name blank (#334). Create and update
* send the same fields; `target` is intentionally omitted (see #331/#333) so Gitea
* attaches the release to the already-synced tag instead of 404-ing on the target.
*/
export function buildGiteaReleasePayload(
release: { tag_name: string; name?: string | null; draft?: boolean; prerelease?: boolean },
releaseNote: string
): { tag_name: string; name: string; body: string; draft?: boolean; prerelease?: boolean } {
return {
tag_name: release.tag_name,
name: release.name || release.tag_name,
body: releaseNote,
draft: release.draft,
prerelease: release.prerelease,
};
}
/**
* Build the JSON body for a Gitea issue / PR-as-issue edit (PATCH .../issues/{index}).
*
* Deliberately excludes `labels`: Gitea's `EditIssueOption` has no `labels` field
* (only `CreateIssueOption` does), so any `labels` key here is silently dropped —
* the same class of bug as the release `title` mix-up (#334 sibling). Labels are
* applied separately via the labels sub-resource (see buildGiteaIssueLabelsPayload).
*/
export function buildGiteaIssueEditPayload(opts: {
title: string;
body: string;
closed: boolean;
}): { title: string; body: string; state: "open" | "closed" } {
return { title: opts.title, body: opts.body, state: opts.closed ? "closed" : "open" };
}
/**
* Build the JSON body for the Gitea issue labels sub-resource
* (PUT .../issues/{index}/labels), which replaces the full label set idempotently.
*/
export function buildGiteaIssueLabelsPayload(labelIds: number[]): { labels: number[] } {
return { labels: labelIds ?? [] };
}
/**
* Replace the label set on an existing Gitea issue (or PR-as-issue) via the
* dedicated labels sub-resource.
*
* Gitea/Forgejo's `EditIssueOption` has no `labels` field (only
* `CreateIssueOption` does), so a `labels` key in a `PATCH .../issues/{index}`
* body is silently dropped by the JSON decoder — the same class of bug as the
* release `title` vs `name` mix-up (#334). Label changes on an already-mirrored
* issue therefore have to go through `PUT .../issues/{index}/labels`, which
* replaces the whole set idempotently: it both applies newly added labels and
* removes ones deleted upstream.
*
* Best-effort: labels are secondary metadata, so a transient failure here is
* logged and left to self-heal on the next sync rather than failing (and
* retrying) the entire issue + comment mirror.
*/
async function reconcileGiteaIssueLabels({
config,
decryptedConfig,
giteaOwner,
repoName,
issueNumber,
labelIds,
}: {
config: Partial<Config>;
decryptedConfig: Config;
giteaOwner: string;
repoName: string;
issueNumber: number;
labelIds: number[];
}): Promise<void> {
try {
await httpPut(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${issueNumber}/labels`,
buildGiteaIssueLabelsPayload(labelIds),
{ Authorization: `token ${decryptedConfig.giteaConfig!.token}` }
);
} catch (error) {
console.warn(
`[Labels] Failed to reconcile labels on issue #${issueNumber}: ${
error instanceof Error ? error.message : String(error)
} (will retry on next sync)`
);
}
}
export const mirrorGitRepoIssuesToGitea = async ({
config,
octokit,
@@ -2437,12 +2556,11 @@ export const mirrorGitRepoIssuesToGitea = async ({
targetIssueNumber = existingIssue.number;
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
{
buildGiteaIssueEditPayload({
title: issuePayload.title,
body: issuePayload.body,
state: issue.state === "closed" ? "closed" : "open",
labels: issuePayload.labels,
},
closed: issue.state === "closed",
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
@@ -2487,12 +2605,11 @@ export const mirrorGitRepoIssuesToGitea = async ({
);
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
{
buildGiteaIssueEditPayload({
title: issuePayload.title,
body: issuePayload.body,
state: issue.state === "closed" ? "closed" : "open",
labels: issuePayload.labels,
},
closed: issue.state === "closed",
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
@@ -2531,6 +2648,21 @@ export const mirrorGitRepoIssuesToGitea = async ({
}
}
// Gitea's EditIssueOption ignores `labels`, so the PATCH above can't change
// them on an already-mirrored issue — reconcile via the labels sub-resource.
// Only needed on the update paths; a freshly POSTed issue already got its
// labels from CreateIssueOption. (#334 sibling)
if (existingIssue) {
await reconcileGiteaIssueLabels({
config,
decryptedConfig,
giteaOwner,
repoName,
issueNumber: targetIssueNumber,
labelIds: giteaLabelIds,
});
}
// Clone comments
const comments = await octokit.paginate(
octokit.rest.issues.listComments,
@@ -2881,6 +3013,14 @@ export async function mirrorGitHubReleasesToGitea({
const repoOwner = giteaOwner || (await getGiteaRepoOwnerAsync({ config, repository }));
const repoName = giteaRepoName || repository.name;
// Derive GITHUB coordinates from fullName, matching the issues/PRs/labels/
// milestones mirror functions (`const [owner, repo] = repository.fullName.split("/")`).
// repository.name/owner can drift from the GitHub source (e.g. Gitea-side
// renames), so fullName is authoritative; fall back only if it's malformed.
const [fullNameOwner, fullNameRepo] = (repository.fullName || "").split("/");
const githubOwner = fullNameOwner && fullNameRepo ? fullNameOwner : repository.owner;
const githubRepo = fullNameOwner && fullNameRepo ? fullNameRepo : repository.name;
// Verify the repository exists in Gitea before attempting to mirror releases
console.log(`[Releases] Verifying repository ${repoName} exists at ${repoOwner}`);
const repoExists = await isRepoPresentInGitea({
@@ -2906,8 +3046,8 @@ export async function mirrorGitHubReleasesToGitea({
while (releases.length < releaseLimit) {
const response = await octokit.rest.repos.listReleases({
owner: repository.owner,
repo: repository.name,
owner: githubOwner,
repo: githubRepo,
per_page: perPage,
page,
});
@@ -2995,15 +3135,7 @@ export async function mirrorGitHubReleasesToGitea({
await httpPatch(
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${existingRelease.id}`,
{
tag_name: release.tag_name,
// Omit `target` — the release already exists and is anchored to its tag;
// re-sending target_commitish risks the same "target not found" 404 (#331).
title: release.name || release.tag_name,
body: releaseNote,
draft: release.draft,
prerelease: release.prerelease,
},
buildGiteaReleasePayload(release, releaseNote),
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
@@ -3074,16 +3206,7 @@ export async function mirrorGitHubReleasesToGitea({
const createReleaseResponse = await httpPost(
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases`,
{
tag_name: release.tag_name,
// Intentionally omit `target`: the tag already exists (verified above), so
// Gitea attaches the release to it. Sending target_commitish can 404 with
// "The target couldn't be found" on some Gitea/Forgejo versions (#331).
title: release.name || release.tag_name,
body: releaseNote,
draft: release.draft,
prerelease: release.prerelease,
},
buildGiteaReleasePayload(release, releaseNote),
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
@@ -3471,12 +3594,11 @@ export async function mirrorGitRepoPullRequestsToGitea({
if (existingPrIssue) {
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
{
buildGiteaIssueEditPayload({
title: issueData.title,
body: issueData.body,
state: issueData.closed ? "closed" : "open",
labels: issueData.labels,
},
closed: issueData.closed,
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
@@ -3515,6 +3637,20 @@ export async function mirrorGitRepoPullRequestsToGitea({
}
}
// Gitea drops `labels` on issue edit, so the "pull-request" marker label
// can't be set via the PATCH above — reconcile it on the update path.
// (#334 sibling)
if (existingPrIssue) {
await reconcileGiteaIssueLabels({
config,
decryptedConfig,
giteaOwner,
repoName,
issueNumber: existingPrIssue.number,
labelIds: issueData.labels,
});
}
successCount++;
console.log(`[Pull Requests] ✅ Successfully created issue for PR #${pr.number}`);
} catch (apiError) {
@@ -3559,12 +3695,11 @@ export async function mirrorGitRepoPullRequestsToGitea({
if (existingPrIssue) {
await httpPatch(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
{
buildGiteaIssueEditPayload({
title: basicIssueData.title,
body: basicIssueData.body,
state: basicIssueData.closed ? "closed" : "open",
labels: basicIssueData.labels,
},
closed: basicIssueData.closed,
}),
{
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
@@ -3602,6 +3737,19 @@ export async function mirrorGitRepoPullRequestsToGitea({
}
}
// Same as the enriched path — reconcile the marker label via the labels
// sub-resource since PATCH ignores it. (#334 sibling)
if (existingPrIssue) {
await reconcileGiteaIssueLabels({
config,
decryptedConfig,
giteaOwner,
repoName,
issueNumber: existingPrIssue.number,
labelIds: basicIssueData.labels,
});
}
successCount++;
console.log(`[Pull Requests] ✅ Created basic issue for PR #${pr.number}`);
} catch (error) {
@@ -3931,29 +4079,43 @@ export async function deleteGiteaRepo(
}
}
/**
* Sanitize a repository name to satisfy Gitea's AlphaDashDot rule for repo
* names (letters, digits, `.`, and `-`; no leading/trailing separators).
*
* Extracted from archiveGiteaRepo (module-level, exported) so gitea-enhanced.ts
* can build the identical `archived-{name}` candidate when probing for a repo
* that was renamed by this exact archive flow — either just now, or by an
* older version of this code before mirroredLocation was backfilled on rename
* (see #331 follow-up).
*/
export function sanitizeRepoNameAlphaDashDot(name: string): string {
// Replace anything that's not [A-Za-z0-9.-] with '-'
const base = name.replace(/[^A-Za-z0-9.-]+/g, "-").replace(/-+/g, "-");
// Trim leading/trailing separators and dots for safety
return base.replace(/^[.-]+/, "").replace(/[.-]+$/, "");
}
/**
* Archive a repository in Gitea
*
*
* IMPORTANT: This function NEVER deletes data. It only marks repositories as archived.
* - For regular repos: Uses Gitea's archive feature (makes read-only)
* - For mirror repos: Renames with [ARCHIVED] prefix (Gitea doesn't allow archiving mirrors)
*
*
* This ensures backups are preserved even when the GitHub source disappears.
*
* Returns the Gitea-side name the repository ended up with after a rename
* (mirror path), or `null` when no rename occurred (regular-repo archive
* path, or any failure). Callers that persist `mirroredLocation` should only
* update it when `archivedName` is non-null.
*/
export async function archiveGiteaRepo(
client: { url: string; token: string },
owner: string,
repo: string
): Promise<void> {
): Promise<{ archivedName: string | null }> {
try {
// Helper: sanitize to Gitea's AlphaDashDot rule
const sanitizeRepoNameAlphaDashDot = (name: string): string => {
// Replace anything that's not [A-Za-z0-9.-] with '-'
const base = name.replace(/[^A-Za-z0-9.-]+/g, "-").replace(/-+/g, "-");
// Trim leading/trailing separators and dots for safety
return base.replace(/^[.-]+/, "").replace(/[.-]+$/, "");
};
// First, check if this is a mirror repository
const repoResponse = await httpGet(
`${client.url}/api/v1/repos/${owner}/${repo}`,
@@ -3964,7 +4126,7 @@ export async function archiveGiteaRepo(
if (!repoResponse.data) {
console.warn(`[Archive] Repository ${owner}/${repo} not found in Gitea. Skipping.`);
return;
return { archivedName: null };
}
if (repoResponse.data?.mirror) {
@@ -3986,7 +4148,7 @@ export async function archiveGiteaRepo(
normalizedName.startsWith('archived-')
) {
console.log(`[Archive] Repository ${owner}/${repo} already marked as archived. Skipping.`);
return;
return { archivedName: currentName };
}
// Use a safe prefix and sanitize the name to satisfy AlphaDashDot rule
@@ -4031,7 +4193,7 @@ export async function archiveGiteaRepo(
// If this also fails, log but don't throw - data remains preserved
console.error(`[Archive] Failed to rename mirror repository ${owner}/${repo}:`, e2);
console.log(`[Archive] Repository ${owner}/${repo} remains accessible but not marked as archived`);
return;
return { archivedName: null };
}
}
@@ -4055,6 +4217,8 @@ export async function archiveGiteaRepo(
// Non-critical - repo is still preserved even if we can't change interval
console.debug(`[Archive] Could not disable mirror interval (non-critical):`, intervalError);
}
return { archivedName };
} else {
// For non-mirror repositories, use Gitea's native archive feature
// This makes the repository read-only but preserves all data
@@ -4075,10 +4239,11 @@ export async function archiveGiteaRepo(
// If archive fails, log but data is still preserved in Gitea
console.error(`[Archive] Failed to archive repository ${owner}/${repo}: ${response.status}`);
console.log(`[Archive] Repository ${owner}/${repo} remains accessible but not marked as archived`);
return;
return { archivedName: null };
}
console.log(`[Archive] Successfully archived repository ${owner}/${repo} (now read-only)`);
return { archivedName: null };
}
} catch (error) {
// Even on error, the repository data is preserved in Gitea
@@ -4086,5 +4251,6 @@ export async function archiveGiteaRepo(
console.error(`[Archive] Could not mark repository ${owner}/${repo} as archived:`, error);
console.log(`[Archive] Repository ${owner}/${repo} data is preserved but not marked as archived`);
// Don't throw - we want cleanup to continue for other repos
return { archivedName: null };
}
}
+231
View File
@@ -0,0 +1,231 @@
import { describe, expect, test } from "bun:test";
import { Octokit } from "@octokit/rest";
import {
applyConditionalRequests,
conditionalRequestCacheKey,
conditionalRequestTokenScope,
InMemoryConditionalRequestStore,
} from "@/lib/github-conditional-requests";
function jsonResponse(
body: unknown,
init: { status: number; etag?: string; link?: string },
): Response {
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (init.etag) headers.etag = init.etag;
if (init.link) headers.link = init.link;
return new Response(init.status === 304 ? null : JSON.stringify(body), {
status: init.status,
headers,
});
}
function clientWithFetch(fetch: (url: string, init: any) => Promise<Response>) {
return new Octokit({
auth: "test-token",
request: { fetch: fetch as unknown as typeof globalThis.fetch },
});
}
describe("applyConditionalRequests", () => {
test("replays If-None-Match and serves the cached body on 304", async () => {
const etag = 'W/"abc123"';
const pulls = [{ number: 1, title: "one" }];
let calls = 0;
let secondRequestIfNoneMatch: string | undefined;
const octokit = clientWithFetch(async (_url, init) => {
calls += 1;
if (calls === 1) {
return jsonResponse(pulls, { status: 200, etag });
}
secondRequestIfNoneMatch = init?.headers?.["if-none-match"];
return jsonResponse(null, { status: 304, etag });
});
applyConditionalRequests(octokit, {
store: new InMemoryConditionalRequestStore(),
scope: "user-1",
});
const first = await octokit.request("GET /repos/{owner}/{repo}/pulls", {
owner: "o",
repo: "r",
state: "all",
});
const second = await octokit.request("GET /repos/{owner}/{repo}/pulls", {
owner: "o",
repo: "r",
state: "all",
});
expect(calls).toBe(2);
expect(secondRequestIfNoneMatch).toBe(etag);
// A 304 is transparently presented as a 200 carrying the cached body.
expect(second.status).toBe(200);
expect(second.data).toEqual(first.data);
expect(second.data).toEqual(pulls);
});
test("does not attach conditional headers to non-GET requests", async () => {
let sawIfNoneMatch = false;
const octokit = clientWithFetch(async (_url, init) => {
if (init?.headers?.["if-none-match"]) sawIfNoneMatch = true;
return jsonResponse({ ok: true }, { status: 201 });
});
applyConditionalRequests(octokit, {
store: new InMemoryConditionalRequestStore(),
scope: "user-1",
});
await octokit.request("POST /repos/{owner}/{repo}/pulls", {
owner: "o",
repo: "r",
title: "x",
head: "a",
base: "b",
});
expect(sawIfNoneMatch).toBe(false);
});
test("makes a full request again when the response carries no ETag", async () => {
let calls = 0;
let secondRequestIfNoneMatch: string | undefined;
const octokit = clientWithFetch(async (_url, init) => {
calls += 1;
if (calls >= 2) secondRequestIfNoneMatch = init?.headers?.["if-none-match"];
return jsonResponse([{ number: 1 }], { status: 200 }); // no etag
});
applyConditionalRequests(octokit, {
store: new InMemoryConditionalRequestStore(),
scope: "user-1",
});
await octokit.request("GET /repos/{owner}/{repo}/pulls", {
owner: "o",
repo: "r",
});
const second = await octokit.request("GET /repos/{owner}/{repo}/pulls", {
owner: "o",
repo: "r",
});
expect(calls).toBe(2);
expect(secondRequestIfNoneMatch).toBeUndefined();
expect(second.status).toBe(200);
});
test("keys cache entries per expanded URL so repos do not collide", async () => {
// Each resource gets its own ETag; the stub returns 304 only when the
// presented If-None-Match matches the ETag issued for that exact URL.
const etagByUrl = new Map<string, string>();
let notModifiedCount = 0;
const octokit = clientWithFetch(async (url, init) => {
const ifNoneMatch = init?.headers?.["if-none-match"] as
| string
| undefined;
let etag = etagByUrl.get(url);
if (!etag) {
etag = `W/"etag-${etagByUrl.size + 1}"`;
etagByUrl.set(url, etag);
}
if (ifNoneMatch && ifNoneMatch === etag) {
notModifiedCount += 1;
return jsonResponse(null, { status: 304, etag });
}
return jsonResponse([{ url }], { status: 200, etag });
});
applyConditionalRequests(octokit, {
store: new InMemoryConditionalRequestStore(),
scope: "user-1",
});
const get = (owner: string, repo: string) =>
octokit.request("GET /repos/{owner}/{repo}/pulls", {
owner,
repo,
state: "all",
});
const first = await get("alpha", "one");
await get("beta", "two");
const third = await get("alpha", "one");
// The repeated first request must revalidate against its OWN ETag and come
// back as a 304 cache hit, not be clobbered by the second repo's entry.
expect(notModifiedCount).toBe(1);
expect(third.status).toBe(200);
expect(third.data).toEqual(first.data);
});
test("isolates cached entries by scope", () => {
const store = new InMemoryConditionalRequestStore();
store.set(conditionalRequestCacheKey("user-1", "GET", "/x"), {
etag: "a",
data: 1,
status: 200,
});
expect(
store.get(conditionalRequestCacheKey("user-2", "GET", "/x")),
).toBeUndefined();
expect(
store.get(conditionalRequestCacheKey("user-1", "GET", "/x"))?.etag,
).toBe("a");
});
test("evicts oldest entries once the byte budget is exceeded", () => {
// Each entry is ~126 bytes (120-char body + quotes + etag), budget of 300
// fits two: inserting the third must evict the oldest.
const store = new InMemoryConditionalRequestStore(5000, 300);
const entry = (etag: string) => ({
etag,
data: "x".repeat(120),
status: 200,
});
store.set("k1", entry("e1"));
store.set("k2", entry("e2"));
store.set("k3", entry("e3"));
expect(store.get("k1")).toBeUndefined();
expect(store.get("k2")?.etag).toBe("e2");
expect(store.get("k3")?.etag).toBe("e3");
});
test("does not cache a single response larger than the byte budget", () => {
const store = new InMemoryConditionalRequestStore(5000, 100);
store.set("small", { etag: "e", data: "ok", status: 200 });
store.set("huge", { etag: "e", data: "x".repeat(500), status: 200 });
expect(store.get("huge")).toBeUndefined();
// The oversized body must not have evicted entries that do fit.
expect(store.get("small")?.etag).toBe("e");
});
test("frees the byte budget when an entry is overwritten", () => {
const store = new InMemoryConditionalRequestStore(5000, 300);
const entry = (etag: string) => ({
etag,
data: "x".repeat(120),
status: 200,
});
// Overwriting the same key must not double-count its bytes, so two more
// distinct keys still fit alongside it.
store.set("k1", entry("e1"));
store.set("k1", entry("e1b"));
store.set("k2", entry("e2"));
expect(store.get("k1")?.etag).toBe("e1b");
expect(store.get("k2")?.etag).toBe("e2");
});
});
describe("conditionalRequestTokenScope", () => {
test("is deterministic, distinct per token, and never leaks the token", () => {
const a = conditionalRequestTokenScope("ghp_token_a");
const b = conditionalRequestTokenScope("ghp_token_b");
expect(a).toBe(conditionalRequestTokenScope("ghp_token_a"));
expect(a).not.toBe(b);
expect(a).not.toContain("ghp_token_a");
expect(a.startsWith("token:")).toBe(true);
});
});
+189
View File
@@ -0,0 +1,189 @@
import { createHash } from "node:crypto";
import type { Octokit } from "@octokit/rest";
/**
* A single cached GitHub response, retained so that a later conditional request
* which comes back `304 Not Modified` can be served from memory instead of
* downloading the full body again. Only the fields the mirror actually consumes
* are stored.
*/
export interface CachedResponse {
etag: string;
data: unknown;
status: number;
/** Preserved so `octokit.paginate` can keep following pages on a cache hit. */
link?: string;
}
export interface ConditionalRequestStore {
get(key: string): CachedResponse | undefined;
set(key: string, value: CachedResponse): void;
}
/**
* Process-lifetime, bounded in-memory store. Gitea Mirror runs as a long-lived
* server whose scheduler re-syncs on an interval, so keeping ETags in memory is
* enough to turn each repeated poll into a cheap `304`. Bounded both by entry
* count and by total body bytes: a single 100-PR page can run to a few hundred
* KB, so a count cap alone would let the store grow to gigabytes. Evicting an
* entry only costs one full response on the next sync.
*/
export class InMemoryConditionalRequestStore implements ConditionalRequestStore {
private readonly entries = new Map<
string,
{ value: CachedResponse; bytes: number }
>();
private totalBytes = 0;
constructor(
private readonly maxEntries = 5000,
private readonly maxTotalBytes = 64 * 1024 * 1024,
) {}
get(key: string): CachedResponse | undefined {
return this.entries.get(key)?.value;
}
set(key: string, value: CachedResponse): void {
const bytes = approximateResponseBytes(value);
// Delete-then-set so Map iteration order approximates LRU for eviction.
const existing = this.entries.get(key);
if (existing) {
this.totalBytes -= existing.bytes;
this.entries.delete(key);
}
// A body larger than the whole budget would just evict everything else and
// then fail to fit anyway; re-fetching it each sync is the cheaper failure.
if (bytes > this.maxTotalBytes) return;
this.entries.set(key, { value, bytes });
this.totalBytes += bytes;
while (
this.entries.size > this.maxEntries ||
this.totalBytes > this.maxTotalBytes
) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined) break;
const evicted = this.entries.get(oldest);
this.entries.delete(oldest);
if (evicted) this.totalBytes -= evicted.bytes;
}
}
}
/**
* Rough byte cost of a cached response: the serialized body plus headers we
* retain. Precision doesn't matter here, only that eviction tracks real memory
* pressure instead of entry count.
*/
function approximateResponseBytes(value: CachedResponse): number {
const body = value.data === undefined ? "" : JSON.stringify(value.data) ?? "";
return body.length + value.etag.length + (value.link?.length ?? 0);
}
/**
* Shared across every client so ETags survive the per-sync re-creation of the
* Octokit instance (a fresh client is built on each scheduled sync).
*/
export const defaultConditionalRequestStore =
new InMemoryConditionalRequestStore();
export function conditionalRequestCacheKey(
scope: string,
method: string,
url: string,
): string {
return `${scope} ${method.toUpperCase()} ${url}`;
}
/**
* Cache scope for clients created with only a token (e.g. the metadata
* mirroring path builds clients via `createGitHubClient(token)` with no user
* id or username). Without this, all such clients would share one "default"
* scope across users. Hashed so the raw token never appears in cache keys.
*/
export function conditionalRequestTokenScope(token: string): string {
return `token:${createHash("sha256").update(token).digest("hex").slice(0, 16)}`;
}
/**
* Adds ETag-based conditional requests to an Octokit instance. For every GET it
* replays the previously stored `If-None-Match`; GitHub then answers `304 Not
* Modified` — which does not count against the token's primary rate limit —
* whenever nothing changed, and the cached body is returned in place of a full
* re-download. Non-GET requests are passed through untouched.
*
* The scope isolates cache entries per token/user so one account never reads
* another's cached data.
*/
export function applyConditionalRequests(
octokit: Octokit,
options: { store?: ConditionalRequestStore; scope?: string } = {},
): void {
// Some tests stub Octokit without the hook system; skip wiring in that case.
if (typeof (octokit as any)?.hook?.wrap !== "function") return;
const store = options.store ?? defaultConditionalRequestStore;
const scope = options.scope ?? "default";
octokit.hook.wrap("request", async (request: any, requestOptions: any): Promise<any> => {
const method = String(requestOptions.method ?? "GET").toUpperCase();
if (method !== "GET") {
return request(requestOptions);
}
// Build the key from the expanded absolute URL, not the route template.
// Inside the hook `requestOptions.url` is still `/repos/{owner}/{repo}/...`,
// so keying on it would collapse every repo into a single entry per user +
// endpoint and stop the 304 path from firing once more than one repo syncs.
// `octokit.request.endpoint.parse` expands the route (owner/repo + query);
// the chained `request` argument has no `.endpoint`, so it must come from
// `octokit.request`.
const parseEndpoint = (octokit as any)?.request?.endpoint?.parse;
const expandedUrl =
typeof parseEndpoint === "function"
? parseEndpoint(requestOptions).url
: requestOptions.url;
const key = conditionalRequestCacheKey(scope, method, expandedUrl);
const cached = store.get(key);
if (cached?.etag) {
requestOptions.headers = {
...requestOptions.headers,
"if-none-match": cached.etag,
};
}
try {
const response = await request(requestOptions);
const etag = response?.headers?.etag;
if (etag && response.status >= 200 && response.status < 300) {
store.set(key, {
etag,
data: response.data,
status: response.status,
link: response.headers?.link,
});
}
return response;
} catch (error: any) {
// GitHub answered "not modified": reuse the stored body, presented as a
// 200 so callers (including octokit.paginate) are unaffected.
if (error?.status === 304 && cached) {
return {
status: 200,
url: expandedUrl,
headers: {
...(error.response?.headers ?? {}),
etag: cached.etag,
...(cached.link ? { link: cached.link } : {}),
},
data: cached.data,
} as any;
}
throw error;
}
});
}
+12
View File
@@ -3,6 +3,10 @@ import type { GitRepo, RepoStatus } from "@/types/Repository";
import { Octokit } from "@octokit/rest";
import { throttling } from "@octokit/plugin-throttling";
import type { Config } from "@/types/config";
import {
applyConditionalRequests,
conditionalRequestTokenScope,
} from "@/lib/github-conditional-requests";
// Conditionally import rate limit manager (not available in test environment)
let RateLimitManager: any = null;
let publishEvent: any = null;
@@ -202,6 +206,14 @@ export function createGitHubClient(
});
}
// Reuse ETags across syncs so unchanged lists (e.g. pull requests) come back
// as rate-limit-free 304s instead of full downloads on every scheduled poll.
// Token-hash fallback keeps clients created with only a token (metadata
// mirroring path) isolated per user instead of sharing one scope.
applyConditionalRequests(octokit, {
scope: userId ?? username ?? conditionalRequestTokenScope(token),
});
return octokit;
}
+113
View File
@@ -71,6 +71,58 @@ describe("sendNotification", () => {
expect(url).toBe("http://apprise:8000/notify/my-token");
});
test("sends gotify notification when provider is gotify", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "gotify",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
gotify: {
url: "https://gotify.example.com",
token: "my-app-token",
priority: 5,
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
expect(opts.headers["X-Gotify-Key"]).toBe("my-app-token");
});
test("sends webhook notification when provider is webhook", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "webhook",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
webhook: {
url: "https://example.com/hooks/gitea-mirror",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe("https://example.com/hooks/gitea-mirror");
const body = JSON.parse(opts.body);
expect(body.title).toBe("Test");
expect(body.type).toBe("sync_success");
});
test("does not throw when fetch fails", async () => {
mockFetch = mock(() => Promise.reject(new Error("Network error")));
globalThis.fetch = mockFetch as any;
@@ -140,9 +192,70 @@ describe("sendNotification", () => {
expect(mockFetch).not.toHaveBeenCalled();
});
test("skips notification when gotify token is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "gotify",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
gotify: {
url: "https://gotify.example.com",
token: "",
priority: 5,
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).not.toHaveBeenCalled();
});
test("skips notification when webhook URL is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "webhook",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
webhook: {
url: "",
},
};
await sendNotification(config, {
title: "Test",
message: "Test message",
type: "sync_success",
});
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe("testNotification", () => {
test("returns error when webhook URL is missing", async () => {
const config: NotificationConfig = {
enabled: true,
provider: "webhook",
notifyOnSyncError: true,
notifyOnSyncSuccess: true,
notifyOnNewRepo: false,
webhook: {
url: "",
},
};
const result = await testNotification(config);
expect(result.success).toBe(false);
expect(result.error).toContain("Webhook URL");
});
test("returns success when notification is sent", async () => {
const config: NotificationConfig = {
enabled: true,
+36
View File
@@ -2,6 +2,8 @@ import type { NotificationConfig } from "@/types/config";
import type { NotificationEvent } from "./providers/ntfy";
import { sendNtfyNotification } from "./providers/ntfy";
import { sendAppriseNotification } from "./providers/apprise";
import { sendGotifyNotification } from "./providers/gotify";
import { sendWebhookNotification } from "./providers/webhook";
import { db, configs } from "@/lib/db";
import { eq, sql } from "drizzle-orm";
import { decrypt } from "@/lib/utils/encryption";
@@ -52,6 +54,18 @@ export async function sendNotification(
return;
}
await sendAppriseNotification(config.apprise, event);
} else if (config.provider === "gotify") {
if (!config.gotify?.url || !config.gotify?.token) {
console.warn("[NotificationService] Gotify URL or token is not configured, skipping notification");
return;
}
await sendGotifyNotification(config.gotify, event);
} else if (config.provider === "webhook") {
if (!config.webhook?.url) {
console.warn("[NotificationService] Webhook URL is not configured, skipping notification");
return;
}
await sendWebhookNotification(config.webhook, event);
}
} catch (error) {
console.error("[NotificationService] Failed to send notification:", error);
@@ -83,6 +97,16 @@ export async function testNotification(
return { success: false, error: "Apprise URL and token are required" };
}
await sendAppriseNotification(notificationConfig.apprise, event);
} else if (notificationConfig.provider === "gotify") {
if (!notificationConfig.gotify?.url || !notificationConfig.gotify?.token) {
return { success: false, error: "Gotify URL and token are required" };
}
await sendGotifyNotification(notificationConfig.gotify, event);
} else if (notificationConfig.provider === "webhook") {
if (!notificationConfig.webhook?.url) {
return { success: false, error: "Webhook URL is required" };
}
await sendWebhookNotification(notificationConfig.webhook, event);
} else {
return { success: false, error: `Unknown provider: ${notificationConfig.provider}` };
}
@@ -166,6 +190,18 @@ export async function triggerJobNotification({
token: decrypt(decryptedConfig.apprise.token),
};
}
if (decryptedConfig.provider === "gotify" && decryptedConfig.gotify?.token) {
decryptedConfig.gotify = {
...decryptedConfig.gotify,
token: decrypt(decryptedConfig.gotify.token),
};
}
if (decryptedConfig.provider === "webhook" && decryptedConfig.webhook?.secret) {
decryptedConfig.webhook = {
...decryptedConfig.webhook,
secret: decrypt(decryptedConfig.webhook.secret),
};
}
// Build event
const repoLabel = repositoryName || organizationName || "Unknown";
+106
View File
@@ -0,0 +1,106 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { sendGotifyNotification } from "./gotify";
import type { NotificationEvent } from "./ntfy";
import type { GotifyConfig } from "@/types/config";
describe("sendGotifyNotification", () => {
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
const baseConfig: GotifyConfig = {
url: "https://gotify.example.com",
token: "AbCdEf123456",
priority: 5,
};
const baseEvent: NotificationEvent = {
title: "Test Notification",
message: "This is a test",
type: "sync_success",
};
test("constructs correct URL from config", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
});
test("strips trailing slash from URL", async () => {
await sendGotifyNotification(
{ ...baseConfig, url: "https://gotify.example.com/" },
baseEvent
);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe("https://gotify.example.com/message");
});
test("sends token via X-Gotify-Key header", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["X-Gotify-Key"]).toBe("AbCdEf123456");
});
test("sends title and message in JSON body", async () => {
await sendGotifyNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.title).toBe("Test Notification");
expect(body.message).toBe("This is a test");
});
test("uses priority 8 for sync_error events", async () => {
const errorEvent: NotificationEvent = {
...baseEvent,
type: "sync_error",
};
await sendGotifyNotification(baseConfig, errorEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(8);
});
test("uses config priority for non-error events", async () => {
await sendGotifyNotification(
{ ...baseConfig, priority: 2 },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(2);
});
test("defaults to priority 5 when not configured", async () => {
await sendGotifyNotification(
{ ...baseConfig, priority: undefined as any },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.priority).toBe(5);
});
test("throws on non-200 response", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("unauthorized", { status: 401 }))
);
globalThis.fetch = mockFetch as any;
expect(
sendGotifyNotification(baseConfig, baseEvent)
).rejects.toThrow("Gotify error: 401");
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { GotifyConfig } from "@/types/config";
import type { NotificationEvent } from "./ntfy";
export async function sendGotifyNotification(config: GotifyConfig, event: NotificationEvent): Promise<void> {
const url = `${config.url.replace(/\/$/, "")}/message`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Gotify-Key": config.token,
};
const body = JSON.stringify({
title: event.title,
message: event.message,
priority: event.type === "sync_error" ? 8 : (config.priority ?? 5),
});
const resp = await fetch(url, { method: "POST", body, headers });
if (!resp.ok) throw new Error(`Gotify error: ${resp.status} ${await resp.text()}`);
}
+76
View File
@@ -0,0 +1,76 @@
import { describe, test, expect, beforeEach, mock } from "bun:test";
import { createHmac } from "node:crypto";
import { sendWebhookNotification } from "./webhook";
import type { NotificationEvent } from "./ntfy";
import type { WebhookConfig } from "@/types/config";
describe("sendWebhookNotification", () => {
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock(() =>
Promise.resolve(new Response("ok", { status: 200 }))
);
globalThis.fetch = mockFetch as any;
});
const baseConfig: WebhookConfig = {
url: "https://example.com/hooks/gitea-mirror",
};
const baseEvent: NotificationEvent = {
title: "Test Notification",
message: "This is a test",
type: "sync_success",
};
test("posts to the configured URL", async () => {
await sendWebhookNotification(baseConfig, baseEvent);
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe("https://example.com/hooks/gitea-mirror");
expect(opts.method).toBe("POST");
expect(opts.headers["Content-Type"]).toBe("application/json");
});
test("sends title, message, type, and timestamp in JSON body", async () => {
await sendWebhookNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
const body = JSON.parse(opts.body);
expect(body.title).toBe("Test Notification");
expect(body.message).toBe("This is a test");
expect(body.type).toBe("sync_success");
expect(new Date(body.timestamp).getTime()).not.toBeNaN();
});
test("omits signature header when no secret is configured", async () => {
await sendWebhookNotification(baseConfig, baseEvent);
const [, opts] = mockFetch.mock.calls[0];
expect(opts.headers["X-Webhook-Signature"]).toBeUndefined();
});
test("signs the body with HMAC-SHA256 when a secret is configured", async () => {
await sendWebhookNotification(
{ ...baseConfig, secret: "my-secret" },
baseEvent
);
const [, opts] = mockFetch.mock.calls[0];
const expected = createHmac("sha256", "my-secret").update(opts.body).digest("hex");
expect(opts.headers["X-Webhook-Signature"]).toBe(`sha256=${expected}`);
});
test("throws on non-200 response", async () => {
mockFetch = mock(() =>
Promise.resolve(new Response("unauthorized", { status: 401 }))
);
globalThis.fetch = mockFetch as any;
expect(
sendWebhookNotification(baseConfig, baseEvent)
).rejects.toThrow("Webhook error: 401");
});
});
+21
View File
@@ -0,0 +1,21 @@
import { createHmac } from "node:crypto";
import type { WebhookConfig } from "@/types/config";
import type { NotificationEvent } from "./ntfy";
export async function sendWebhookNotification(config: WebhookConfig, event: NotificationEvent): Promise<void> {
const body = JSON.stringify({
title: event.title,
message: event.message,
type: event.type,
timestamp: new Date().toISOString(),
});
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (config.secret) {
const signature = createHmac("sha256", config.secret).update(body).digest("hex");
headers["X-Webhook-Signature"] = `sha256=${signature}`;
}
const resp = await fetch(config.url, { method: "POST", body, headers });
if (!resp.ok) throw new Error(`Webhook error: ${resp.status} ${await resp.text()}`);
}
+14 -16
View File
@@ -4,9 +4,10 @@
*/
import { findInterruptedJobs, resumeInterruptedJob } from './helpers';
import { resetStuckMirrorStatuses } from './stuck-status-recovery';
import { db, repositories, organizations, mirrorJobs, configs } from './db';
import { eq, and, lt, inArray, sql } from 'drizzle-orm';
import { mirrorGithubRepoToGitea, mirrorGitHubOrgRepoToGiteaOrg, syncGiteaRepo } from './gitea';
import { mirrorGithubRepoToGitea, syncGiteaRepo } from './gitea';
import { createGitHubClient } from './github';
import { processWithResilience } from './utils/concurrency';
import { repositoryVisibilityEnum, repoStatusEnum } from '@/types/Repository';
@@ -121,6 +122,13 @@ export async function initializeRecovery(options: {
// Clean up stale jobs first
await cleanupStaleJobs();
// Reset repositories/organizations stuck in an in-flight status
// ("mirroring"/"syncing") with no live process behind them (issue
// #339). Job-level recovery below only reconciles mirrorJobs rows;
// repository.status is never reconciled by it, so rows orphaned by
// a crash would otherwise stay "syncing" forever. Never throws.
await resetStuckMirrorStatuses();
// Find interrupted jobs (with per-job logging — this is the
// active recovery path that will immediately try to resume them)
const interruptedJobs = await findInterruptedJobs({ logFound: true });
@@ -290,21 +298,11 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
mirroredLocation: repo.mirroredLocation || "",
};
// Mirror the repository based on whether it's in an organization
if (repo.organization && config.giteaConfig.preserveOrgStructure) {
await mirrorGitHubOrgRepoToGiteaOrg({
config,
octokit,
orgName: repo.organization,
repository: repoData,
});
} else {
await mirrorGithubRepoToGitea({
octokit,
repository: repoData,
config,
});
}
await mirrorGithubRepoToGitea({
octokit,
repository: repoData,
config,
});
return repo;
},
@@ -0,0 +1,52 @@
/**
* Unit tests for the pure orphan-verdict decision logic — regression coverage
* for issue #331's root cause: `identifyOrphanedRepositories()` treated a DB
* repository as "orphaned" the moment it was missing from a single bulk
* GitHub fetch (owned+collaborator+org repos, plus starred repos). That bulk
* fetch can be transiently incomplete (rate-limit timing, GraphQL star-list
* pagination quirks, org-allowlist edge cases, etc.), producing false
* positives that got archived (renamed to `archived-{name}` in Gitea/Forgejo)
* even though the repo was never actually removed/unstarred on GitHub.
*
* The fix adds a second, targeted confirmation call for any repo that merely
* *looks* orphaned from the bulk list before finalizing it as such.
* `resolveOrphanVerdict` is the pure decision function extracted from that
* flow (similar in spirit to classifyAssetsForReconciliation /
* classifyReleasesForReconciliation in gitea-releases.test.ts) so the
* decision logic itself is unit-testable without hitting the DB or octokit.
*/
import { describe, test, expect } from "bun:test";
import { resolveOrphanVerdict } from "./repository-cleanup-service";
describe("resolveOrphanVerdict", () => {
test("repo present in the bulk fetch is never orphaned, regardless of the direct check", () => {
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: true, directCheckConfirmsGone: true })
).toBe(false);
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: true, directCheckConfirmsGone: false })
).toBe(false);
});
test("repo missing from the bulk fetch is orphaned only when the direct check confirms it's gone (404)", () => {
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: false, directCheckConfirmsGone: true })
).toBe(true);
});
test("repo missing from the bulk fetch but still found by the direct check is NOT orphaned (bulk fetch was incomplete)", () => {
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: false, directCheckConfirmsGone: false })
).toBe(false);
});
test("repo missing from the bulk fetch whose direct check itself failed (network error, rate limit, etc.) fails safe as NOT orphaned", () => {
// directCheckConfirmsGone is only true for a clean, explicit 404 — any
// other outcome (including a failed verification call) is represented
// as false by the caller, which must resolve to "not orphaned" here.
expect(
resolveOrphanVerdict({ fullNameFoundInBulkList: false, directCheckConfirmsGone: false })
).toBe(false);
});
});
+143 -9
View File
@@ -15,6 +15,37 @@ import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
let cleanupInterval: NodeJS.Timeout | null = null;
let isCleanupRunning = false;
/**
* Decide whether a DB repository that appears to be missing from the bulk
* GitHub fetch should actually be treated as orphaned.
*
* The bulk fetch (owned+collaborator+org repos, plus starred repos) that
* feeds `fullNameFoundInBulkList` can be transiently incomplete — rate-limit
* timing, GraphQL star-list pagination quirks, org-allowlist edge cases,
* etc. — so a repo missing from it is only a *candidate*, not a confirmed
* orphan. It is only orphaned when a direct, targeted GitHub call ALSO
* confirms the repo is gone (a clean 404). Any other outcome — the repo
* still exists, or the direct check itself failed for some other reason
* (network error, rate limit, 5xx, timeout) — must NOT be treated as
* orphaned; this fails safe and matches the existing fail-safe philosophy
* already in this module for GitHub API errors.
*
* Pure/exported so the decision logic is unit-testable without hitting the
* DB or octokit (see repository-cleanup-service.test.ts).
*/
export function resolveOrphanVerdict({
fullNameFoundInBulkList,
directCheckConfirmsGone,
}: {
fullNameFoundInBulkList: boolean;
directCheckConfirmsGone: boolean;
}): boolean {
if (fullNameFoundInBulkList) {
return false;
}
return directCheckConfirmsGone;
}
/**
* Identify orphaned repositories for a user
* These are repositories that exist in our database (and likely in Gitea)
@@ -80,8 +111,12 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
.where(eq(repositories.userId, userId));
// Only identify repositories as orphaned if we successfully accessed GitHub
// This prevents false positives when GitHub is down or account is inaccessible
const orphanedRepos = dbRepos.filter(repo => {
// This prevents false positives when GitHub is down or account is inaccessible.
//
// First pass (sync, cheap): filter down to repos that merely *look*
// orphaned based on map membership against the single bulk fetch above.
// This is the false-positive-prone signal — see resolveOrphanVerdict.
const candidateOrphans = dbRepos.filter(repo => {
// Skip repositories we've already archived/preserved
if (repo.status === 'archived' || repo.isArchived) {
console.log(`[Repository Cleanup] Skipping ${repo.fullName} - already archived`);
@@ -97,6 +132,8 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
const githubRepo = githubReposByFullName.get(repo.fullName);
if (!githubRepo) {
// Missing from the bulk list — candidate for direct confirmation below,
// not yet a confirmed orphan.
return true;
}
@@ -107,11 +144,93 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
return false;
});
if (candidateOrphans.length === 0) {
return [];
}
// Second pass (async, targeted): confirm each candidate directly against
// GitHub before finalizing it as orphaned. This only adds extra API calls
// for the (presumably small) set of repos that look orphaned, not for
// every repo, so it shouldn't meaningfully increase rate-limit pressure
// in the common case (few or no orphans per run). Promise.allSettled so
// one repo's verification failure can't block the others.
const verificationOutcomes = await Promise.allSettled(
candidateOrphans.map(async (repo) => {
if (repo.isStarred) {
try {
await octokit.rest.activity.checkRepoIsStarredByAuthenticatedUser({
owner: repo.owner,
repo: repo.name,
});
// Resolves (no throw) => still starred; the bulk star fetch
// missed it. Fail safe: do not treat as orphaned.
return { repo, directCheckConfirmsGone: false };
} catch (starError: any) {
if (starError?.status === 404) {
return { repo, directCheckConfirmsGone: true };
}
console.warn(
`[Repository Cleanup] Direct star-check for ${repo.fullName} failed with a non-404 error; skipping this cycle to be safe: ${
starError instanceof Error ? starError.message : String(starError)
}`
);
return { repo, directCheckConfirmsGone: false };
}
}
try {
await octokit.rest.repos.get({ owner: repo.owner, repo: repo.name });
// Resolves (no throw) => repo still exists; the bulk fetch missed
// it (e.g. an org-allowlist edge case). Fail safe: not orphaned.
return { repo, directCheckConfirmsGone: false };
} catch (repoError: any) {
if (repoError?.status === 404) {
return { repo, directCheckConfirmsGone: true };
}
console.warn(
`[Repository Cleanup] Direct existence check for ${repo.fullName} failed with a non-404 error; skipping this cycle to be safe: ${
repoError instanceof Error ? repoError.message : String(repoError)
}`
);
return { repo, directCheckConfirmsGone: false };
}
})
);
const orphanedRepos = verificationOutcomes
.map((outcome, index) => {
if (outcome.status !== 'fulfilled') {
const repo = candidateOrphans[index];
console.warn(
`[Repository Cleanup] Direct orphan verification threw unexpectedly for ${repo.fullName}; skipping this cycle to be safe: ${
outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)
}`
);
return null;
}
const { repo, directCheckConfirmsGone } = outcome.value;
const isOrphaned = resolveOrphanVerdict({
fullNameFoundInBulkList: false,
directCheckConfirmsGone,
});
if (!isOrphaned) {
return null;
}
console.log(
`[Repository Cleanup] Confirmed orphaned via direct GitHub check: ${repo.fullName}`
);
return repo;
})
.filter((repo): repo is (typeof dbRepos)[number] => repo !== null);
if (orphanedRepos.length > 0) {
console.log(`[Repository Cleanup] Found ${orphanedRepos.length} orphaned repositories for user ${userId}`);
}
return orphanedRepos;
} catch (error) {
console.error(`[Repository Cleanup] Error identifying orphaned repositories for user ${userId}:`, error);
@@ -185,15 +304,30 @@ async function handleOrphanedRepository(
// Non-fatal; continue with best guess
}
await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
// Update database status
await db.update(repositories).set({
const { archivedName } = await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
// Update database status. If the archive call renamed the repo in Gitea
// (mirror path), persist the new location so a subsequent "Manual Sync"
// (the UI's documented path for refreshing an archived mirror) can find
// it by its actual current name instead of the stale pre-rename one —
// otherwise syncGiteaRepoEnhanced looks up a name that no longer exists
// and Gitea returns HTTP 405 ("not a pull mirror").
//
// Only mirroredLocation gets the Gitea-side `archived-{name}` value.
// Do NOT write it into `name`: repositories.name is consumed as the
// GITHUB repo name elsewhere (release listing, force-push detection),
// and mirroredLocation alone is what syncGiteaRepoEnhanced resolves
// first when locating the Gitea mirror.
const dbUpdate: Record<string, any> = {
status: 'archived',
isArchived: true,
errorMessage: 'Repository archived - no longer in GitHub',
updatedAt: new Date(),
}).where(eq(repositories.id, repo.id));
};
if (archivedName && archivedName !== giteaRepoName) {
dbUpdate.mirroredLocation = `${giteaOwner}/${archivedName}`;
}
await db.update(repositories).set(dbUpdate).where(eq(repositories.id, repo.id));
// Create event
await publishEvent({
+14 -1
View File
@@ -15,6 +15,7 @@ import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForI
import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
import { createMirrorJob } from '@/lib/helpers';
import { getNextScheduledRun, isCronExpression, normalizeTimezone } from '@/lib/utils/schedule-utils';
import { resetStuckMirrorStatuses } from '@/lib/stuck-status-recovery';
let schedulerInterval: NodeJS.Timeout | null = null;
let isSchedulerRunning = false;
@@ -723,8 +724,20 @@ async function schedulerLoop(): Promise<void> {
}
isSchedulerRunning = true;
try {
// Heal repositories/organizations stuck in an in-flight status
// ("mirroring"/"syncing") from a crash or restart (issue #339). Runs on
// every tick, before the enabled-config filtering, so stuck rows are
// reset even for users without scheduling enabled. Never throws.
const stuckReset = await resetStuckMirrorStatuses();
if (stuckReset.repositories > 0 || stuckReset.organizations > 0) {
console.log(
`[Scheduler] Reset ${stuckReset.repositories} stuck repositor${stuckReset.repositories === 1 ? 'y' : 'ies'} ` +
`and ${stuckReset.organizations} stuck organization(s) from in-flight status to "failed"`
);
}
// Get all active configurations with scheduling enabled
const activeConfigs = await db
.select()
+197
View File
@@ -0,0 +1,197 @@
/**
* Tests for stuck in-flight status recovery (issue #339).
*
* Repositories interrupted mid-mirror/mid-sync (container restart, OOM,
* crash) were left stuck at "mirroring"/"syncing" forever: the scheduler's
* sync pool never selects those statuses, job-level recovery only reconciles
* mirrorJobs rows (and the scheduler path creates none), and the UI disables
* the Sync button for in-flight statuses.
*
* The decision logic is tested directly as pure functions. The wiring into
* the scheduler loop, initializeRecovery, and the startup-recovery script is
* asserted by reading the source (same pattern as
* gitea-mirror-failure-recovery.test.ts) because behavioral tests of those
* modules require process-wide module mocks that pollute other test files.
*/
import { describe, test, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
IN_FLIGHT_REPO_STATUSES,
IN_FLIGHT_ORG_STATUSES,
STUCK_IN_FLIGHT_THRESHOLD_MS,
computeStuckStatusCutoff,
isStuckInFlight,
buildStuckResetErrorMessage,
buildStuckResetUpdate,
resetStuckMirrorStatuses,
} from "./stuck-status-recovery";
const HOUR = 60 * 60 * 1000;
describe("computeStuckStatusCutoff", () => {
test("uses process start as cutoff while the process is younger than the threshold", () => {
// Process started 10 minutes ago; now - 2h would reach back BEFORE the
// process started. The cutoff must clamp to process start so rows written
// by this process are never considered stuck.
const processStart = new Date("2026-07-16T10:00:00Z");
const now = new Date("2026-07-16T10:10:00Z");
const cutoff = computeStuckStatusCutoff(now, processStart);
expect(cutoff.getTime()).toBe(processStart.getTime());
});
test("uses now - threshold once the process has been up longer than the threshold", () => {
const processStart = new Date("2026-07-16T00:00:00Z");
const now = new Date("2026-07-16T10:00:00Z"); // up for 10 hours
const cutoff = computeStuckStatusCutoff(now, processStart);
expect(cutoff.getTime()).toBe(now.getTime() - STUCK_IN_FLIGHT_THRESHOLD_MS);
});
test("honors a custom threshold", () => {
const processStart = new Date("2026-07-16T00:00:00Z");
const now = new Date("2026-07-16T10:00:00Z");
const cutoff = computeStuckStatusCutoff(now, processStart, 30 * 60 * 1000);
expect(cutoff.getTime()).toBe(now.getTime() - 30 * 60 * 1000);
});
test("threshold matches the 2-hour staleness window from isRepoCurrentlyMirroring", () => {
expect(STUCK_IN_FLIGHT_THRESHOLD_MS).toBe(2 * HOUR);
});
});
describe("isStuckInFlight", () => {
const cutoff = new Date("2026-07-16T08:00:00Z");
const before = new Date("2026-07-16T05:00:00Z"); // older than cutoff
const after = new Date("2026-07-16T09:00:00Z"); // newer than cutoff
test("a 'syncing' repo last updated before the cutoff is stuck", () => {
expect(isStuckInFlight({ status: "syncing", updatedAt: before }, cutoff)).toBe(true);
});
test("a 'mirroring' repo last updated before the cutoff is stuck", () => {
expect(isStuckInFlight({ status: "mirroring", updatedAt: before }, cutoff)).toBe(true);
});
test("an in-flight repo updated after the cutoff is NOT stuck (live work is protected)", () => {
expect(isStuckInFlight({ status: "syncing", updatedAt: after }, cutoff)).toBe(false);
expect(isStuckInFlight({ status: "mirroring", updatedAt: after }, cutoff)).toBe(false);
});
test("terminal or queued statuses are never stuck, no matter how old", () => {
for (const status of ["synced", "mirrored", "failed", "imported", "pending-approval", "archived", "ignored"]) {
expect(isStuckInFlight({ status, updatedAt: before }, cutoff)).toBe(false);
}
});
test("an in-flight repo with no updatedAt is treated as stuck", () => {
expect(isStuckInFlight({ status: "syncing", updatedAt: null }, cutoff)).toBe(true);
});
test("in-flight status lists cover exactly the statuses set before network work", () => {
// gitea-enhanced.ts sets "syncing"; gitea.ts sets "mirroring" for repos
// and orgs. Nothing else is written as an intermediate status.
expect([...IN_FLIGHT_REPO_STATUSES].sort()).toEqual(["mirroring", "syncing"]);
expect([...IN_FLIGHT_ORG_STATUSES]).toEqual(["mirroring"]);
});
});
describe("buildStuckResetUpdate / buildStuckResetErrorMessage", () => {
test("resets to 'failed' with the provided timestamp", () => {
const now = new Date("2026-07-16T12:00:00Z");
const update = buildStuckResetUpdate("syncing", now);
expect(update.status).toBe("failed");
expect(update.updatedAt).toBe(now);
expect(update.errorMessage.length).toBeGreaterThan(0);
});
test("error message names the stuck status and the interrupted operation", () => {
const syncMessage = buildStuckResetErrorMessage("syncing");
expect(syncMessage).toContain('"syncing"');
expect(syncMessage).toContain("interrupted sync");
const mirrorMessage = buildStuckResetErrorMessage("mirroring");
expect(mirrorMessage).toContain('"mirroring"');
expect(mirrorMessage).toContain("interrupted mirror");
});
test("error message tells the user how work resumes", () => {
const message = buildStuckResetErrorMessage("syncing");
expect(message).toContain("next scheduled run");
expect(message).toContain("Retry");
});
});
describe("resetStuckMirrorStatuses", () => {
test("never throws even when the db layer misbehaves", async () => {
// The global test setup replaces @/lib/db with a stub whose select chain
// does not return arrays. The function must swallow that (it runs inside
// the scheduler loop and recovery — housekeeping must never block them)
// and report zero resets.
const result = await resetStuckMirrorStatuses({
cutoff: new Date(),
now: new Date(),
});
expect(result).toEqual({ repositories: 0, organizations: 0 });
});
});
describe("wiring (source regression)", () => {
const read = (...segments: string[]) =>
readFileSync(join(import.meta.dir, ...segments), "utf8");
test("scheduler loop resets stuck statuses on every tick, before config filtering", () => {
const source = read("scheduler-service.ts");
expect(source).toContain('from \'@/lib/stuck-status-recovery\'');
const loopStart = source.indexOf("async function schedulerLoop");
expect(loopStart).toBeGreaterThan(-1);
const loopBody = source.slice(loopStart);
const resetCall = loopBody.indexOf("resetStuckMirrorStatuses(");
const configQuery = loopBody.indexOf("const activeConfigs");
expect(resetCall).toBeGreaterThan(-1);
expect(configQuery).toBeGreaterThan(-1);
// Must run before the enabled-config filtering so stuck rows heal even
// for users without scheduling enabled.
expect(resetCall).toBeLessThan(configQuery);
});
test("initializeRecovery resets stuck statuses before resuming interrupted jobs", () => {
const source = read("recovery.ts");
expect(source).toContain("from './stuck-status-recovery'");
const initStart = source.indexOf("export async function initializeRecovery");
expect(initStart).toBeGreaterThan(-1);
const initBody = source.slice(initStart);
const resetCall = initBody.indexOf("resetStuckMirrorStatuses(");
const findJobs = initBody.indexOf("findInterruptedJobs(");
expect(resetCall).toBeGreaterThan(-1);
expect(findJobs).toBeGreaterThan(-1);
expect(resetCall).toBeLessThan(findJobs);
});
test("startup-recovery script resets stuck statuses BEFORE the no-jobs early exit", () => {
const source = read("..", "..", "scripts", "startup-recovery.ts");
const resetCall = source.indexOf("resetStuckMirrorStatuses(");
const needsRecoveryCheck = source.indexOf("hasJobsNeedingRecovery()");
expect(resetCall).toBeGreaterThan(-1);
expect(needsRecoveryCheck).toBeGreaterThan(-1);
// Scheduler-driven syncs create no resilient job records, so a crash
// mid-scheduled-sync leaves stuck repos but NO interrupted jobs. If the
// reset ran after the early exit it would be skipped in exactly the case
// that matters (issue #339).
expect(resetCall).toBeLessThan(needsRecoveryCheck);
});
});
+251
View File
@@ -0,0 +1,251 @@
/**
* Stuck in-flight status recovery (issue #339).
*
* Repositories are marked "mirroring"/"syncing" (organizations: "mirroring")
* in the DB before long-running network work starts. Every in-process failure
* path resets the status via catch blocks, but a hard interruption (container
* restart, OOM kill, host reboot, deploy) kills the process before the catch
* runs and leaves the row stuck in an in-flight status forever:
*
* - the scheduler's sync pool only selects mirrored/synced/failed/pending
* (scheduler-service.ts), so a stuck repo is never picked up again;
* - the UI disables the Sync/Mirror button for in-flight statuses
* (RepositoryTable.tsx), so the user cannot restart it either;
* - job-level recovery (recovery.ts) only reconciles mirrorJobs rows — the
* scheduler's sync path does not create resilient job records at all, so
* an interrupted scheduled sync leaves nothing for recovery to find.
*
* This module resets those orphaned rows to "failed" (with an explanatory
* errorMessage) so the scheduler's next run and the UI's Retry button can
* pick them up again. It is invoked from:
*
* 1. the scheduler loop (every minute) — heals stuck rows at runtime;
* 2. initializeRecovery() — heals on the startup/middleware recovery path;
* 3. scripts/startup-recovery.ts — heals before the app starts serving.
*
* Cutoff semantics: a row is only reset when its updatedAt is older than
* max(process start, now - 2h). Rows written by the current process are
* therefore never touched while the process is younger than the threshold,
* and long-lived processes use the same 2-hour staleness window that
* isRepoCurrentlyMirroring (gitea.ts) already applies to in-flight statuses.
*/
import { db, repositories, organizations } from "@/lib/db";
import { inArray, eq } from "drizzle-orm";
import { repoStatusEnum } from "@/types/Repository";
import { createMirrorJob } from "@/lib/helpers";
/** Repository statuses that indicate in-flight work. */
export const IN_FLIGHT_REPO_STATUSES = ["mirroring", "syncing"] as const;
/** Organization statuses that indicate in-flight work. */
export const IN_FLIGHT_ORG_STATUSES = ["mirroring"] as const;
/**
* How long an in-flight status may go without an update before it is
* considered stuck. Matches the 2-hour staleness window used by
* isRepoCurrentlyMirroring in gitea.ts.
*/
export const STUCK_IN_FLIGHT_THRESHOLD_MS = 2 * 60 * 60 * 1000;
/**
* Captured at module load, before any request handling can start a mirror or
* sync in this process. Any in-flight row older than this was written by a
* previous (crashed/restarted) process.
*/
const PROCESS_START = new Date();
export function getProcessStart(): Date {
return PROCESS_START;
}
/**
* Compute the cutoff before which an in-flight status counts as stuck:
* max(processStart, now - threshold).
*
* - Early in the process lifetime the cutoff is the process start, so rows
* stuck by a PREVIOUS process are reset immediately after a restart while
* rows written by THIS process are never touched.
* - Once the process has been up longer than the threshold, the cutoff is
* now - threshold, healing operations that stalled at runtime.
*/
export function computeStuckStatusCutoff(
now: Date,
processStart: Date = PROCESS_START,
thresholdMs: number = STUCK_IN_FLIGHT_THRESHOLD_MS
): Date {
return new Date(Math.max(processStart.getTime(), now.getTime() - thresholdMs));
}
/**
* Whether a row with an in-flight status counts as stuck relative to the
* cutoff. A missing updatedAt is treated as stuck (nothing can prove the
* work is still alive).
*/
export function isStuckInFlight(
row: { status: string; updatedAt: Date | null },
cutoff: Date
): boolean {
if (
!(IN_FLIGHT_REPO_STATUSES as readonly string[]).includes(row.status) &&
!(IN_FLIGHT_ORG_STATUSES as readonly string[]).includes(row.status)
) {
return false;
}
if (!row.updatedAt) {
return true;
}
return new Date(row.updatedAt).getTime() < cutoff.getTime();
}
/** Human-readable explanation stored in errorMessage on reset. */
export function buildStuckResetErrorMessage(previousStatus: string): string {
const operation = previousStatus === "syncing" ? "sync" : "mirror";
return (
`Detected interrupted ${operation}: status was stuck at "${previousStatus}" ` +
`(the application was likely restarted or crashed mid-operation). ` +
`The status was reset automatically; the next scheduled run will retry, ` +
`or you can use Retry to run it now.`
);
}
/** The DB update payload applied to a stuck row. */
export function buildStuckResetUpdate(
previousStatus: string,
now: Date
): { status: "failed"; errorMessage: string; updatedAt: Date } {
return {
status: repoStatusEnum.parse("failed") as "failed",
errorMessage: buildStuckResetErrorMessage(previousStatus),
updatedAt: now,
};
}
export interface StuckStatusResetResult {
repositories: number;
organizations: number;
}
/**
* Reset repositories and organizations stuck in an in-flight status to
* "failed" so the scheduler and the UI's Retry action can pick them up.
*
* Never throws: errors are logged and reflected as zero counts so callers
* (scheduler loop, recovery) are never blocked by this housekeeping step.
*/
export async function resetStuckMirrorStatuses(options: {
cutoff?: Date;
now?: Date;
} = {}): Promise<StuckStatusResetResult> {
const now = options.now ?? new Date();
const cutoff = options.cutoff ?? computeStuckStatusCutoff(now);
const result: StuckStatusResetResult = { repositories: 0, organizations: 0 };
// --- Repositories ---
try {
const inFlightRepos = await db
.select({
id: repositories.id,
userId: repositories.userId,
name: repositories.name,
fullName: repositories.fullName,
status: repositories.status,
updatedAt: repositories.updatedAt,
})
.from(repositories)
.where(inArray(repositories.status, [...IN_FLIGHT_REPO_STATUSES]));
const stuckRepos = inFlightRepos.filter((repo) =>
isStuckInFlight(repo, cutoff)
);
for (const repo of stuckRepos) {
await db
.update(repositories)
.set(buildStuckResetUpdate(repo.status, now))
.where(eq(repositories.id, repo.id));
// Activity-log entry + SSE event so the UI updates live. Push
// notifications are skipped: a restart can reset many rows at once
// and this is internal housekeeping, not a user-triggered failure.
await createMirrorJob({
userId: repo.userId,
repositoryId: repo.id,
repositoryName: repo.name,
message: `Reset stuck repository status: ${repo.fullName ?? repo.name}`,
details:
`Repository was stuck at "${repo.status}" since ` +
`${repo.updatedAt ? new Date(repo.updatedAt).toISOString() : "an unknown time"} ` +
`with no active job — most likely an application restart or crash interrupted it. ` +
`Status was reset to "failed" so it can be retried.`,
status: "failed",
skipNotification: true,
});
console.log(
`[StuckStatusRecovery] Reset repository ${repo.fullName ?? repo.name} from "${repo.status}" to "failed" (stuck since ${
repo.updatedAt ? new Date(repo.updatedAt).toISOString() : "unknown"
})`
);
}
result.repositories = stuckRepos.length;
} catch (error) {
console.error(
"[StuckStatusRecovery] Failed to reset stuck repository statuses:",
error
);
}
// --- Organizations ---
try {
const inFlightOrgs = await db
.select({
id: organizations.id,
userId: organizations.userId,
name: organizations.name,
status: organizations.status,
updatedAt: organizations.updatedAt,
})
.from(organizations)
.where(inArray(organizations.status, [...IN_FLIGHT_ORG_STATUSES]));
const stuckOrgs = inFlightOrgs.filter((org) => isStuckInFlight(org, cutoff));
for (const org of stuckOrgs) {
await db
.update(organizations)
.set(buildStuckResetUpdate(org.status, now))
.where(eq(organizations.id, org.id));
await createMirrorJob({
userId: org.userId,
organizationId: org.id,
organizationName: org.name,
message: `Reset stuck organization status: ${org.name}`,
details:
`Organization was stuck at "${org.status}" since ` +
`${org.updatedAt ? new Date(org.updatedAt).toISOString() : "an unknown time"} ` +
`with no active job — most likely an application restart or crash interrupted it. ` +
`Status was reset to "failed" so it can be retried.`,
status: "failed",
skipNotification: true,
});
console.log(
`[StuckStatusRecovery] Reset organization ${org.name} from "${org.status}" to "failed" (stuck since ${
org.updatedAt ? new Date(org.updatedAt).toISOString() : "unknown"
})`
);
}
result.organizations = stuckOrgs.length;
} catch (error) {
console.error(
"[StuckStatusRecovery] Failed to reset stuck organization statuses:",
error
);
}
return result;
}
+6 -5
View File
@@ -1,5 +1,6 @@
import { describe, test, expect } from "bun:test";
import { jsonResponse, formatDate, formatDateShort, truncate, safeParse, parseErrorMessage, showErrorToast } from "./utils";
import { formatDateTime } from "./utils/time-format";
describe("jsonResponse", () => {
test("creates a Response with JSON content", () => {
@@ -44,10 +45,11 @@ describe("formatDate", () => {
const date = new Date("2023-01-15T12:30:45Z");
const formatted = formatDate(date);
// The exact format might depend on the locale, so we'll check for parts
// The exact format depends on the system locale and the user's time
// format preference, so check for locale-independent parts and that it
// delegates to the shared time-format utility.
expect(formatted).toContain("2023");
expect(formatted).toContain("January");
expect(formatted).toContain("15");
expect(formatted).toBe(formatDateTime(date));
});
test("formats a date string", () => {
@@ -55,8 +57,7 @@ describe("formatDate", () => {
const formatted = formatDate(dateStr);
expect(formatted).toContain("2023");
expect(formatted).toContain("January");
expect(formatted).toContain("15");
expect(formatted).toBe(formatDateTime(dateStr));
});
test("returns 'Never' for null or undefined", () => {
+3 -7
View File
@@ -3,6 +3,7 @@ import { twMerge } from "tailwind-merge";
import { httpRequest, HttpError } from "@/lib/http-client";
import type { RepoStatus } from "@/types/Repository";
import { withBase } from "@/lib/base-path";
import { formatDateTime } from "@/lib/utils/time-format";
export const API_BASE = withBase("/api");
@@ -23,13 +24,8 @@ export function generateRandomString(length: number): string {
export function formatDate(date?: Date | string | null): string {
if (!date) return "Never";
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
// Locale-aware and respects the user's 12h/24h time format preference.
return formatDateTime(date);
}
export function formatDateShort(date?: Date | string | null): string | undefined {
+66
View File
@@ -191,3 +191,69 @@ test("DB row missing starredDuplicateStrategy defaults to suffix on read", () =>
const ui = mapDbToUiConfig({ githubConfig: { owner: "octo", token: "" } });
expect(ui.githubConfig.starredDuplicateStrategy).toBe("suffix");
});
// Regression for #338: saving any setting from the Configuration page reset
// giteaConfig.mirrorInterval (set via GITEA_MIRROR_INTERVAL) back to "8h"
// because the mapper hardcoded the default instead of preserving the stored value.
test("mapUiToDbConfig preserves env-configured mirrorInterval on save", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, {
giteaConfig: { mirrorInterval: "10m" },
});
expect(db.giteaConfig.mirrorInterval).toBe("10m");
});
test("mapUiToDbConfig defaults mirrorInterval to 8h without existing config", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions);
expect(db.giteaConfig.mirrorInterval).toBe("8h");
});
test("mapUiToDbConfig preserves non-UI fields from existing config on save", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, {
githubConfig: { type: "organization", includeArchived: true, includePublic: false },
giteaConfig: {
createOrg: false,
templateOwner: "templates",
templateRepo: "base",
addTopics: false,
topicPrefix: "gh-",
preserveVisibility: true,
},
});
expect(db.githubConfig.type).toBe("organization");
expect(db.githubConfig.includeArchived).toBe(true);
expect(db.githubConfig.includePublic).toBe(false);
expect(db.giteaConfig.createOrg).toBe(false);
expect(db.giteaConfig.templateOwner).toBe("templates");
expect(db.giteaConfig.templateRepo).toBe("base");
expect(db.giteaConfig.addTopics).toBe(false);
expect(db.giteaConfig.topicPrefix).toBe("gh-");
expect(db.giteaConfig.preserveVisibility).toBe(true);
});
test("mapUiToDbConfig keeps env-configured full-copy forkStrategy when forks are included", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, {
giteaConfig: { forkStrategy: "full-copy" },
});
expect(db.giteaConfig.forkStrategy).toBe("full-copy");
});
test("mapUiToDbConfig lets skipForks override a stored forkStrategy", () => {
const ui = buildMinimalUiConfigs();
const withSkip: AdvancedOptions = { ...ui.advancedOptions, skipForks: true };
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, withSkip, {
giteaConfig: { forkStrategy: "full-copy" },
});
expect(db.giteaConfig.forkStrategy).toBe("skip");
});
test("mapUiToDbConfig resets a stale skip forkStrategy to reference when skipForks is unchecked", () => {
const ui = buildMinimalUiConfigs();
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions, {
giteaConfig: { forkStrategy: "skip" },
});
expect(db.giteaConfig.forkStrategy).toBe("reference");
});
+39 -23
View File
@@ -51,28 +51,37 @@ function normalizeOrgList(orgs: string[] | undefined): string[] {
/**
* Maps UI config structure to database schema structure
*
* `existing` is the stored DB config (if any). Fields the Configuration form
* doesn't expose (mirrorInterval, topics, templates, ...) are preserved from it
* so a UI save can't silently reset values configured via environment variables
* (e.g. GITEA_MIRROR_INTERVAL, see issue #338).
*/
export function mapUiToDbConfig(
githubConfig: GitHubConfig,
giteaConfig: GiteaConfig,
mirrorOptions: MirrorOptions,
advancedOptions: AdvancedOptions
advancedOptions: AdvancedOptions,
existing?: {
githubConfig?: Partial<DbGitHubConfig>;
giteaConfig?: Partial<DbGiteaConfig>;
}
): { githubConfig: DbGitHubConfig; giteaConfig: DbGiteaConfig } {
// Map GitHub config to match database schema fields
const dbGithubConfig: DbGitHubConfig = {
// Map username to owner field
owner: githubConfig.username,
type: "personal", // Default to personal, could be made configurable
type: existing?.githubConfig?.type || "personal", // Not in UI; preserve stored value
token: githubConfig.token || "",
// Map checkbox fields with proper names
includeStarred: githubConfig.mirrorStarred,
includePrivate: githubConfig.privateRepositories,
includeCollaboratorRepos: githubConfig.includeCollaboratorRepos ?? true,
includeForks: !advancedOptions.skipForks, // Note: UI has skipForks, DB has includeForks
skipForks: advancedOptions.skipForks, // Add skipForks field
includeArchived: false, // Not in UI yet, default to false
includePublic: true, // Not in UI yet, default to true
includeArchived: existing?.githubConfig?.includeArchived ?? false, // Not in UI; preserve stored value
includePublic: existing?.githubConfig?.includePublic ?? true, // Not in UI; preserve stored value
// Organization related fields — opt-in allowlist (empty = all org repos)
includeOrganizations: normalizeOrgList(githubConfig.includeOrganizations),
@@ -102,28 +111,35 @@ export function mapUiToDbConfig(
organization: giteaConfig.organization, // Add organization field
preserveOrgStructure: giteaConfig.mirrorStrategy === "preserve" || giteaConfig.mirrorStrategy === "mixed", // Add preserveOrgStructure field
// Mirror interval and options
mirrorInterval: "8h", // Default value, could be made configurable
// Mirror interval — not in UI; preserve the stored value so a save doesn't
// reset an env-configured GITEA_MIRROR_INTERVAL back to the default (#338)
mirrorInterval: existing?.giteaConfig?.mirrorInterval || "8h",
lfs: mirrorOptions.mirrorLFS || false, // LFS mirroring option
wiki: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.wiki,
// Visibility settings
visibility: giteaConfig.visibility || "default",
preserveVisibility: false, // This should be a separate field, not the same as preserveOrgStructure
// Organization creation
createOrg: true, // Default to true
// Template settings (not in UI yet)
templateOwner: undefined,
templateRepo: undefined,
// Topics
addTopics: true, // Default to true
topicPrefix: undefined,
// Fork strategy
forkStrategy: advancedOptions.skipForks ? "skip" : "reference",
preserveVisibility: existing?.giteaConfig?.preserveVisibility ?? false, // Not in UI; preserve stored value
// Organization creation — not in UI; preserve stored value
createOrg: existing?.giteaConfig?.createOrg ?? true,
// Template settings (not in UI yet) — preserve stored values
templateOwner: existing?.giteaConfig?.templateOwner,
templateRepo: existing?.giteaConfig?.templateRepo,
// Topics — not in UI; preserve stored values
addTopics: existing?.giteaConfig?.addTopics ?? true,
topicPrefix: existing?.giteaConfig?.topicPrefix,
// Fork strategy — skipForks is the only UI control; keep an env-configured
// "full-copy" instead of downgrading it, but reset a stale "skip" once the
// user unchecks skipForks.
forkStrategy: advancedOptions.skipForks
? "skip"
: existing?.giteaConfig?.forkStrategy && existing.giteaConfig.forkStrategy !== "skip"
? existing.giteaConfig.forkStrategy
: "reference",
// Mirror options from UI
issueConcurrency: giteaConfig.issueConcurrency ?? 3,
+231
View File
@@ -0,0 +1,231 @@
import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test";
import {
TIME_FORMAT_STORAGE_KEY,
isTimeFormatPreference,
getTimeFormatPreference,
setTimeFormatPreference,
subscribeToTimeFormatChange,
resolveHour12,
formatDateTime,
formatShortDateTime,
formatTime,
} from "./time-format";
// A fixed instant; assertions below are timezone-agnostic (they check the
// hour cycle / AM-PM marker, not exact clock values).
const FIXED_DATE = new Date("2023-01-15T12:30:45Z");
const MERIDIEM = /AM|PM/i;
// The Bun test runtime has no DOM. Install minimal localStorage/window shims
// for the persistence and subscription tests, and remove them afterwards.
const g = globalThis as any;
const createdLocalStorage = typeof g.localStorage === "undefined";
const createdWindow = typeof g.window === "undefined";
beforeAll(() => {
if (createdLocalStorage) {
const store = new Map<string, string>();
g.localStorage = {
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
};
}
if (createdWindow) {
const target = new EventTarget();
g.window = target;
}
});
afterAll(() => {
if (createdLocalStorage) delete g.localStorage;
if (createdWindow) delete g.window;
});
beforeEach(() => {
g.localStorage.removeItem(TIME_FORMAT_STORAGE_KEY);
});
describe("isTimeFormatPreference", () => {
test("accepts valid preferences", () => {
expect(isTimeFormatPreference("auto")).toBe(true);
expect(isTimeFormatPreference("12h")).toBe(true);
expect(isTimeFormatPreference("24h")).toBe(true);
});
test("rejects invalid values", () => {
expect(isTimeFormatPreference("24")).toBe(false);
expect(isTimeFormatPreference("")).toBe(false);
expect(isTimeFormatPreference(null)).toBe(false);
expect(isTimeFormatPreference(undefined)).toBe(false);
expect(isTimeFormatPreference(12)).toBe(false);
});
});
describe("resolveHour12", () => {
test("maps preferences onto Intl's hour12 option", () => {
expect(resolveHour12("12h")).toBe(true);
expect(resolveHour12("24h")).toBe(false);
expect(resolveHour12("auto")).toBeUndefined();
});
});
describe("preference persistence", () => {
test("defaults to 'auto' when nothing is stored", () => {
expect(getTimeFormatPreference()).toBe("auto");
});
test("round-trips a stored preference", () => {
setTimeFormatPreference("24h");
expect(getTimeFormatPreference()).toBe("24h");
setTimeFormatPreference("12h");
expect(getTimeFormatPreference()).toBe("12h");
});
test("falls back to 'auto' for corrupted stored values", () => {
g.localStorage.setItem(TIME_FORMAT_STORAGE_KEY, "bogus");
expect(getTimeFormatPreference()).toBe("auto");
});
});
describe("subscribeToTimeFormatChange", () => {
test("notifies on preference change and stops after unsubscribe", () => {
let calls = 0;
const unsubscribe = subscribeToTimeFormatChange(() => {
calls++;
});
setTimeFormatPreference("24h");
expect(calls).toBe(1);
setTimeFormatPreference("12h");
expect(calls).toBe(2);
unsubscribe();
setTimeFormatPreference("auto");
expect(calls).toBe(2);
});
test("notifies on cross-tab storage events for our key only", () => {
let calls = 0;
const unsubscribe = subscribeToTimeFormatChange(() => {
calls++;
});
g.window.dispatchEvent(
Object.assign(new Event("storage"), { key: TIME_FORMAT_STORAGE_KEY })
);
expect(calls).toBe(1);
g.window.dispatchEvent(
Object.assign(new Event("storage"), { key: "theme" })
);
expect(calls).toBe(1);
unsubscribe();
});
});
describe("formatTime", () => {
test("forces 24-hour time regardless of a 12-hour locale", () => {
const formatted = formatTime(FIXED_DATE, {
locale: "en-US",
preference: "24h",
});
expect(formatted).not.toMatch(MERIDIEM);
expect(formatted).toMatch(/^\d{2}:\d{2}$/);
});
test("forces 12-hour time regardless of a 24-hour locale", () => {
const formatted = formatTime(FIXED_DATE, {
locale: "de-DE",
preference: "12h",
});
expect(formatted).toMatch(MERIDIEM);
});
test("'auto' follows the locale convention (12h for en-US)", () => {
const formatted = formatTime(FIXED_DATE, {
locale: "en-US",
preference: "auto",
});
expect(formatted).toMatch(MERIDIEM);
});
test("'auto' follows the locale convention (24h for de-DE)", () => {
const formatted = formatTime(FIXED_DATE, {
locale: "de-DE",
preference: "auto",
});
expect(formatted).not.toMatch(MERIDIEM);
});
test("uses the stored preference when none is passed", () => {
setTimeFormatPreference("24h");
const formatted = formatTime(FIXED_DATE, { locale: "en-US" });
expect(formatted).not.toMatch(MERIDIEM);
expect(formatted).toMatch(/^\d{2}:\d{2}$/);
});
});
describe("formatDateTime", () => {
test("includes the full date and respects the 24h preference", () => {
const formatted = formatDateTime(FIXED_DATE, {
locale: "en-US",
preference: "24h",
});
expect(formatted).toContain("January");
expect(formatted).toContain("2023");
expect(formatted).not.toMatch(MERIDIEM);
expect(formatted).toMatch(/\d{2}:\d{2}/);
});
test("includes the full date and respects the 12h preference", () => {
const formatted = formatDateTime(FIXED_DATE, {
locale: "en-US",
preference: "12h",
});
expect(formatted).toContain("January");
expect(formatted).toContain("2023");
expect(formatted).toMatch(MERIDIEM);
});
test("accepts ISO strings and epoch milliseconds", () => {
const fromString = formatDateTime("2023-01-15T12:30:45Z", {
locale: "en-US",
preference: "24h",
});
const fromNumber = formatDateTime(FIXED_DATE.getTime(), {
locale: "en-US",
preference: "24h",
});
expect(fromString).toBe(fromNumber);
});
});
describe("formatShortDateTime", () => {
test("renders a compact numeric date with time", () => {
const formatted = formatShortDateTime(FIXED_DATE, {
locale: "en-US",
preference: "12h",
});
expect(formatted).toMatch(/\d{2}\/\d{2}\/\d{2}/);
expect(formatted).toMatch(MERIDIEM);
});
test("respects the 24h preference", () => {
const formatted = formatShortDateTime(FIXED_DATE, {
locale: "en-US",
preference: "24h",
});
expect(formatted).not.toMatch(MERIDIEM);
expect(formatted).toMatch(/\d{2}:\d{2}/);
});
});
+159
View File
@@ -0,0 +1,159 @@
/**
* Shared time/date formatting with a user-configurable 12h/24h preference.
*
* All timestamp rendering in the UI should go through this module so that:
* - By default ("auto") times follow the browser locale's convention instead
* of a hardcoded locale (previously "en-US" forced 12-hour AM/PM for everyone).
* - Users can explicitly force 12-hour or 24-hour time. The preference is a
* pure display concern, so it is persisted client-side in localStorage
* (same mechanism as the theme preference) rather than in the database.
*/
export type TimeFormatPreference = "auto" | "12h" | "24h";
export const TIME_FORMAT_STORAGE_KEY = "timeFormat";
export const TIME_FORMAT_CHANGE_EVENT = "gitea-mirror:time-format-change";
export function isTimeFormatPreference(
value: unknown
): value is TimeFormatPreference {
return value === "auto" || value === "12h" || value === "24h";
}
/**
* Read the persisted preference. Safe to call during SSR / in tests where
* localStorage does not exist (falls back to "auto").
*/
export function getTimeFormatPreference(): TimeFormatPreference {
if (typeof localStorage === "undefined") return "auto";
try {
const stored = localStorage.getItem(TIME_FORMAT_STORAGE_KEY);
return isTimeFormatPreference(stored) ? stored : "auto";
} catch {
return "auto";
}
}
/**
* Persist the preference and notify listeners in this tab. Other tabs are
* notified via the native "storage" event.
*/
export function setTimeFormatPreference(preference: TimeFormatPreference): void {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(TIME_FORMAT_STORAGE_KEY, preference);
}
} catch {
// Persisting is best-effort (e.g. storage disabled); still notify listeners.
}
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(TIME_FORMAT_CHANGE_EVENT, { detail: preference })
);
}
}
/**
* Subscribe to preference changes (same tab via custom event, other tabs via
* the "storage" event). Returns an unsubscribe function. Compatible with
* React's useSyncExternalStore.
*/
export function subscribeToTimeFormatChange(callback: () => void): () => void {
if (typeof window === "undefined") return () => {};
const onCustom = () => callback();
const onStorage = (event: StorageEvent) => {
if (event.key === TIME_FORMAT_STORAGE_KEY) callback();
};
window.addEventListener(TIME_FORMAT_CHANGE_EVENT, onCustom);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(TIME_FORMAT_CHANGE_EVENT, onCustom);
window.removeEventListener("storage", onStorage);
};
}
/** Map a preference onto Intl's hour12 option ("auto" defers to the locale). */
export function resolveHour12(
preference: TimeFormatPreference
): boolean | undefined {
if (preference === "12h") return true;
if (preference === "24h") return false;
return undefined;
}
export interface FormatDateTimeOptions {
/** Override the stored preference (mainly for tests). */
preference?: TimeFormatPreference;
/** Override the browser/system locale (mainly for tests). */
locale?: string;
}
function buildOptions(
base: Intl.DateTimeFormatOptions,
preference: TimeFormatPreference
): Intl.DateTimeFormatOptions {
const hour12 = resolveHour12(preference);
return hour12 === undefined ? base : { ...base, hour12 };
}
/**
* Full date + time, e.g. "January 15, 2023, 12:30 PM" / "15. Januar 2023, 12:30".
* Locale defaults to the browser locale; hour cycle follows the user preference.
*/
export function formatDateTime(
date: Date | string | number,
options: FormatDateTimeOptions = {}
): string {
const preference = options.preference ?? getTimeFormatPreference();
return new Intl.DateTimeFormat(
options.locale,
buildOptions(
{
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
},
preference
)
).format(new Date(date));
}
/**
* Compact date + time, e.g. "01/15/23, 12:30 PM" / "15.01.23, 12:30".
* Used where space is tight (dashboard status cards).
*/
export function formatShortDateTime(
date: Date | string | number,
options: FormatDateTimeOptions = {}
): string {
const preference = options.preference ?? getTimeFormatPreference();
return new Intl.DateTimeFormat(
options.locale,
buildOptions(
{
year: "2-digit",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
},
preference
)
).format(new Date(date));
}
/**
* Time only, e.g. "12:30 PM" / "12:30".
*/
export function formatTime(
date: Date | string | number,
options: FormatDateTimeOptions = {}
): string {
const preference = options.preference ?? getTimeFormatPreference();
return new Intl.DateTimeFormat(
options.locale,
buildOptions({ hour: "2-digit", minute: "2-digit" }, preference)
).format(new Date(date));
}
+62 -24
View File
@@ -94,38 +94,48 @@ export const POST: APIRoute = async ({ request, locals }) => {
const existingConfig = existingConfigResult[0];
// Parse the stored configs once — used both to preserve fields the
// Configuration form doesn't expose (e.g. env-configured mirrorInterval,
// see issue #338) and to preserve tokens when the form submits them empty.
let existingGithub: Record<string, any> | undefined;
let existingGitea: Record<string, any> | undefined;
if (existingConfig) {
try {
existingGithub =
typeof existingConfig.githubConfig === "string"
? JSON.parse(existingConfig.githubConfig)
: existingConfig.githubConfig;
existingGitea =
typeof existingConfig.giteaConfig === "string"
? JSON.parse(existingConfig.giteaConfig)
: existingConfig.giteaConfig;
} catch (parseError) {
console.error("Failed to parse existing config:", parseError);
}
}
// Map UI structure to database schema structure first
const { githubConfig: mappedGithubConfig, giteaConfig: mappedGiteaConfig } = mapUiToDbConfig(
githubConfig,
giteaConfig,
mirrorOptions,
advancedOptions
advancedOptions,
{ githubConfig: existingGithub, giteaConfig: existingGitea }
);
// Preserve tokens if fields are empty
if (existingConfig) {
try {
const existingGithub =
typeof existingConfig.githubConfig === "string"
? JSON.parse(existingConfig.githubConfig)
: existingConfig.githubConfig;
const existingGitea =
typeof existingConfig.giteaConfig === "string"
? JSON.parse(existingConfig.giteaConfig)
: existingConfig.giteaConfig;
// Decrypt existing tokens before preserving
if (!mappedGithubConfig.token && existingGithub.token) {
mappedGithubConfig.token = decrypt(existingGithub.token);
}
if (!mappedGiteaConfig.token && existingGitea.token) {
mappedGiteaConfig.token = decrypt(existingGitea.token);
}
} catch (tokenError) {
console.error("Failed to preserve tokens:", tokenError);
try {
// Decrypt existing tokens before preserving
if (!mappedGithubConfig.token && existingGithub?.token) {
mappedGithubConfig.token = decrypt(existingGithub.token);
}
if (!mappedGiteaConfig.token && existingGitea?.token) {
mappedGiteaConfig.token = decrypt(existingGitea.token);
}
} catch (tokenError) {
console.error("Failed to preserve tokens:", tokenError);
}
// Encrypt tokens before saving
@@ -162,6 +172,20 @@ export const POST: APIRoute = async ({ request, locals }) => {
token: encrypt(processedNotificationConfig.apprise.token),
};
}
// Encrypt gotify token if present
if (processedNotificationConfig.gotify?.token) {
processedNotificationConfig.gotify = {
...processedNotificationConfig.gotify,
token: encrypt(processedNotificationConfig.gotify.token),
};
}
// Encrypt webhook secret if present
if (processedNotificationConfig.webhook?.secret) {
processedNotificationConfig.webhook = {
...processedNotificationConfig.webhook,
secret: encrypt(processedNotificationConfig.webhook.secret),
};
}
}
if (existingConfig) {
@@ -344,6 +368,20 @@ export const GET: APIRoute = async ({ request, locals }) => {
notificationConfig.apprise = { ...notificationConfig.apprise, token: "" };
}
}
if (notificationConfig.gotify?.token) {
try {
notificationConfig.gotify = { ...notificationConfig.gotify, token: decrypt(notificationConfig.gotify.token) };
} catch {
notificationConfig.gotify = { ...notificationConfig.gotify, token: "" };
}
}
if (notificationConfig.webhook?.secret) {
try {
notificationConfig.webhook = { ...notificationConfig.webhook, secret: decrypt(notificationConfig.webhook.secret) };
} catch {
notificationConfig.webhook = { ...notificationConfig.webhook, secret: "" };
}
}
}
return new Response(JSON.stringify({
-525
View File
@@ -1,525 +0,0 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="Advanced Topics - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>Advanced</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">Advanced Topics</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
Advanced configuration options, deployment strategies, troubleshooting, and performance optimization for Gitea Mirror.
</p>
</div>
<!-- Environment Variables -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Environment Variables</h2>
<p class="text-muted-foreground mb-6">
Gitea Mirror can be configured using environment variables. These are particularly useful for containerized deployments.
</p>
<div class="bg-muted/30 rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border">
<th class="text-left p-3 font-semibold">Variable</th>
<th class="text-left p-3 font-semibold">Description</th>
<th class="text-left p-3 font-semibold">Default</th>
</tr>
</thead>
<tbody>
{[
{ var: 'NODE_ENV', desc: 'Application environment', default: 'production' },
{ var: 'PORT', desc: 'Server port', default: '4321' },
{ var: 'HOST', desc: 'Server host', default: '0.0.0.0' },
{ var: 'BASE_URL', desc: 'Application base path ("/" or e.g. "/mirror")', default: '/' },
{ var: 'BETTER_AUTH_SECRET', desc: 'Authentication secret key', default: 'Auto-generated' },
{ var: 'BETTER_AUTH_URL', desc: 'Authentication origin (scheme + host, no path). Any path is stripped automatically.', default: 'http://localhost:4321' },
{ var: 'NODE_EXTRA_CA_CERTS', desc: 'Path to CA certificate file', default: 'None' },
{ var: 'DATABASE_URL', desc: 'SQLite database path', default: './data/gitea-mirror.db' },
].map((item, i) => (
<tr class={i % 2 === 0 ? 'bg-muted/20' : ''}>
<td class="p-3 font-mono text-xs">{item.var}</td>
<td class="p-3">{item.desc}</td>
<td class="p-3 text-muted-foreground">{item.default}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Database Management -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Database Management</h2>
<p class="text-muted-foreground mb-6">
Gitea Mirror uses SQLite for data storage. The database is automatically created on first run.
</p>
<h3 class="text-xl font-semibold mb-4">Database Commands</h3>
<div class="space-y-4">
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Initialize Database</h4>
<div class="bg-muted/30 rounded p-3 mb-2">
<code class="text-sm">bun run init-db</code>
</div>
<p class="text-sm text-muted-foreground">Creates or recreates the database schema</p>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Check Database</h4>
<div class="bg-muted/30 rounded p-3 mb-2">
<code class="text-sm">bun run check-db</code>
</div>
<p class="text-sm text-muted-foreground">Verifies database integrity and displays statistics</p>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Fix Database</h4>
<div class="bg-muted/30 rounded p-3 mb-2">
<code class="text-sm">bun run fix-db</code>
</div>
<p class="text-sm text-muted-foreground">Attempts to repair common database issues</p>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Backup Database</h4>
<div class="bg-muted/30 rounded p-3 mb-2">
<code class="text-sm">cp data/gitea-mirror.db data/gitea-mirror.db.backup</code>
</div>
<p class="text-sm text-muted-foreground">Always backup before major changes</p>
</div>
</div>
<h3 class="text-xl font-semibold mb-4 mt-8">Database Schema Management</h3>
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<div class="flex gap-3">
<div class="text-blue-600 dark:text-blue-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="font-semibold text-blue-600 dark:text-blue-500 mb-1">Drizzle Kit</p>
<p class="text-sm">Database schema is managed with Drizzle ORM. Use these commands for schema changes:</p>
<ul class="mt-2 space-y-1 text-sm">
<li><code class="bg-blue-500/10 px-1 rounded">bun run drizzle-kit generate</code> - Generate migration files</li>
<li><code class="bg-blue-500/10 px-1 rounded">bun run drizzle-kit push</code> - Apply schema changes directly</li>
<li><code class="bg-blue-500/10 px-1 rounded">bun run drizzle-kit studio</code> - Open database browser</li>
</ul>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Performance Optimization -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Performance Optimization</h2>
<h3 class="text-xl font-semibold mb-4">Mirroring Performance</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{[
{
title: 'Batch Operations',
tips: [
'Mirror multiple repositories at once',
'Use organization-level mirroring',
'Schedule mirroring during off-peak hours'
]
},
{
title: 'Network Optimization',
tips: [
'Use SSH URLs when possible',
'Enable Git LFS only when needed',
'Consider repository size limits'
]
}
].map(section => (
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-3">{section.title}</h4>
<ul class="space-y-1 text-sm text-muted-foreground">
{section.tips.map(tip => (
<li class="flex gap-2">
<span>•</span>
<span>{tip}</span>
</li>
))}
</ul>
</div>
))}
</div>
<h3 class="text-xl font-semibold mb-4">Database Performance</h3>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">Regular Maintenance</h4>
<ul class="space-y-1 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Enable automatic cleanup in Configuration → Automation</span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Periodically vacuum the SQLite database: <code class="bg-amber-500/10 px-1 rounded">sqlite3 data/gitea-mirror.db "VACUUM;"</code></span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Monitor database size and clean old events regularly</span>
</li>
</ul>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Reverse Proxy Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Reverse Proxy Configuration</h2>
<p class="text-muted-foreground mb-6">
For production deployments, it's recommended to use a reverse proxy like Nginx or Caddy.
</p>
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 mb-6">
<div class="flex gap-3">
<div class="text-red-600 dark:text-red-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
</div>
<div>
<p class="font-semibold text-red-600 dark:text-red-500 mb-1">Required Environment Variables for Reverse Proxy</p>
<p class="text-sm mb-3">
When running Gitea Mirror behind a reverse proxy, you <strong>must</strong> set these environment variables to your external URL.
Without them, sign-in will fail with "invalid origin" errors and pages may appear blank.
</p>
<div class="bg-muted/30 rounded p-3">
<pre class="text-sm"><code>{`# All three MUST be set to your external URL:
BETTER_AUTH_URL=https://gitea-mirror.example.com
PUBLIC_BETTER_AUTH_URL=https://gitea-mirror.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://gitea-mirror.example.com`}</code></pre>
</div>
<div class="bg-muted/30 rounded p-3 mt-3">
<pre class="text-sm"><code>{`# If deployed under a path prefix (example: /mirror):
# BASE_URL handles the path — auth URLs stay as origin only
BASE_URL=/mirror
BETTER_AUTH_URL=https://git.example.com
PUBLIC_BETTER_AUTH_URL=https://git.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
# → Auth endpoints resolve to: https://git.example.com/mirror/api/auth/*`}</code></pre>
</div>
<ul class="mt-3 space-y-1 text-sm">
<li><code class="bg-red-500/10 px-1 rounded">BASE_URL</code> — Application base path for path-prefix deployments (e.g. <code>/mirror</code>). This is handled separately from the auth URLs.</li>
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_URL</code> — Server-side auth origin, scheme + host only (e.g. <code>https://git.example.com</code>). Do <strong>not</strong> include the base path — it is applied automatically from <code>BASE_URL</code>.</li>
<li><code class="bg-red-500/10 px-1 rounded">PUBLIC_BETTER_AUTH_URL</code> — Client-side (browser) auth origin. Same rule: origin only, no path.</li>
<li><code class="bg-red-500/10 px-1 rounded">BETTER_AUTH_TRUSTED_ORIGINS</code> — Comma-separated origins allowed to make auth requests</li>
</ul>
</div>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Docker Compose Example</h3>
<div class="bg-muted/30 rounded-lg p-4 mb-6">
<pre class="text-sm overflow-x-auto"><code>{`services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
environment:
- BETTER_AUTH_SECRET=your-secret-key-min-32-chars
- BASE_URL=/mirror
- BETTER_AUTH_URL=https://git.example.com
- PUBLIC_BETTER_AUTH_URL=https://git.example.com
- BETTER_AUTH_TRUSTED_ORIGINS=https://git.example.com
# ... other settings ...`}</code></pre>
</div>
<h3 class="text-xl font-semibold mb-4">Nginx Example</h3>
<div class="bg-muted/30 rounded-lg p-4 mb-6">
<pre class="text-sm overflow-x-auto"><code>{`server {
listen 80;
server_name gitea-mirror.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name gitea-mirror.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:4321;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SSE endpoint needs special handling
location /api/sse {
proxy_pass http://localhost:4321;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Cache-Control 'no-cache';
proxy_set_header X-Accel-Buffering 'no';
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
}`}</code></pre>
</div>
<h3 class="text-xl font-semibold mb-4">Caddy Example</h3>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{`gitea-mirror.example.com {
reverse_proxy localhost:4321
}`}</code></pre>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Monitoring and Health Checks -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Monitoring and Health Checks</h2>
<h3 class="text-xl font-semibold mb-4">Health Check Endpoint</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<p class="text-sm text-muted-foreground mb-4">Monitor application health using the built-in endpoint:</p>
<div class="bg-muted/30 rounded p-3 mb-4">
<code class="text-sm">GET /api/health</code>
</div>
<p class="text-sm font-semibold mb-2">Response:</p>
<div class="bg-muted/30 rounded p-3">
<pre class="text-sm"><code>{`{
"status": "ok",
"timestamp": "2024-01-15T10:30:00Z",
"database": "connected",
"version": "1.0.0"
}`}</code></pre>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Monitoring with Prometheus</h3>
<p class="text-sm text-muted-foreground mb-4">
While Gitea Mirror doesn't have built-in Prometheus metrics, you can monitor it using:
</p>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span>•</span>
<span>Blackbox exporter for endpoint monitoring</span>
</li>
<li class="flex gap-2">
<span>•</span>
<span>Node exporter for system metrics</span>
</li>
<li class="flex gap-2">
<span>•</span>
<span>Custom scripts to check database metrics</span>
</li>
</ul>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Backup and Recovery -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Backup and Recovery</h2>
<h3 class="text-xl font-semibold mb-4">What to Backup</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Essential Files</h4>
<ul class="space-y-1 text-sm text-muted-foreground">
<li class="font-mono">• data/gitea-mirror.db</li>
<li class="font-mono">• .env (if using)</li>
<li class="font-mono">• Custom CA certificates</li>
</ul>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold mb-2">Optional Files</h4>
<ul class="space-y-1 text-sm text-muted-foreground">
<li class="font-mono">• Docker volumes</li>
<li class="font-mono">• Custom configurations</li>
<li class="font-mono">• Logs for auditing</li>
</ul>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Backup Script Example</h3>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{`#!/bin/bash
BACKUP_DIR="/backups/gitea-mirror"
DATE=$(date +%Y%m%d_%H%M%S)
# Create backup directory
mkdir -p "$BACKUP_DIR/$DATE"
# Backup database
cp data/gitea-mirror.db "$BACKUP_DIR/$DATE/"
# Backup environment
cp .env "$BACKUP_DIR/$DATE/" 2>/dev/null || true
# Create tarball
tar -czf "$BACKUP_DIR/backup_$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE"
# Clean up
rm -rf "$BACKUP_DIR/$DATE"
# Keep only last 7 backups
ls -t "$BACKUP_DIR"/backup_*.tar.gz | tail -n +8 | xargs rm -f`}</code></pre>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Troubleshooting Guide -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Troubleshooting Guide</h2>
<div class="space-y-4">
{[
{
issue: 'Application won\'t start',
solutions: [
'Check port availability: `lsof -i :4321`',
'Verify environment variables are set correctly',
'Check database file permissions',
'Review logs for startup errors'
]
},
{
issue: 'Authentication failures',
solutions: [
'Ensure BETTER_AUTH_SECRET is set and consistent',
'Check BETTER_AUTH_URL matches your deployment',
'Clear browser cookies and try again',
'Verify database contains user records'
]
},
{
issue: 'Mirroring failures',
solutions: [
'Test GitHub/Gitea connections individually',
'Verify access tokens have correct permissions',
'Check network connectivity and firewall rules',
'Review Activity Log for detailed error messages'
]
},
{
issue: 'Performance issues',
solutions: [
'Check database size and run cleanup',
'Monitor system resources (CPU, memory, disk)',
'Reduce concurrent mirroring operations',
'Consider upgrading deployment resources'
]
}
].map(item => (
<div class="bg-card rounded-lg border border-border p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">{item.issue}</h4>
<ul class="space-y-1 text-sm">
{item.solutions.map(solution => (
<li class="flex gap-2">
<span class="text-primary">→</span>
<span>{solution}</span>
</li>
))}
</ul>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Migration Guide -->
<section>
<h2 class="text-2xl font-bold mb-6">Migration Guide</h2>
<h3 class="text-xl font-semibold mb-4">Migrating from JWT to Better Auth</h3>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary">
<p class="mb-4">If you're upgrading from an older version using JWT authentication:</p>
<ol class="space-y-3 text-sm">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs font-semibold">1</span>
<div>
<strong>Backup your database</strong>
<p class="text-muted-foreground">Always create a backup before migration</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs font-semibold">2</span>
<div>
<strong>Update environment variables</strong>
<p class="text-muted-foreground">Replace JWT_SECRET with BETTER_AUTH_SECRET</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs font-semibold">3</span>
<div>
<strong>Run database migrations</strong>
<p class="text-muted-foreground">New auth tables will be created automatically</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-xs font-semibold">4</span>
<div>
<strong>Users will need to log in again</strong>
<p class="text-muted-foreground">Previous sessions will be invalidated</p>
</div>
</li>
</ol>
</div>
</section>
</article>
</main>
</MainLayout>
-340
View File
@@ -1,340 +0,0 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="Architecture - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-1 4h1m-1 4h1"/>
</svg>
<span>Architecture Overview</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">Gitea Mirror Architecture</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
This document provides a comprehensive overview of the Gitea Mirror application architecture, including component diagrams, project structure, and detailed explanations of each part of the system.
</p>
</div>
<!-- System Overview -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">System Overview</h2>
<div class="bg-card/50 border border-border/50 rounded-lg p-6 mb-8">
<p class="text-base leading-relaxed mb-6">
Gitea Mirror is a web application that automates the mirroring of GitHub repositories to Gitea instances. It provides a user-friendly interface for configuring, monitoring, and managing mirroring operations without requiring users to edit configuration files or run Docker commands.
</p>
<div class="space-y-1">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">Technology Stack</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{[
{ name: 'Astro', desc: 'Web framework for Server-Side Rendering (SSR)' },
{ name: 'React', desc: 'Component library for interactive UI elements' },
{ name: 'Tailwind CSS v4', desc: 'Utility-first CSS framework (with Vite plugin)' },
{ name: 'Shadcn UI', desc: 'UI component library built on Tailwind CSS' },
{ name: 'SQLite', desc: 'Database for storing configuration, state, and events' },
{ name: 'Bun', desc: 'JavaScript runtime and package manager' },
{ name: 'Drizzle ORM', desc: 'Type-safe ORM for database interactions' },
{ name: 'Better Auth', desc: 'Modern authentication library with SSO/OIDC support' }
].map(tech => (
<div class="flex items-start gap-3">
<div class="w-2 h-2 rounded-full bg-primary mt-2"></div>
<div>
<span class="font-semibold text-foreground">{tech.name}</span>
<p class="text-sm text-muted-foreground">{tech.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Architecture Diagram -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Architecture Diagram</h2>
<div class="my-8">
<div class="architecture-diagram bg-muted/30 rounded-xl p-8 border border-border">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Gitea Mirror System -->
<div class="bg-card rounded-lg border-2 border-primary/20 p-6">
<h3 class="text-lg font-semibold mb-6 text-center text-primary">Gitea Mirror System</h3>
<div class="space-y-4">
{[
{ icon: '🎨', name: 'Frontend', tech: 'Astro + React' },
{ icon: '⚙️', name: 'Backend', tech: 'Bun Runtime' },
{ icon: '🗄️', name: 'Database', tech: 'SQLite + Drizzle' }
].map((component, index) => (
<>
<div class="bg-primary/10 rounded-lg p-4 text-center">
<div class="text-2xl mb-2">{component.icon}</div>
<h4 class="font-semibold">{component.name}</h4>
<p class="text-sm text-muted-foreground mt-1">{component.tech}</p>
</div>
{index < 2 && (
<div class="flex justify-center">
<div class="text-muted-foreground">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>
</svg>
</div>
</div>
)}
</>
))}
</div>
</div>
<!-- External APIs -->
<div class="space-y-4">
<div class="bg-card rounded-lg border-2 border-amber-500/20 p-6">
<h3 class="text-lg font-semibold mb-6 text-center text-amber-600 dark:text-amber-500">External APIs</h3>
<div class="space-y-4">
<div class="bg-amber-500/10 rounded-lg p-4 text-center">
<div class="text-2xl mb-2">🐙</div>
<h4 class="font-semibold">GitHub API</h4>
<p class="text-sm text-muted-foreground mt-1">Repository Data Source</p>
</div>
<div class="bg-amber-500/10 rounded-lg p-4 text-center">
<div class="text-2xl mb-2">🍵</div>
<h4 class="font-semibold">Gitea API</h4>
<p class="text-sm text-muted-foreground mt-1">Mirror Destination</p>
</div>
</div>
</div>
<div class="flex items-center justify-center gap-4 mt-6">
<div class="text-center">
<div class="text-sm text-muted-foreground mb-2">Data Flow</div>
<div class="flex items-center gap-2">
<span class="text-primary font-semibold">Backend</span>
<svg class="w-6 h-6 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
</svg>
<span class="text-amber-600 dark:text-amber-500 font-semibold">APIs</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Component Breakdown -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Component Breakdown</h2>
<!-- Frontend -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Frontend (Astro + React)</h3>
<div class="pl-4 border-l-2 border-primary/20">
<p class="text-muted-foreground mb-4">
The frontend is built with Astro, a modern web framework that allows for server-side rendering and partial hydration. React components are used for interactive elements, providing a responsive and dynamic user interface.
</p>
<h4 class="font-semibold mb-3">Key Frontend Components</h4>
<div class="space-y-3">
{[
{ name: 'Dashboard', desc: 'Overview of mirroring status and recent activity' },
{ name: 'Repository Management', desc: 'Interface for managing repositories to mirror' },
{ name: 'Organization Management', desc: 'Interface for managing GitHub organizations' },
{ name: 'Configuration', desc: 'Settings for GitHub and Gitea connections' },
{ name: 'Activity Log', desc: 'Detailed log of mirroring operations' }
].map(component => (
<div class="flex gap-3">
<span class="text-primary font-mono text-sm">▸</span>
<div>
<strong>{component.name}</strong>
<p class="text-sm text-muted-foreground mt-1">{component.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
<!-- Backend -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Backend (Bun)</h3>
<div class="pl-4 border-l-2 border-primary/20">
<p class="text-muted-foreground mb-4">
The backend is built with Bun and provides API endpoints for the frontend to interact with. It handles:
</p>
<div class="space-y-3">
{[
'Authentication with Better Auth (email/password, SSO, OIDC)',
'OAuth2/OIDC provider functionality',
'GitHub API integration',
'Gitea API integration',
'Mirroring operations and job queue',
'Real-time updates via Server-Sent Events (SSE) at /api/sse/',
'Job recovery system for interrupted operations',
'Graceful shutdown handling',
'Scheduled automatic mirroring',
'Database interactions with Drizzle ORM'
].map(item => (
<div class="flex gap-3">
<span class="text-primary font-mono text-sm">▸</span>
<span>{item}</span>
</div>
))}
</div>
</div>
</div>
<!-- Database -->
<div>
<h3 class="text-xl font-semibold mb-4">Database (SQLite + Drizzle ORM)</h3>
<div class="pl-4 border-l-2 border-primary/20">
<p class="text-muted-foreground mb-4">
SQLite with Bun's native SQLite driver is used for data persistence, with Drizzle ORM providing type-safe database interactions. The database stores:
</p>
<div class="space-y-3">
{[
'User accounts and authentication data (Better Auth)',
'OAuth applications and SSO provider configurations',
'GitHub and Gitea configuration',
'Repository and organization information',
'Mirroring job history and status',
'Event notifications and their read status',
'OAuth tokens and consent records'
].map(item => (
<div class="flex gap-3">
<span class="text-primary font-mono text-sm">▸</span>
<span>{item}</span>
</div>
))}
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Data Flow -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Data Flow</h2>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary">
<ol class="space-y-4">
{[
{ title: 'User Authentication', desc: 'Users authenticate via Better Auth using email/password, SSO providers, or as OIDC clients.' },
{ title: 'Configuration', desc: 'Users configure GitHub and Gitea settings through the UI, which are stored in the SQLite database.' },
{ title: 'Repository Discovery', desc: 'The backend queries the GitHub API to discover repositories based on user configuration.' },
{ title: 'Mirroring Process', desc: 'When triggered, the backend fetches repository data from GitHub and pushes it to Gitea.' },
{ title: 'Status Tracking', desc: 'All operations are logged in the database and displayed in the Activity Log.' }
].map((step, index) => (
<li class="flex gap-4">
<span class="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">
{index + 1}
</span>
<div>
<strong class="text-foreground">{step.title}</strong>
<p class="text-sm text-muted-foreground mt-1">{step.desc}</p>
</div>
</li>
))}
</ol>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Project Structure -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Project Structure</h2>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{`gitea-mirror/
├── src/ # Source code
│ ├── components/ # React components
│ ├── content/ # Documentation and content
│ ├── layouts/ # Astro layout components
│ ├── lib/ # Utility functions and database
│ ├── pages/ # Astro pages and API routes
│ └── styles/ # CSS and Tailwind styles
├── public/ # Static assets
├── data/ # Database and persistent data
├── docker/ # Docker configuration
└── scripts/ # Utility scripts for deployment and maintenance
├── gitea-mirror-lxc-local.sh # Local LXC deployment script
└── manage-db.ts # Database management tool`}</code></pre>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Deployment Options -->
<section>
<h2 class="text-2xl font-bold mb-6">Deployment Options</h2>
<p class="text-muted-foreground mb-6">Gitea Mirror supports multiple deployment options:</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{[
{ icon: '🐳', name: 'Docker', desc: 'Run as a containerized application using Docker and docker-compose' },
{ icon: '📦', name: 'LXC Containers', desc: 'Deploy in Linux Containers on Proxmox VE using community script' },
{ icon: '🏃', name: 'Native', desc: 'Run directly on the host system using Bun runtime' }
].map(option => (
<div class="bg-card rounded-lg border border-border p-4 hover:border-primary/50 transition-colors">
<div class="flex items-start gap-3">
<div class="text-2xl">{option.icon}</div>
<div>
<h4 class="font-semibold mb-1">{option.name}</h4>
<p class="text-sm text-muted-foreground">{option.desc}</p>
</div>
</div>
</div>
))}
</div>
<div class="bg-muted/30 rounded-lg p-4 mt-6">
<h4 class="font-semibold mb-3">Deployment Advantages</h4>
<div class="space-y-2">
<div class="flex gap-3">
<strong class="text-primary">Docker:</strong>
<span class="text-muted-foreground">Isolation, easy updates, consistent environment</span>
</div>
<div class="flex gap-3">
<strong class="text-primary">LXC:</strong>
<span class="text-muted-foreground">Lightweight virtualization, better performance than Docker, system-level isolation</span>
</div>
<div class="flex gap-3">
<strong class="text-primary">Native:</strong>
<span class="text-muted-foreground">Best performance, direct access to system resources</span>
</div>
</div>
</div>
<div class="mt-4 text-sm text-muted-foreground">
<p><strong>Note:</strong> LXC deployment is available through the <a href="https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror" class="text-primary hover:underline">Proxmox VE Community Scripts</a> project.</p>
</div>
</section>
</article>
</main>
</MainLayout>
-536
View File
@@ -1,536 +0,0 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="Authentication & SSO - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
<span>Authentication</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">Authentication & SSO Configuration</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
Configure authentication methods including email/password, Single Sign-On (SSO), and OIDC provider functionality for Gitea Mirror.
</p>
</div>
<!-- Overview -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Authentication Overview</h2>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-6">
<p class="text-base leading-relaxed">
Gitea Mirror uses <strong>Better Auth</strong>, a modern authentication library that supports multiple authentication methods.
All authentication settings can be configured through the web UI without editing configuration files.
</p>
</div>
<h3 class="text-lg font-semibold mb-4">Supported Authentication Methods</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{
icon: '✉️',
title: 'Email & Password',
desc: 'Traditional authentication with email and password. Always enabled by default.',
status: 'Always Enabled'
},
{
icon: '🌐',
title: 'Single Sign-On (SSO)',
desc: 'Allow users to sign in using external OIDC providers like Google, Okta, or Azure AD.',
status: 'Optional'
},
{
icon: '🔑',
title: 'OIDC Provider',
desc: 'Act as an OIDC provider, allowing other applications to authenticate through Gitea Mirror.',
status: 'Optional'
}
].map(method => (
<div class="bg-card rounded-lg border border-border p-4 hover:border-primary/50 transition-colors">
<div class="text-2xl mb-3">{method.icon}</div>
<h4 class="font-semibold mb-2">{method.title}</h4>
<p class="text-sm text-muted-foreground mb-3">{method.desc}</p>
<span class={`text-xs px-2 py-1 rounded-full ${method.status === 'Always Enabled' ? 'bg-green-500/10 text-green-600 dark:text-green-500' : 'bg-blue-500/10 text-blue-600 dark:text-blue-500'}`}>
{method.status}
</span>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Accessing Authentication Settings -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Accessing Authentication Settings</h2>
<ol class="space-y-3">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">1</span>
<span>Navigate to the <strong>Configuration</strong> page</span>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">2</span>
<span>Click on the <strong>Authentication</strong> tab</span>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">3</span>
<span>Configure SSO providers or OAuth applications as needed</span>
</li>
</ol>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- SSO Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Single Sign-On (SSO) Configuration</h2>
<p class="text-muted-foreground mb-6">
SSO allows your users to authenticate using external identity providers. This is useful for organizations that already have centralized authentication systems.
</p>
<h3 class="text-xl font-semibold mb-4">Adding an SSO Provider</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<h4 class="font-semibold mb-4">Required Information</h4>
<div class="space-y-4">
{[
{ name: 'Issuer URL', desc: 'The OIDC issuer URL of your provider', example: 'https://accounts.google.com' },
{ name: 'Domain', desc: 'The email domain for this provider', example: 'example.com' },
{ name: 'Provider ID', desc: 'A unique identifier for this provider', example: 'google-sso' },
{ name: 'Client ID', desc: 'OAuth client ID from your provider', example: '123456789.apps.googleusercontent.com' },
{ name: 'Client Secret', desc: 'OAuth client secret from your provider', example: 'GOCSPX-...' }
].map(field => (
<div class="border-l-2 border-muted pl-4">
<div class="flex items-baseline gap-2 mb-1">
<strong class="text-sm">{field.name}</strong>
<span class="text-xs text-muted-foreground">Required</span>
</div>
<p class="text-sm text-muted-foreground">{field.desc}</p>
<code class="text-xs bg-muted px-2 py-0.5 rounded mt-1 inline-block">{field.example}</code>
</div>
))}
</div>
</div>
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4 mb-6">
<div class="flex gap-3">
<div class="text-blue-600 dark:text-blue-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="font-semibold text-blue-600 dark:text-blue-500 mb-1">Auto-Discovery</p>
<p class="text-sm">Most OIDC providers support auto-discovery. Simply enter the Issuer URL and click "Discover" to automatically populate the endpoint URLs.</p>
</div>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Redirect URL Configuration</h3>
<div class="bg-muted/30 rounded-lg p-4">
<p class="text-sm mb-2">When configuring your SSO provider, use this redirect URL:</p>
<code class="bg-muted rounded px-3 py-2 block">https://your-domain.com/api/auth/sso/callback/{`{provider-id}`}</code>
<p class="text-xs text-muted-foreground mt-2">Replace <code>{`{provider-id}`}</code> with your chosen Provider ID (e.g., google-sso)</p>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Example SSO Configurations -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Example SSO Configurations</h2>
<!-- Google Example -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
<img src="https://www.google.com/favicon.ico" alt="Google" class="w-5 h-5" />
Google SSO
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<ol class="space-y-4">
<li>
<strong>1. Create OAuth Client in Google Cloud Console</strong>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li>• Go to <a href="https://console.cloud.google.com/" class="text-primary hover:underline">Google Cloud Console</a></li>
<li>• Create a new OAuth 2.0 Client ID</li>
<li>• Add authorized redirect URI: <code class="bg-muted px-1 rounded">https://your-domain.com/api/auth/sso/callback/google-sso</code></li>
</ul>
</li>
<li>
<strong>2. Configure in Gitea Mirror</strong>
<div class="mt-2 bg-muted/30 rounded-lg p-3 text-sm">
<div class="grid grid-cols-1 gap-2">
<div><strong>Issuer URL:</strong> <code>https://accounts.google.com</code></div>
<div><strong>Domain:</strong> <code>your-company.com</code></div>
<div><strong>Provider ID:</strong> <code>google-sso</code></div>
<div><strong>Client ID:</strong> <code>[Your Google Client ID]</code></div>
<div><strong>Client Secret:</strong> <code>[Your Google Client Secret]</code></div>
</div>
</div>
</li>
<li>
<strong>3. Use Auto-Discovery</strong>
<p class="text-sm text-muted-foreground mt-1">Click "Discover" to automatically populate the endpoint URLs</p>
</li>
</ol>
</div>
</div>
<!-- Okta Example -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
<span class="w-5 h-5 bg-blue-600 rounded flex items-center justify-center text-white text-xs font-bold">O</span>
Okta SSO
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<ol class="space-y-4">
<li>
<strong>1. Create OIDC Application in Okta</strong>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li>• In Okta Admin Console, create a new OIDC Web Application</li>
<li>• Set Sign-in redirect URI: <code class="bg-muted px-1 rounded">https://your-domain.com/api/auth/sso/callback/okta-sso</code></li>
<li>• Note the Client ID and Client Secret</li>
</ul>
</li>
<li>
<strong>2. Configure in Gitea Mirror</strong>
<div class="mt-2 bg-muted/30 rounded-lg p-3 text-sm">
<div class="grid grid-cols-1 gap-2">
<div><strong>Issuer URL:</strong> <code>https://your-okta-domain.okta.com</code></div>
<div><strong>Domain:</strong> <code>your-company.com</code></div>
<div><strong>Provider ID:</strong> <code>okta-sso</code></div>
<div><strong>Client ID:</strong> <code>[Your Okta Client ID]</code></div>
<div><strong>Client Secret:</strong> <code>[Your Okta Client Secret]</code></div>
</div>
</div>
</li>
</ol>
</div>
</div>
<!-- Azure AD Example -->
<div>
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
<span class="w-5 h-5 bg-blue-500 rounded flex items-center justify-center text-white text-xs">M</span>
Azure AD / Microsoft Entra ID
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<ol class="space-y-4">
<li>
<strong>1. Register Application in Azure Portal</strong>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li>• Go to Azure Portal → Azure Active Directory → App registrations</li>
<li>• Create a new registration</li>
<li>• Add redirect URI: <code class="bg-muted px-1 rounded">https://your-domain.com/api/auth/sso/callback/azure-sso</code></li>
</ul>
</li>
<li>
<strong>2. Configure in Gitea Mirror</strong>
<div class="mt-2 bg-muted/30 rounded-lg p-3 text-sm">
<div class="grid grid-cols-1 gap-2">
<div><strong>Issuer URL:</strong> <code>https://login.microsoftonline.com/{`{tenant-id}`}/v2.0</code></div>
<div><strong>Domain:</strong> <code>your-company.com</code></div>
<div><strong>Provider ID:</strong> <code>azure-sso</code></div>
<div><strong>Client ID:</strong> <code>[Your Application ID]</code></div>
<div><strong>Client Secret:</strong> <code>[Your Client Secret]</code></div>
</div>
</div>
</li>
</ol>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- OIDC Provider Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">OIDC Provider Configuration</h2>
<p class="text-muted-foreground mb-6">
The OIDC Provider feature allows Gitea Mirror to act as an authentication provider for other applications.
This is useful when you want to centralize authentication through Gitea Mirror.
</p>
<h3 class="text-xl font-semibold mb-4">Creating OAuth Applications</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<ol class="space-y-4">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">1</span>
<div>
<strong>Navigate to OAuth Applications</strong>
<p class="text-sm text-muted-foreground mt-1">Go to Configuration → Authentication → OAuth Applications</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">2</span>
<div>
<strong>Create New Application</strong>
<p class="text-sm text-muted-foreground mt-1">Click "Create Application" and provide:</p>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li>• Application Name</li>
<li>• Application Type (Web, Mobile, or Desktop)</li>
<li>• Redirect URLs (one per line)</li>
</ul>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">3</span>
<div>
<strong>Save Credentials</strong>
<p class="text-sm text-muted-foreground mt-1">You'll receive a Client ID and Client Secret. Store these securely!</p>
</div>
</li>
</ol>
</div>
<h3 class="text-xl font-semibold mb-4">OIDC Endpoints</h3>
<div class="bg-muted/30 rounded-lg p-4 mb-6">
<p class="text-sm mb-3">Applications can use these standard OIDC endpoints:</p>
<div class="space-y-2 text-sm">
<div class="flex gap-2">
<strong class="w-32">Discovery:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/.well-known/openid-configuration</code>
</div>
<div class="flex gap-2">
<strong class="w-32">Authorization:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/api/auth/oauth2/authorize</code>
</div>
<div class="flex gap-2">
<strong class="w-32">Token:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/api/auth/oauth2/token</code>
</div>
<div class="flex gap-2">
<strong class="w-32">UserInfo:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/api/auth/oauth2/userinfo</code>
</div>
<div class="flex gap-2">
<strong class="w-32">JWKS:</strong>
<code class="bg-muted px-2 py-0.5 rounded flex-1">https://your-domain.com/api/auth/jwks</code>
</div>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Supported Scopes</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{ scope: 'openid', desc: 'Required - provides user ID', claims: 'sub' },
{ scope: 'profile', desc: 'User profile information', claims: 'name, username, picture' },
{ scope: 'email', desc: 'Email address', claims: 'email, email_verified' }
].map(item => (
<div class="bg-card rounded-lg border border-border p-4">
<code class="text-sm font-semibold text-primary">{item.scope}</code>
<p class="text-sm text-muted-foreground mt-2">{item.desc}</p>
<p class="text-xs text-muted-foreground mt-2">Claims: {item.claims}</p>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- User Experience -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">User Experience</h2>
<h3 class="text-xl font-semibold mb-4">Login Flow with SSO</h3>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-6">
<p class="mb-4">When SSO is configured, users will see authentication options on the login page:</p>
<ol class="space-y-2 text-sm">
<li class="flex gap-2"><span class="font-semibold">1.</span> Email & Password tab for traditional login</li>
<li class="flex gap-2"><span class="font-semibold">2.</span> SSO tab with provider buttons or email input</li>
<li class="flex gap-2"><span class="font-semibold">3.</span> Automatic redirect to the appropriate provider</li>
<li class="flex gap-2"><span class="font-semibold">4.</span> Return to Gitea Mirror after successful authentication</li>
</ol>
</div>
<h3 class="text-xl font-semibold mb-4">OAuth Consent Flow</h3>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary">
<p class="mb-4">When an application requests authentication through Gitea Mirror:</p>
<ol class="space-y-2 text-sm">
<li class="flex gap-2"><span class="font-semibold">1.</span> User is redirected to Gitea Mirror</li>
<li class="flex gap-2"><span class="font-semibold">2.</span> Login prompt if not already authenticated</li>
<li class="flex gap-2"><span class="font-semibold">3.</span> Consent screen showing requested permissions</li>
<li class="flex gap-2"><span class="font-semibold">4.</span> User approves or denies the request</li>
<li class="flex gap-2"><span class="font-semibold">5.</span> Redirect back to the application with auth code</li>
</ol>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Security Considerations -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Security Considerations</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
icon: '🔒',
title: 'Client Secrets',
items: [
'Store OAuth client secrets securely',
'Never commit secrets to version control',
'Rotate secrets regularly'
]
},
{
icon: '🔗',
title: 'Redirect URLs',
items: [
'Only add trusted redirect URLs',
'Use HTTPS in production',
'Validate exact URL matches'
]
},
{
icon: '🛡️',
title: 'Scopes & Permissions',
items: [
'Grant minimum required scopes',
'Review requested permissions',
'Users can revoke access anytime'
]
},
{
icon: '⏱️',
title: 'Token Security',
items: [
'Access tokens have expiration',
'Refresh tokens for long-lived access',
'Tokens can be revoked'
]
}
].map(section => (
<div class="bg-card rounded-lg border border-border p-4">
<div class="flex items-center gap-3 mb-3">
<span class="text-2xl">{section.icon}</span>
<h4 class="font-semibold">{section.title}</h4>
</div>
<ul class="space-y-1 text-sm text-muted-foreground">
{section.items.map(item => (
<li class="flex gap-2">
<span>•</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Troubleshooting -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Troubleshooting</h2>
<div class="space-y-4">
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">SSO Login Issues</h4>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>"Invalid origin" error:</strong> Check that your Gitea Mirror URL matches the configured redirect URI
</div>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>"Provider not found" error:</strong> Ensure the provider is properly configured and saved
</div>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>Redirect loop:</strong> Verify the redirect URI in both Gitea Mirror and the SSO provider match exactly
</div>
</li>
</ul>
</div>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">OIDC Provider Issues</h4>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>Application not found:</strong> Ensure the client ID is correct and the app is not disabled
</div>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>Invalid redirect URI:</strong> The redirect URI must match exactly what's configured
</div>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<div>
<strong>Consent not working:</strong> Check browser cookies are enabled and not blocked
</div>
</li>
</ul>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Migration from JWT -->
<section>
<h2 class="text-2xl font-bold mb-6">Migration from JWT Authentication</h2>
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<div class="flex gap-3">
<div class="text-blue-600 dark:text-blue-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="font-semibold text-blue-600 dark:text-blue-500 mb-2">For Existing Users</p>
<ul class="space-y-1 text-sm">
<li>• Email/password authentication continues to work</li>
<li>• No action required from existing users</li>
<li>• SSO can be added as an additional option</li>
<li>• JWT_SECRET is no longer required in environment variables</li>
</ul>
</div>
</div>
</div>
</section>
</article>
</main>
</MainLayout>
-476
View File
@@ -1,476 +0,0 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="CA Certificates - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
<span>Security</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">CA Certificates Configuration</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
Configure custom Certificate Authority (CA) certificates for connecting to self-signed or privately signed Gitea instances.
</p>
</div>
<!-- Overview -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Overview</h2>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-6">
<p class="text-base leading-relaxed">
When your Gitea instance uses a self-signed certificate or a certificate signed by a private Certificate Authority (CA),
you need to configure Gitea Mirror to trust these certificates. This guide explains how to add custom CA certificates
for different deployment methods.
</p>
</div>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<div class="flex gap-3">
<div class="text-amber-600 dark:text-amber-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div>
<p class="font-semibold text-amber-600 dark:text-amber-500 mb-1">Important</p>
<p class="text-sm">Without proper CA certificate configuration, you'll encounter SSL/TLS errors when connecting to Gitea instances with custom certificates.</p>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Common SSL/TLS Errors -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Common SSL/TLS Errors</h2>
<p class="text-muted-foreground mb-4">If you see any of these errors, you likely need to configure CA certificates:</p>
<div class="space-y-3">
{[
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'SELF_SIGNED_CERT_IN_CHAIN',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'CERT_UNTRUSTED',
'unable to verify the first certificate'
].map(error => (
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-3">
<code class="text-sm text-red-600 dark:text-red-500">{error}</code>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Docker Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Docker Configuration</h2>
<p class="text-muted-foreground mb-6">For Docker deployments, you have several options to add custom CA certificates:</p>
<h3 class="text-xl font-semibold mb-4">Method 1: Volume Mount (Recommended)</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<ol class="space-y-4">
<li>
<strong>1. Create a certificates directory</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>mkdir -p ./certs</code></pre>
</div>
</li>
<li>
<strong>2. Copy your CA certificate(s)</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>cp /path/to/your-ca-cert.crt ./certs/</code></pre>
</div>
</li>
<li>
<strong>3. Update docker-compose.yml</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>{`version: '3.8'
services:
gitea-mirror:
image: raylabs/gitea-mirror:latest
volumes:
- ./data:/app/data
- ./certs:/usr/local/share/ca-certificates:ro
environment:
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt`}</code></pre>
</div>
</li>
<li>
<strong>4. Restart the container</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>docker-compose down && docker-compose up -d</code></pre>
</div>
</li>
</ol>
</div>
<h3 class="text-xl font-semibold mb-4">Method 2: Custom Docker Image</h3>
<div class="bg-card rounded-lg border border-border p-6">
<p class="text-sm text-muted-foreground mb-4">For permanent certificate inclusion, create a custom Docker image:</p>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{`FROM raylabs/gitea-mirror:latest
# Copy CA certificates
COPY ./certs/*.crt /usr/local/share/ca-certificates/
# Update CA certificates
RUN update-ca-certificates
# Set environment variable
ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca-cert.crt`}</code></pre>
</div>
<p class="text-sm text-muted-foreground mt-4">Build and use your custom image:</p>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>docker build -t my-gitea-mirror .</code></pre>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Native/Bun Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Native/Bun Configuration</h2>
<p class="text-muted-foreground mb-6">For native Bun deployments, configure CA certificates using environment variables:</p>
<h3 class="text-xl font-semibold mb-4">Method 1: Environment Variable</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<ol class="space-y-4">
<li>
<strong>1. Export the certificate path</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>export NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt</code></pre>
</div>
</li>
<li>
<strong>2. Run Gitea Mirror</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>bun run start</code></pre>
</div>
</li>
</ol>
</div>
<h3 class="text-xl font-semibold mb-4">Method 2: .env File</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<p class="text-sm text-muted-foreground mb-4">Add to your .env file:</p>
<div class="bg-muted/30 rounded-lg p-3">
<pre class="text-sm"><code>NODE_EXTRA_CA_CERTS=/path/to/your-ca-cert.crt</code></pre>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Method 3: System-wide CA Store</h3>
<div class="bg-card rounded-lg border border-border p-6">
<p class="text-sm text-muted-foreground mb-4">Add certificates to your system's CA store:</p>
<div class="space-y-4">
<div>
<strong>Ubuntu/Debian:</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>{`sudo cp your-ca-cert.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates`}</code></pre>
</div>
</div>
<div>
<strong>RHEL/CentOS/Fedora:</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>{`sudo cp your-ca-cert.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust`}</code></pre>
</div>
</div>
<div>
<strong>macOS:</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>{`sudo security add-trusted-cert -d -r trustRoot \\
-k /Library/Keychains/System.keychain your-ca-cert.crt`}</code></pre>
</div>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- LXC Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">LXC Container Configuration</h2>
<p class="text-muted-foreground mb-6">For LXC deployments on Proxmox VE:</p>
<div class="bg-card rounded-lg border border-border p-6">
<ol class="space-y-4">
<li>
<strong>1. Enter the container</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>pct enter &lt;container-id&gt;</code></pre>
</div>
</li>
<li>
<strong>2. Create certificates directory</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>mkdir -p /usr/local/share/ca-certificates</code></pre>
</div>
</li>
<li>
<strong>3. Copy your CA certificate</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>cat > /usr/local/share/ca-certificates/your-ca.crt</code></pre>
</div>
<p class="text-xs text-muted-foreground mt-1">Paste your certificate content and press Ctrl+D</p>
</li>
<li>
<strong>4. Update the systemd service</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>{`cat >> /etc/systemd/system/gitea-mirror.service << EOF
Environment="NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/your-ca.crt"
EOF`}</code></pre>
</div>
</li>
<li>
<strong>5. Reload and restart</strong>
<div class="bg-muted/30 rounded-lg p-3 mt-2">
<pre class="text-sm"><code>{`systemctl daemon-reload
systemctl restart gitea-mirror`}</code></pre>
</div>
</li>
</ol>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Multiple CA Certificates -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Multiple CA Certificates</h2>
<p class="text-muted-foreground mb-6">If you need to trust multiple CA certificates:</p>
<h3 class="text-xl font-semibold mb-4">Option 1: Bundle Certificates</h3>
<div class="bg-card rounded-lg border border-border p-6 mb-6">
<p class="text-sm text-muted-foreground mb-4">Combine multiple certificates into one file:</p>
<div class="bg-muted/30 rounded-lg p-3">
<pre class="text-sm"><code>{`cat ca-cert1.crt ca-cert2.crt ca-cert3.crt > ca-bundle.crt
export NODE_EXTRA_CA_CERTS=/path/to/ca-bundle.crt`}</code></pre>
</div>
</div>
<h3 class="text-xl font-semibold mb-4">Option 2: System CA Store</h3>
<div class="bg-card rounded-lg border border-border p-6">
<p class="text-sm text-muted-foreground mb-4">Add all certificates to the system CA store (recommended for production):</p>
<div class="bg-muted/30 rounded-lg p-3">
<pre class="text-sm"><code>{`# Copy all certificates
cp *.crt /usr/local/share/ca-certificates/
update-ca-certificates`}</code></pre>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Verification -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Verifying Certificate Configuration</h2>
<p class="text-muted-foreground mb-6">Test your certificate configuration:</p>
<div class="bg-card rounded-lg border border-border p-6">
<h4 class="font-semibold mb-4">1. Test Gitea Connection</h4>
<p class="text-sm text-muted-foreground mb-3">Use the "Test Connection" button in the Gitea configuration section</p>
<h4 class="font-semibold mb-4 mt-6">2. Check Logs</h4>
<p class="text-sm text-muted-foreground mb-3">Look for SSL/TLS errors in the application logs:</p>
<div class="space-y-3">
<div>
<strong class="text-sm">Docker:</strong>
<div class="bg-muted/30 rounded-lg p-2 mt-1">
<code class="text-sm">docker logs gitea-mirror</code>
</div>
</div>
<div>
<strong class="text-sm">Native:</strong>
<div class="bg-muted/30 rounded-lg p-2 mt-1">
<code class="text-sm">Check terminal output</code>
</div>
</div>
<div>
<strong class="text-sm">LXC:</strong>
<div class="bg-muted/30 rounded-lg p-2 mt-1">
<code class="text-sm">journalctl -u gitea-mirror -f</code>
</div>
</div>
</div>
<h4 class="font-semibold mb-4 mt-6">3. Manual Certificate Test</h4>
<p class="text-sm text-muted-foreground mb-3">Test SSL connection directly:</p>
<div class="bg-muted/30 rounded-lg p-3">
<pre class="text-sm"><code>openssl s_client -connect your-gitea-domain.com:443 -CAfile /path/to/ca-cert.crt</code></pre>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Best Practices -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Best Practices</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
icon: '🔒',
title: 'Certificate Security',
items: [
'Keep CA certificates secure',
'Use read-only mounts in Docker',
'Limit certificate file permissions',
'Regularly update certificates'
]
},
{
icon: '📁',
title: 'Certificate Management',
items: [
'Use descriptive certificate filenames',
'Document certificate purposes',
'Track certificate expiration dates',
'Maintain certificate backups'
]
},
{
icon: '🏢',
title: 'Production Deployment',
items: [
'Use proper SSL certificates when possible',
'Consider Let\'s Encrypt for public instances',
'Implement certificate rotation procedures',
'Monitor certificate expiration'
]
},
{
icon: '🔍',
title: 'Troubleshooting',
items: [
'Verify certificate format (PEM)',
'Check certificate chain completeness',
'Ensure proper file permissions',
'Test with openssl commands'
]
}
].map(section => (
<div class="bg-card rounded-lg border border-border p-4">
<div class="flex items-center gap-3 mb-3">
<span class="text-2xl">{section.icon}</span>
<h4 class="font-semibold">{section.title}</h4>
</div>
<ul class="space-y-1 text-sm text-muted-foreground">
{section.items.map(item => (
<li class="flex gap-2">
<span>•</span>
<span>{item}</span>
</li>
))}
</ul>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Common Issues -->
<section>
<h2 class="text-2xl font-bold mb-6">Common Issues and Solutions</h2>
<div class="space-y-4">
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">Certificate not being recognized</h4>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Ensure the certificate is in PEM format</span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Check that NODE_EXTRA_CA_CERTS points to the correct file</span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Restart the application after adding certificates</span>
</li>
</ul>
</div>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">Still getting SSL errors</h4>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Verify the complete certificate chain is included</span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Check if intermediate certificates are needed</span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Ensure the certificate matches the server hostname</span>
</li>
</ul>
</div>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-2">Certificate expired</h4>
<ul class="space-y-2 text-sm">
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Check certificate validity: <code class="bg-amber-500/10 px-1 rounded">openssl x509 -in cert.crt -noout -dates</code></span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Update with new certificate from your CA</span>
</li>
<li class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Restart Gitea Mirror after updating</span>
</li>
</ul>
</div>
</div>
</section>
</article>
</main>
</MainLayout>
-514
View File
@@ -1,514 +0,0 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
const envVars = [
{ name: 'NODE_ENV', desc: 'Runtime environment', default: 'development', example: 'production' },
{ name: 'BASE_URL', desc: 'Application base path', default: '/', example: '/mirror' },
{ name: 'DATABASE_URL', desc: 'SQLite database URL', default: 'file:data/gitea-mirror.db', example: 'file:path/to/database.db' },
{ name: 'JWT_SECRET', desc: 'Secret key for JWT auth', default: 'Auto-generated', example: 'your-secure-string' },
{ name: 'HOST', desc: 'Server host', default: 'localhost', example: '0.0.0.0' },
{ name: 'PORT', desc: 'Server port', default: '4321', example: '8080' }
];
const githubOptions = [
{ name: 'Username', desc: 'Your GitHub username', default: '-' },
{ name: 'Token', desc: 'GitHub personal access token (Classic PAT)', default: '-' },
{ name: 'Private Repositories', desc: 'Include private repositories', default: 'false' },
{ name: 'Mirror Starred', desc: 'Mirror repositories you\'ve starred', default: 'false' },
{ name: 'Mirror Issues', desc: 'Mirror issues from GitHub to Gitea', default: 'false' },
{ name: 'Mirror Wiki', desc: 'Mirror wiki pages from GitHub to Gitea', default: 'false' },
{ name: 'Mirror Organizations', desc: 'Mirror organization repositories', default: 'false' },
{ name: 'Only Mirror Orgs', desc: 'Only mirror organization repositories', default: 'false' },
{ name: 'Skip Forks', desc: 'Exclude repositories that are forks', default: 'false' },
{ name: 'Skip Starred Issues', desc: 'Skip issues for starred repositories', default: 'false' }
];
const giteaOptions = [
{ name: 'URL', desc: 'Gitea server URL', default: '-' },
{ name: 'Token', desc: 'Gitea access token', default: '-' },
{ name: 'Organization', desc: 'Default organization for mirrored repositories', default: '-' },
{ name: 'Visibility', desc: 'Default visibility for mirrored repositories', default: 'public' },
{ name: 'Starred Repos Org', desc: 'Organization for starred repositories', default: 'github' }
];
---
<MainLayout title="Configuration - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>Configuration Guide</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">Gitea Mirror Configuration</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
This guide provides detailed information on how to configure Gitea Mirror for your environment.
</p>
</div>
<!-- Configuration Methods -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Configuration Methods</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
<div class="bg-card rounded-lg border border-border p-6 hover:border-primary/50 transition-colors">
<div class="flex items-start gap-4">
<div class="text-2xl">🔧</div>
<div>
<h3 class="font-semibold text-lg mb-2">Environment Variables</h3>
<p class="text-sm text-muted-foreground">Set configuration options through environment variables for automated deployments</p>
</div>
</div>
</div>
<div class="bg-card rounded-lg border border-border p-6 hover:border-primary/50 transition-colors">
<div class="flex items-start gap-4">
<div class="text-2xl">🖥️</div>
<div>
<h3 class="font-semibold text-lg mb-2">Web UI</h3>
<p class="text-sm text-muted-foreground">Configure the application through the web interface after installation</p>
</div>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Environment Variables -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Environment Variables</h2>
<p class="text-muted-foreground mb-6">The following environment variables can be used to configure Gitea Mirror:</p>
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-border">
<th class="text-left py-3 px-4 font-semibold">Variable</th>
<th class="text-left py-3 px-4 font-semibold">Description</th>
<th class="text-left py-3 px-4 font-semibold">Default</th>
<th class="text-left py-3 px-4 font-semibold">Example</th>
</tr>
</thead>
<tbody>
{envVars.map((v, i) => (
<tr class={`border-b border-border/50 hover:bg-muted/30 ${i === envVars.length - 1 ? 'border-b-0' : ''}`}>
<td class="py-3 px-4">
<code class="text-sm bg-muted px-1.5 py-0.5 rounded">{v.name}</code>
</td>
<td class="py-3 px-4 text-sm text-muted-foreground">{v.desc}</td>
<td class="py-3 px-4 text-sm"><code>{v.default}</code></td>
<td class="py-3 px-4 text-sm"><code>{v.example}</code></td>
</tr>
))}
</tbody>
</table>
</div>
<!-- Security Note -->
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4 mt-6">
<div class="flex gap-3">
<div class="text-amber-600 dark:text-amber-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div>
<h4 class="font-semibold text-amber-600 dark:text-amber-500 mb-1">Security Note</h4>
<p class="text-sm">The application will automatically generate a secure random <code class="bg-amber-500/10 px-1 py-0.5 rounded">JWT_SECRET</code> on first run if one isn't provided. This generated secret is stored in the data directory for persistence across container restarts.</p>
<p class="text-sm mt-2">While this auto-generation feature provides good security by default, you can still explicitly set your own <code class="bg-amber-500/10 px-1 py-0.5 rounded">JWT_SECRET</code> for complete control over your deployment.</p>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Web UI Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Web UI Configuration</h2>
<p class="text-muted-foreground mb-6">After installing and starting Gitea Mirror, you can configure it through the web interface:</p>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-8">
<ol class="space-y-3">
{[
'Navigate to <code class="bg-muted px-1.5 py-0.5 rounded text-sm">http://your-server:port/</code>',
'If this is your first time, you\'ll be guided through creating an admin account',
'Log in with your credentials',
'Go to the Configuration page'
].map((step, i) => (
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">{i + 1}</span>
<span set:html={step}></span>
</li>
))}
</ol>
</div>
<!-- GitHub Configuration -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">GitHub Configuration</h3>
<p class="text-muted-foreground mb-4">The GitHub configuration section allows you to connect to GitHub and specify which repositories to mirror.</p>
<div class="overflow-x-auto mb-6">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-border">
<th class="text-left py-3 px-4 font-semibold">Option</th>
<th class="text-left py-3 px-4 font-semibold">Description</th>
<th class="text-left py-3 px-4 font-semibold">Default</th>
</tr>
</thead>
<tbody>
{githubOptions.map((opt, i) => (
<tr class={`border-b border-border/50 hover:bg-muted/30 ${i === githubOptions.length - 1 ? 'border-b-0' : ''}`}>
<td class="py-3 px-4 font-medium">{opt.name}</td>
<td class="py-3 px-4 text-sm text-muted-foreground">{opt.desc}</td>
<td class="py-3 px-4 text-sm"><code>{opt.default}</code></td>
</tr>
))}
</tbody>
</table>
</div>
<!-- GitHub Token Permissions -->
<div class="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4 mb-6">
<div class="flex gap-3">
<div class="text-blue-600 dark:text-blue-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<h4 class="font-semibold text-blue-600 dark:text-blue-500 mb-2">Required Permissions</h4>
<p class="text-sm mb-3">You need to create a <span class="font-semibold">Classic GitHub PAT Token</span> with the following scopes:</p>
<ul class="space-y-1 text-sm">
<li class="flex gap-2">
<span class="text-blue-600 dark:text-blue-500">•</span>
<span><code class="bg-blue-500/10 px-1 py-0.5 rounded">repo</code> - Full control of private repositories</span>
</li>
<li class="flex gap-2">
<span class="text-blue-600 dark:text-blue-500">•</span>
<span><code class="bg-blue-500/10 px-1 py-0.5 rounded">admin:org</code> - Full control of orgs and teams, read and write org projects</span>
</li>
</ul>
<p class="text-sm mt-2">The organization access is required for mirroring organization repositories.</p>
</div>
</div>
</div>
<div class="pl-4 border-l-2 border-primary/20">
<h5 class="font-semibold mb-3">To create a GitHub token:</h5>
<ol class="space-y-2 text-sm">
<li>Go to <a href="https://github.com/settings/tokens" class="text-primary hover:underline">GitHub Settings > Developer settings > Personal access tokens</a></li>
<li>Click "Generate new token"</li>
<li>Select the required permissions</li>
<li>Copy the generated token and paste it into Gitea Mirror</li>
</ol>
</div>
</div>
<!-- Gitea Configuration -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Gitea Configuration</h3>
<p class="text-muted-foreground mb-4">The Gitea configuration section allows you to connect to your Gitea instance and specify how repositories should be mirrored.</p>
<div class="overflow-x-auto mb-6">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-border">
<th class="text-left py-3 px-4 font-semibold">Option</th>
<th class="text-left py-3 px-4 font-semibold">Description</th>
<th class="text-left py-3 px-4 font-semibold">Default</th>
</tr>
</thead>
<tbody>
{giteaOptions.map((opt, i) => (
<tr class={`border-b border-border/50 hover:bg-muted/30 ${i === giteaOptions.length - 1 ? 'border-b-0' : ''}`}>
<td class="py-3 px-4 font-medium">{opt.name}</td>
<td class="py-3 px-4 text-sm text-muted-foreground">{opt.desc}</td>
<td class="py-3 px-4 text-sm"><code>{opt.default}</code></td>
</tr>
))}
</tbody>
</table>
</div>
<!-- Mirror Strategies -->
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-6">
<h4 class="font-semibold text-lg mb-4">Mirror Strategies</h4>
<p class="text-sm text-muted-foreground mb-4">Choose how your repositories will be organized in Gitea:</p>
<div class="space-y-4">
<div class="bg-card rounded-lg border border-border p-4">
<h5 class="font-semibold text-base mb-2 flex items-center gap-2">
<span class="text-blue-600 dark:text-blue-500">📁</span>
Preserve GitHub Structure
</h5>
<p class="text-sm text-muted-foreground mb-2">Maintains the exact structure from GitHub:</p>
<ul class="space-y-1 text-sm ml-4">
<li class="flex gap-2">
<span class="text-muted-foreground">•</span>
<span>Personal repos → Your Gitea username</span>
</li>
<li class="flex gap-2">
<span class="text-muted-foreground">•</span>
<span>Organization repos → Same organization name in Gitea</span>
</li>
</ul>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h5 class="font-semibold text-base mb-2 flex items-center gap-2">
<span class="text-purple-600 dark:text-purple-500">🏢</span>
Single Organization
</h5>
<p class="text-sm text-muted-foreground mb-2">Consolidates all repositories into one organization:</p>
<ul class="space-y-1 text-sm ml-4">
<li class="flex gap-2">
<span class="text-muted-foreground">•</span>
<span>All repos → One designated organization</span>
</li>
<li class="flex gap-2">
<span class="text-muted-foreground">•</span>
<span>Requires setting "Organization" field</span>
</li>
</ul>
</div>
<div class="bg-card rounded-lg border border-border p-4">
<h5 class="font-semibold text-base mb-2 flex items-center gap-2">
<span class="text-green-600 dark:text-green-500">👤</span>
Flat User Structure
</h5>
<p class="text-sm text-muted-foreground mb-2">Mirrors all repositories under your user account:</p>
<ul class="space-y-1 text-sm ml-4">
<li class="flex gap-2">
<span class="text-muted-foreground">•</span>
<span>All repos → Your Gitea username</span>
</li>
<li class="flex gap-2">
<span class="text-muted-foreground">•</span>
<span>No organizations needed</span>
</li>
</ul>
</div>
</div>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-3 mt-4">
<p class="text-sm">
<span class="font-semibold">Note:</span> Starred repositories are always mirrored to the "Starred Repos Org" (default: "starred") regardless of the chosen strategy.
</p>
</div>
</div>
<div class="pl-4 border-l-2 border-primary/20">
<h5 class="font-semibold mb-3">To create a Gitea access token:</h5>
<ol class="space-y-2 text-sm">
<li>Log in to your Gitea instance</li>
<li>Go to Settings > Applications</li>
<li>Under "Generate New Token", enter a name for your token</li>
<li>Click "Generate Token"</li>
<li>Copy the generated token and paste it into Gitea Mirror</li>
</ol>
</div>
</div>
<!-- Schedule Configuration -->
<div>
<h3 class="text-xl font-semibold mb-4">Schedule Configuration</h3>
<p class="text-muted-foreground mb-4">You can configure automatic mirroring on a schedule:</p>
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-border">
<th class="text-left py-3 px-4 font-semibold">Option</th>
<th class="text-left py-3 px-4 font-semibold">Description</th>
<th class="text-left py-3 px-4 font-semibold">Default</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-border/50 hover:bg-muted/30">
<td class="py-3 px-4 font-medium">Enable Scheduling</td>
<td class="py-3 px-4 text-sm text-muted-foreground">Enable automatic mirroring</td>
<td class="py-3 px-4 text-sm"><code>false</code></td>
</tr>
<tr class="hover:bg-muted/30">
<td class="py-3 px-4 font-medium">Interval (seconds)</td>
<td class="py-3 px-4 text-sm text-muted-foreground">Time between mirroring operations</td>
<td class="py-3 px-4 text-sm"><code>3600</code> (1 hour)</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Advanced Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Advanced Configuration</h2>
<!-- Database Management -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Database Management</h3>
<p class="text-muted-foreground mb-4">Gitea Mirror includes several database management tools that can be run from the command line:</p>
<div class="bg-muted/30 rounded-lg p-4 overflow-x-auto">
<pre class="text-sm whitespace-pre-wrap break-all"><code>{`# Initialize the database (only if it doesn't exist)
bun run init-db
# Check database status
bun run check-db
# Fix database location issues
bun run fix-db
# Reset all users (for testing signup flow)
bun run reset-users
# Remove database files completely
bun run cleanup-db`}</code></pre>
</div>
</div>
<!-- Event Management -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Event Management</h3>
<p class="text-muted-foreground mb-4">Events in Gitea Mirror (such as repository mirroring operations) are stored in the SQLite database and can be viewed in the Activity Log page.</p>
<div class="bg-green-500/10 border border-green-500/20 rounded-lg p-4">
<div class="flex gap-3">
<div class="text-green-600 dark:text-green-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="text-sm mb-2">Event Management Features:</p>
<ul class="space-y-1 text-sm">
<li class="flex gap-2">
<span class="text-green-600 dark:text-green-500">•</span>
<span>View all events with filtering by type, status, and search</span>
</li>
<li class="flex gap-2">
<span class="text-green-600 dark:text-green-500">•</span>
<span>Real-time updates via Server-Sent Events (SSE)</span>
</li>
<li class="flex gap-2">
<span class="text-green-600 dark:text-green-500">•</span>
<span>Clean up old events using the cleanup button in the Activity Log</span>
</li>
<li class="flex gap-2">
<span class="text-green-600 dark:text-green-500">•</span>
<span>Automatic cleanup with configurable retention period</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Automatic Recovery System -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4">Automatic Recovery System</h3>
<p class="text-muted-foreground mb-4">Gitea Mirror includes a robust recovery system that automatically handles interrupted operations:</p>
<div class="bg-card rounded-lg border border-border p-6">
<ul class="space-y-3">
<li class="flex gap-2">
<span class="text-primary">✓</span>
<div>
<span class="font-semibold">Startup Recovery:</span>
<p class="text-sm text-muted-foreground mt-1">Automatically recovers interrupted jobs when the application starts</p>
</div>
</li>
<li class="flex gap-2">
<span class="text-primary">✓</span>
<div>
<span class="font-semibold">Graceful Shutdown:</span>
<p class="text-sm text-muted-foreground mt-1">Saves job state before shutting down to enable recovery on restart</p>
</div>
</li>
<li class="flex gap-2">
<span class="text-primary">✓</span>
<div>
<span class="font-semibold">Job State Persistence:</span>
<p class="text-sm text-muted-foreground mt-1">Stores mirror job progress in the database for resilience</p>
</div>
</li>
</ul>
<div class="mt-4 bg-muted/30 rounded-lg p-4 overflow-x-auto">
<p class="text-sm font-medium mb-2">Manual recovery tools:</p>
<pre class="text-sm whitespace-pre-wrap break-all"><code>{`# Run startup recovery manually
bun run startup-recovery
# Fix interrupted jobs
bun scripts/fix-interrupted-jobs.ts
# Test recovery system
bun run test-recovery`}</code></pre>
</div>
</div>
</div>
<!-- Health Check Endpoint -->
<div>
<h3 class="text-xl font-semibold mb-4">Health Check Endpoint</h3>
<div class="bg-card rounded-lg border border-border p-6">
<h4 class="font-semibold mb-3">System Health Monitoring</h4>
<p class="text-muted-foreground mb-4">Gitea Mirror includes a built-in health check endpoint at <code class="bg-muted px-1.5 py-0.5 rounded">/api/health</code> that provides:</p>
<ul class="space-y-2 mb-6">
<li class="flex gap-2">
<span class="text-primary">✓</span>
<span>System status and uptime</span>
</li>
<li class="flex gap-2">
<span class="text-primary">✓</span>
<span>Database connectivity check</span>
</li>
<li class="flex gap-2">
<span class="text-primary">✓</span>
<span>Memory usage statistics</span>
</li>
<li class="flex gap-2">
<span class="text-primary">✓</span>
<span>Environment information</span>
</li>
</ul>
<div class="bg-muted/30 rounded-lg p-4 overflow-x-auto">
<pre class="text-sm whitespace-pre-wrap break-all"><code>{`# Basic check (returns 200 OK if healthy)
curl -I http://your-server:port/api/health
# Detailed health information (JSON)
curl http://your-server:port/api/health`}</code></pre>
</div>
</div>
</div>
</section>
</article>
</main>
</MainLayout>
+3 -87
View File
@@ -1,89 +1,5 @@
---
import MainLayout from '../../layouts/main.astro';
import { LuSettings, LuRocket, LuBookOpen, LuShield, LuKey, LuNetwork } from 'react-icons/lu';
import { withBase } from '@/lib/base-path';
// Define our documentation pages directly
const docs = [
{
slug: 'quickstart',
title: 'Quick Start Guide',
description: 'Get started with Gitea Mirror quickly.',
order: 1,
icon: LuRocket,
href: '/docs/quickstart'
},
{
slug: 'configuration',
title: 'Configuration',
description: 'Guide to configuring Gitea Mirror for your environment.',
order: 2,
icon: LuSettings,
href: '/docs/configuration'
},
{
slug: 'authentication',
title: 'Authentication & SSO',
description: 'Configure authentication methods, SSO providers, and OIDC.',
order: 3,
icon: LuKey,
href: '/docs/authentication'
},
{
slug: 'architecture',
title: 'Architecture',
description: 'Comprehensive overview of the Gitea Mirror application architecture.',
order: 4,
icon: LuBookOpen,
href: '/docs/architecture'
},
{
slug: 'ca-certificates',
title: 'CA Certificates',
description: 'Configure custom CA certificates for self-signed Gitea instances.',
order: 5,
icon: LuShield,
href: '/docs/ca-certificates'
},
{
slug: 'advanced',
title: 'Advanced Topics',
description: 'Advanced configuration, troubleshooting, and deployment options.',
order: 6,
icon: LuNetwork,
href: '/docs/advanced'
}
];
// Sort by order
const sortedDocs = docs.sort((a, b) => a.order - b.order);
// The documentation moved to the website. This stub keeps old bookmarks
// working; the offline reference lives in the repo's docs/ directory.
return Astro.redirect("https://gitea-mirror.raylabs.io/docs/");
---
<MainLayout title="Documentation">
<main class="max-w-5xl mx-auto px-4 py-12">
<h1 class="text-3xl font-bold mb-2 text-center text-foreground">Gitea Mirror Documentation</h1>
<p class="mb-10 text-lg text-muted-foreground text-center">Browse guides and technical docs for Gitea Mirror.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
{sortedDocs.map(doc => {
const Icon = doc.icon;
return (
<a
href={withBase(doc.href)}
class="group block p-7 border border-border rounded-2xl bg-card hover:bg-muted transition-colors shadow-lg focus:ring-2 focus:ring-ring outline-none"
tabindex="0"
>
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-muted rounded-full flex items-center justify-center text-muted-foreground">
<Icon className="w-5 h-5" />
</div>
<h2 class="text-xl font-semibold group-hover:text-foreground transition">{doc.title}</h2>
</div>
<p class="text-muted-foreground">{doc.description}</p>
</a>
);
})}
</div>
</main>
</MainLayout>
-438
View File
@@ -1,438 +0,0 @@
---
import MainLayout from '../../layouts/main.astro';
import { withBase } from '@/lib/base-path';
---
<MainLayout title="Quick Start Guide - Gitea Mirror">
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href={withBase('/docs/')}
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 md:p-8 border border-border">
<!-- Header -->
<div class="mb-12 space-y-4">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span>Quick Start</span>
</div>
<h1 class="text-4xl font-bold tracking-tight">Gitea Mirror Quick Start Guide</h1>
<p class="text-lg text-muted-foreground leading-relaxed max-w-4xl">
This guide will help you get Gitea Mirror up and running quickly.
</p>
</div>
<!-- Prerequisites -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Prerequisites</h2>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary mb-8">
<h3 class="font-semibold mb-4">Before you begin, make sure you have:</h3>
<div class="space-y-3">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">1</div>
<div>
<span class="font-semibold text-foreground">A GitHub account with a personal access token</span>
<p class="text-sm text-muted-foreground mt-1">Create one at <a href="https://github.com/settings/tokens" class="text-primary hover:underline">GitHub Settings</a></p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">2</div>
<div>
<span class="font-semibold text-foreground">A Gitea instance with an access token</span>
<p class="text-sm text-muted-foreground mt-1">Available in your Gitea Settings > Applications</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">3</div>
<div>
<span class="font-semibold text-foreground">One of the following:</span>
<ul class="mt-2 space-y-1 text-sm text-muted-foreground pl-4">
<li class="flex gap-2"><span>•</span> Docker and docker-compose (for Docker deployment)</li>
<li class="flex gap-2"><span>•</span> Bun 1.2.9+ (for native deployment)</li>
<li class="flex gap-2"><span>•</span> Proxmox VE or LXD (for LXC container deployment)</li>
</ul>
</div>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Installation Options -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Installation Options</h2>
<p class="text-muted-foreground mb-8">Choose the installation method that works best for your environment.</p>
<!-- Docker Installation -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
🐳 Using Docker <span class="text-sm text-muted-foreground font-normal">(Recommended for most users)</span>
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<p class="text-muted-foreground mb-4">Docker provides the easiest way to get started with minimal configuration.</p>
<div class="space-y-4">
{[
{ step: 'Clone the repository', cmd: 'git clone https://github.com/RayLabsHQ/gitea-mirror.git\ncd gitea-mirror' },
{ step: 'Start the application in production mode', cmd: 'docker compose up -d' },
{ step: 'Access the application', cmd: null, text: 'Open your browser and navigate to <a href="http://localhost:4321" class="text-primary hover:underline font-medium">http://localhost:4321</a>' }
].map((item, i) => (
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-semibold text-muted-foreground">STEP {i + 1}</span>
<span class="text-sm text-muted-foreground">{item.step}</span>
</div>
{item.cmd ? (
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>{item.cmd}</code></pre>
</div>
) : (
<p class="text-sm" set:html={item.text}></p>
)}
</div>
))}
</div>
</div>
</div>
<!-- Bun Installation -->
<div class="mb-8">
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
🏃 Using Bun <span class="text-sm text-muted-foreground font-normal">(Native Installation)</span>
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<p class="text-muted-foreground mb-4">If you prefer to run the application directly on your system:</p>
<div class="space-y-4">
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-semibold text-muted-foreground">STEP 1</span>
<span class="text-sm text-muted-foreground">Clone the repository</span>
</div>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>git clone https://github.com/RayLabsHQ/gitea-mirror.git
cd gitea-mirror</code></pre>
</div>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-semibold text-muted-foreground">STEP 2</span>
<span class="text-sm text-muted-foreground">Run the quick setup script</span>
</div>
<div class="bg-muted/30 rounded-lg p-4">
<pre class="text-sm"><code>bun run setup</code></pre>
</div>
<p class="text-sm text-muted-foreground mt-2">This installs dependencies and initializes the database.</p>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-semibold text-muted-foreground">STEP 3</span>
<span class="text-sm text-muted-foreground">Choose how to run the application</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
<div class="border border-border rounded-lg p-4">
<h5 class="font-semibold mb-2">Development Mode</h5>
<div class="bg-muted/30 rounded-lg p-3 text-sm">
<pre><code>bun run dev</code></pre>
</div>
<p class="text-xs text-muted-foreground mt-2">For Bun-specific features, use: <code class="bg-muted px-1 rounded">bunx --bun astro dev</code></p>
</div>
<div class="border border-border rounded-lg p-4">
<h5 class="font-semibold mb-2">Production Mode</h5>
<div class="bg-muted/30 rounded-lg p-3 text-sm">
<pre><code>bun run build
bun run start</code></pre>
</div>
</div>
</div>
</div>
<div>
<div class="flex items-center gap-2 mb-2">
<span class="text-sm font-semibold text-muted-foreground">STEP 4</span>
<span class="text-sm text-muted-foreground">Access the application</span>
</div>
<p class="text-sm">Open your browser and navigate to <a href="http://localhost:4321" class="text-primary hover:underline font-medium">http://localhost:4321</a></p>
</div>
</div>
</div>
</div>
<!-- LXC Installation -->
<div>
<h3 class="text-xl font-semibold mb-4 flex items-center gap-2">
📦 Using LXC Containers <span class="text-sm text-muted-foreground font-normal">(Recommended for Proxmox VE)</span>
</h3>
<div class="bg-card rounded-lg border border-border p-6">
<div class="space-y-4">
<h4 class="font-semibold flex items-center gap-2">
<span>Proxmox VE Installation</span>
<span class="text-xs text-muted-foreground font-normal">(Community Script)</span>
</h4>
<p class="text-sm text-muted-foreground mb-4">Deploy Gitea Mirror on Proxmox VE using the community-maintained script:</p>
<div class="bg-muted/30 rounded-lg p-4 overflow-x-auto">
<pre class="text-sm whitespace-pre-wrap break-all"><code>bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gitea-mirror.sh)"</code></pre>
</div>
<div class="mt-4 text-sm text-muted-foreground">
<p class="font-medium mb-2">This script will:</p>
<ul class="space-y-1 pl-4">
<li class="flex gap-2"><span>•</span> Create a privileged Alpine Linux LXC container</li>
<li class="flex gap-2"><span>•</span> Install Bun runtime and dependencies</li>
<li class="flex gap-2"><span>•</span> Clone and build Gitea Mirror</li>
<li class="flex gap-2"><span>•</span> Configure systemd service for automatic startup</li>
<li class="flex gap-2"><span>•</span> Set up the application to run on port 4321</li>
</ul>
</div>
<div class="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<div class="flex gap-3">
<div class="text-blue-600 dark:text-blue-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="text-sm">
<p class="font-semibold text-blue-600 dark:text-blue-500 mb-1">Note</p>
<p>After installation, access Gitea Mirror at <code class="bg-blue-500/10 px-1 py-0.5 rounded">http://&lt;container-ip&gt;:4321</code></p>
</div>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-border">
<p class="text-sm text-muted-foreground">
For more information about the community script, visit the
<a href="https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror" class="text-primary hover:underline">Community Scripts documentation</a>.
</p>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Initial Configuration -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Initial Configuration</h2>
<p class="text-muted-foreground mb-6">Follow these steps to configure Gitea Mirror for first use:</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
num: '1',
title: 'Create Admin Account',
items: [
"You'll be prompted on first access",
'Enter your email address and password',
'This will be your administrator account'
]
},
{
num: '2',
title: 'Configure GitHub Connection',
items: [
'Navigate to the Configuration page',
'Enter your GitHub credentials',
'Select repositories to mirror',
'Configure filtering options'
]
},
{
num: '3',
title: 'Configure Gitea Connection',
items: [
'Enter your Gitea server URL',
'Enter your Gitea access token',
'Configure organization settings',
'Set default visibility'
]
},
{
num: '4',
title: 'Set Up Scheduling',
items: [
'Enable automatic mirroring',
'Set the mirroring interval',
'Save your configuration'
]
}
].map(step => (
<div class="bg-card rounded-lg border border-border p-4 hover:border-primary/50 transition-colors">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">{step.num}</div>
<div>
<h4 class="font-semibold mb-1">{step.title}</h4>
<ul class="text-sm text-muted-foreground space-y-1">
{step.items.map(item => (
<li>• {item}</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Performing Your First Mirror -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Performing Your First Mirror</h2>
<div class="bg-gradient-to-r from-primary/5 to-transparent rounded-lg p-6 border-l-4 border-primary">
<h3 class="font-semibold mb-4">After completing the configuration, you can start mirroring repositories:</h3>
<ol class="space-y-3">
{[
'Click <strong>"Import GitHub Data"</strong> to fetch repositories from GitHub',
'Go to the <strong>Repositories</strong> page to view your imported repositories',
'Select the repositories you want to mirror',
'Click <strong>"Mirror Selected"</strong> to start the mirroring process',
'Monitor the progress on the <strong>Activity</strong> page',
"You'll receive toast notifications about the success or failure of operations"
].map((step, i) => (
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-primary/10 rounded-full flex items-center justify-center text-sm font-semibold">{i + 1}</span>
<span set:html={step}></span>
</li>
))}
</ol>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Troubleshooting -->
<section class="mb-12">
<h2 class="text-2xl font-bold mb-6">Troubleshooting</h2>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<div class="flex gap-3">
<div class="text-amber-600 dark:text-amber-500">
<svg class="w-5 h-5 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="space-y-3">
<h4 class="font-semibold text-amber-600 dark:text-amber-500">If you encounter any issues:</h4>
<div class="space-y-2 text-sm">
<div class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Check the <strong>Activity Log</strong> for detailed error messages</span>
</div>
<div class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Verify your GitHub and Gitea tokens have the correct permissions</span>
</div>
<div class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Ensure your Gitea instance is accessible from the machine running Gitea Mirror</span>
</div>
</div>
<div class="mt-4 space-y-3">
<h5 class="font-semibold">Check logs based on your deployment method:</h5>
<div class="bg-amber-500/5 rounded-lg p-3 space-y-2">
<div class="grid grid-cols-1 gap-2 text-sm">
<div>
<span class="font-medium">Docker:</span>
<code class="bg-amber-500/10 px-1.5 py-0.5 rounded ml-2">docker logs gitea-mirror</code>
</div>
<div>
<span class="font-medium">Native:</span>
<span class="text-muted-foreground ml-2">Check terminal output or system logs</span>
</div>
<div>
<span class="font-medium">LXC:</span>
<code class="bg-amber-500/10 px-1.5 py-0.5 rounded ml-2">systemctl status gitea-mirror</code>
<span class="text-muted-foreground ml-1">or</span>
<code class="bg-amber-500/10 px-1.5 py-0.5 rounded ml-1">journalctl -u gitea-mirror -f</code>
</div>
</div>
</div>
<div class="space-y-2 text-sm">
<div class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>Use the health check endpoint: <code class="bg-amber-500/10 px-1.5 py-0.5 rounded">curl http://your-server:4321/api/health</code></span>
</div>
<div class="flex gap-2">
<span class="text-amber-600 dark:text-amber-500">•</span>
<span>For database issues: <code class="bg-amber-500/10 px-1.5 py-0.5 rounded">bun run check-db</code> or <code class="bg-amber-500/10 px-1.5 py-0.5 rounded">bun run fix-db</code></span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="my-12 h-px bg-border/50"></div>
<!-- Next Steps -->
<section>
<h2 class="text-2xl font-bold mb-6">Next Steps</h2>
<div class="bg-gradient-to-br from-primary/5 via-transparent to-primary/5 rounded-lg p-6 border border-border">
<h3 class="font-semibold mb-4">After your initial setup:</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-3">
{[
'Explore the dashboard for an overview of your mirroring status',
'Set up automatic mirroring schedules for hands-off operation',
'Configure organization mirroring for team repositories',
'Use the cleanup button in Activity Log to manage old events'
].map(item => (
<div class="flex gap-3">
<span class="text-primary">✓</span>
<span>{item}</span>
</div>
))}
</div>
<div class="space-y-3">
<div class="flex gap-3">
<span class="text-primary">📖</span>
<span>Check out the <a href={withBase('/docs/configuration')} class="text-primary hover:underline font-medium">Configuration Guide</a> for advanced settings</span>
</div>
<div class="flex gap-3">
<span class="text-primary">🏗️</span>
<span>Review the <a href={withBase('/docs/architecture')} class="text-primary hover:underline font-medium">Architecture Documentation</a> to understand the system</span>
</div>
<div class="flex gap-3">
<span class="text-primary">📊</span>
<span>For server deployments, set up monitoring using the health check endpoint</span>
</div>
</div>
</div>
</div>
</section>
</article>
</main>
</MainLayout>
+14 -1
View File
@@ -119,14 +119,27 @@ export interface AppriseConfig {
tag?: string;
}
export interface GotifyConfig {
url: string;
token: string;
priority: number;
}
export interface WebhookConfig {
url: string;
secret?: string;
}
export interface NotificationConfig {
enabled: boolean;
provider: "ntfy" | "apprise";
provider: "ntfy" | "apprise" | "gotify" | "webhook";
notifyOnSyncError: boolean;
notifyOnSyncSuccess: boolean;
notifyOnNewRepo: boolean;
ntfy?: NtfyConfig;
apprise?: AppriseConfig;
gotify?: GotifyConfig;
webhook?: WebhookConfig;
}
export interface Config extends ConfigType {}
+14 -9
View File
@@ -9,27 +9,32 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^5.0.6",
"@astrojs/react": "^5.0.7",
"@radix-ui/react-slot": "^1.2.5",
"@astrojs/mdx": "^7.0.3",
"@astrojs/react": "^6.0.1",
"@radix-ui/react-slot": "^1.3.0",
"@splinetool/react-spline": "^4.1.0",
"@splinetool/runtime": "^1.12.97",
"@tailwindcss/vite": "^4.3.1",
"@splinetool/runtime": "^1.12.98",
"@tailwindcss/vite": "^4.3.3",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^6.4.6",
"astro": "^7.1.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1"
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"tw-animate-css": "^1.4.0"
},
"packageManager": "pnpm@10.32.1"
"packageManager": "pnpm@10.32.1",
"pnpm": {
"overrides": {
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1"
}
}
}
+1113 -682
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 905 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 45 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
# Robots.txt for Gitea Mirror
User-agent: *
Allow: /
Sitemap: https://gitea-mirror.com/sitemap.xml
Sitemap: https://gitea-mirror.raylabs.io/sitemap.xml
# Crawl-delay for responsible crawling
User-agent: *
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://gitea-mirror.com/</loc>
<loc>https://gitea-mirror.raylabs.io/</loc>
<lastmod>2025-01-08</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
+3 -2
View File
@@ -1,11 +1,12 @@
---
import { Github, Book, MessageSquare, Bug } from 'lucide-react';
import { Book, MessageSquare, Bug } from 'lucide-react';
import { GithubIcon } from './icons/GithubIcon';
const links = [
{
title: "Source Code",
href: "https://github.com/RayLabsHQ/gitea-mirror",
icon: Github
icon: GithubIcon
},
{
title: "Documentation",
+3 -2
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { Github, Star } from 'lucide-react';
import { Star } from 'lucide-react';
import { GithubIcon } from './icons/GithubIcon';
import { Button } from './ui/button';
export function GitHubButton() {
@@ -44,7 +45,7 @@ export function GitHubButton() {
asChild
>
<a href="https://github.com/RayLabsHQ/gitea-mirror" target="_blank" rel="noopener noreferrer" className="flex items-center">
<Github className="w-4 h-4 mr-2" />
<GithubIcon className="w-4 h-4 mr-2" />
<span>Star on GitHub</span>
{stars !== null && (
<>
+2 -1
View File
@@ -18,7 +18,8 @@ export function Header() {
{ href: '/#features', label: 'Features' },
{ href: '/#use-cases', label: 'Use Cases' },
{ href: '/#screenshots', label: 'Screenshots' },
{ href: '/#installation', label: 'Installation' }
{ href: '/#installation', label: 'Installation' },
{ href: '/docs/', label: 'Docs' }
];
return (
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
// GitHub brand mark. Replaces lucide-react's `Github` icon, which was removed
// along with all brand icons in lucide-react v1.
export function GithubIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className={className}
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}
+281
View File
@@ -0,0 +1,281 @@
---
import '../styles/global.css';
import { Header } from '../components/Header';
import Footer from '../components/Footer.astro';
import { docsNav } from '../lib/docs-nav';
const { frontmatter, headings = [] } = Astro.props;
const title = frontmatter?.title ?? 'Documentation';
const description =
frontmatter?.description ??
'Documentation for Gitea Mirror, the self-hosted GitHub to Gitea mirroring service.';
const currentPath = Astro.url.pathname.endsWith('/')
? Astro.url.pathname
: `${Astro.url.pathname}/`;
const canonical = `https://gitea-mirror.raylabs.io${currentPath}`;
const toc = headings.filter((h: { depth: number }) => h.depth === 2 || h.depth === 3);
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} · Gitea Mirror Docs</title>
<meta name="description" content={description} />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="canonical" href={canonical} />
<meta property="og:title" content={`${title} · Gitea Mirror Docs`} />
<meta property="og:description" content={description} />
<meta property="og:type" content="article" />
<meta property="og:url" content={canonical} />
<meta property="og:image" content="https://gitea-mirror.raylabs.io/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<script is:inline>
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', theme === 'dark');
</script>
</head>
<body class="min-h-screen bg-background text-foreground antialiased">
<Header client:load />
<div class="mx-auto max-w-7xl px-4 pt-24 pb-20 sm:px-6 lg:px-8">
<div class="flex gap-10">
<!-- Sidebar navigation -->
<aside class="hidden w-56 shrink-0 lg:block">
<nav class="sticky top-24 space-y-6" aria-label="Docs">
<a
href="/docs/"
class="block text-sm font-semibold text-foreground hover:text-indigo-500"
>
Documentation
</a>
{docsNav.map((section) => (
<div class="space-y-2">
<div class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
{section.title}
</div>
<ul class="space-y-1">
{section.items.map((item) => (
<li>
<a
href={item.href}
class:list={[
'block rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
currentPath === item.href
? 'bg-indigo-500/10 font-medium text-indigo-500'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
]}
>
{item.title}
</a>
</li>
))}
</ul>
</div>
))}
</nav>
</aside>
<!-- Content -->
<main class="min-w-0 flex-1">
<!-- Mobile section picker -->
<details class="mb-6 rounded-lg border border-border lg:hidden">
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium">
Docs navigation
</summary>
<div class="space-y-4 border-t border-border p-4">
{docsNav.map((section) => (
<div class="space-y-1.5">
<div class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
{section.title}
</div>
<ul class="space-y-1">
{section.items.map((item) => (
<li>
<a href={item.href} class="block py-1 text-sm text-muted-foreground">
{item.title}
</a>
</li>
))}
</ul>
</div>
))}
</div>
</details>
<article class="docs-content">
<slot />
</article>
</main>
<!-- Table of contents -->
{toc.length > 1 && (
<aside class="hidden w-52 shrink-0 xl:block">
<nav class="sticky top-24 space-y-2" aria-label="On this page">
<div class="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
On this page
</div>
<ul class="space-y-1 border-l border-border">
{toc.map((h: { depth: number; slug: string; text: string }) => (
<li>
<a
href={`#${h.slug}`}
class:list={[
'block border-l-2 border-transparent py-0.5 text-[13px] text-muted-foreground transition-colors hover:border-indigo-500 hover:text-foreground',
h.depth === 2 ? 'pl-3' : 'pl-6',
]}
>
{h.text}
</a>
</li>
))}
</ul>
</nav>
</aside>
)}
</div>
</div>
<Footer />
</body>
</html>
<style is:global>
.docs-content {
font-size: 1rem;
line-height: 1.75;
color: var(--muted-foreground);
}
.docs-content > * + * {
margin-top: 1.1rem;
}
.docs-content :is(h1, h2, h3, h4) {
font-weight: 700;
color: var(--foreground);
letter-spacing: -0.01em;
scroll-margin-top: 6rem;
}
.docs-content h1 {
font-size: clamp(1.9rem, 2.5vw + 1rem, 2.5rem);
line-height: 1.15;
}
.docs-content h2 {
font-size: 1.5rem;
margin-top: 2.75rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.docs-content h3 {
font-size: 1.15rem;
margin-top: 2rem;
}
.docs-content a {
color: var(--foreground);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
}
.docs-content a:hover {
text-decoration-color: currentColor;
}
.docs-content strong {
color: var(--foreground);
}
.docs-content :is(ul, ol) {
padding-left: 1.4rem;
}
.docs-content ul {
list-style: disc;
}
.docs-content ol {
list-style: decimal;
}
.docs-content li + li {
margin-top: 0.35rem;
}
.docs-content code {
font-size: 0.85em;
background: var(--muted);
color: var(--foreground);
border-radius: 0.3rem;
padding: 0.15rem 0.4rem;
}
.docs-content pre {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 0.6rem;
padding: 1rem 1.25rem;
font-size: 0.85rem;
line-height: 1.6;
}
.docs-content pre code {
background: transparent;
padding: 0;
font-size: inherit;
}
.docs-content blockquote {
border-left: 3px solid var(--border);
border-radius: 0.4rem;
background: var(--muted);
padding: 0.85rem 1.1rem;
font-size: 0.95rem;
}
.docs-content blockquote p {
margin: 0;
}
.docs-content table {
display: block;
overflow-x: auto;
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.docs-content th {
text-align: left;
color: var(--foreground);
border-bottom: 1px solid var(--border);
padding: 0.5rem 0.9rem 0.5rem 0;
white-space: nowrap;
}
.docs-content td {
border-bottom: 1px solid var(--border);
padding: 0.5rem 0.9rem 0.5rem 0;
vertical-align: top;
}
.docs-content td code {
white-space: nowrap;
}
.docs-content img {
max-width: 100%;
border-radius: 0.6rem;
border: 1px solid var(--border);
}
.docs-content hr {
border: none;
border-top: 1px solid var(--border);
margin: 2.5rem 0;
}
</style>
+1 -1
View File
@@ -7,7 +7,7 @@ const {
content: {
title = 'Use Case',
description = 'Explore how Gitea Mirror helps engineering teams stay resilient.',
canonical = 'https://gitea-mirror.com/use-cases',
canonical = 'https://gitea-mirror.raylabs.io/use-cases',
}
} = Astro.props;
---
+1 -1
View File
@@ -7,7 +7,7 @@ const {
content: {
title = 'Use Case',
description = 'Explore how Gitea Mirror helps engineering teams stay resilient.',
canonical = 'https://gitea-mirror.com/use-cases',
canonical = 'https://gitea-mirror.raylabs.io/use-cases',
}
} = Astro.props;
---
+42
View File
@@ -0,0 +1,42 @@
export interface DocsNavItem {
title: string;
href: string;
}
export interface DocsNavSection {
title: string;
items: DocsNavItem[];
}
export const docsNav: DocsNavSection[] = [
{
title: 'Getting Started',
items: [
{ title: 'Quickstart', href: '/docs/quickstart/' },
{ title: 'Deployment', href: '/docs/deployment/' },
],
},
{
title: 'Configuration',
items: [
{ title: 'Configuration Guide', href: '/docs/configuration/' },
{ title: 'Environment Variables', href: '/docs/environment-variables/' },
],
},
{
title: 'Features',
items: [
{ title: 'Notifications', href: '/docs/notifications/' },
{ title: 'Authentication & SSO', href: '/docs/authentication/' },
{ title: 'Force-Push Protection', href: '/docs/force-push-protection/' },
],
},
{
title: 'Reference',
items: [
{ title: 'Architecture', href: '/docs/architecture/' },
{ title: 'Advanced', href: '/docs/advanced/' },
{ title: 'Custom CA Certificates', href: '/docs/ca-certificates/' },
],
},
];
@@ -2,7 +2,7 @@
layout: ../../layouts/UseCaseLayout.astro
title: "GitHub Backup Tools Compared: Self-Hosted vs Cloud Solutions"
description: "Compare Gitea Mirror with BackHub, Rewind, GitHub Enterprise Backup, and manual scripts. Choose the best GitHub backup solution for your needs."
canonical: "https://gitea-mirror.com/comparison/github-backup-tools/"
canonical: "https://gitea-mirror.raylabs.io/comparison/github-backup-tools/"
---
# GitHub Backup Tools Compared: Finding the Right Solution
@@ -11,7 +11,7 @@ canonical: "https://gitea-mirror.com/comparison/github-backup-tools/"
GitHub hosts millions of repositories, but relying on a single platform comes with risks:
- **Outages**: GitHub experiences downtime (most recent: November 2024, October 2024, August 2024)
- **Outages**: GitHub experiences downtime several times a year (check [githubstatus.com](https://www.githubstatus.com/history) for the running history)
- **Account issues**: DMCA takedowns, TOS violations, or account suspensions can lock you out
- **Accidental deletions**: One wrong click and your repo history vanishes
- **Company changes**: Microsoft's acquisition led to policy shifts many developers disagreed with
@@ -64,6 +64,8 @@ The question isn't *if* you need backups—it's *which solution* fits your workf
**Setup**:
```bash
git clone https://github.com/RayLabsHQ/gitea-mirror.git
cd gitea-mirror
docker compose -f docker-compose.alt.yml up -d
# Visit http://localhost:4321, create account, add tokens
```
@@ -72,7 +74,7 @@ docker compose -f docker-compose.alt.yml up -d
**Best for**: Teams wanting zero infrastructure management, compliance-focused organizations
**How it works**: SaaS platform that backs up GitHub orgs/repos to their cloud storage. Offers point-in-time recovery and compliance features.
**How it works**: SaaS platform that backs up GitHub orgs/repos to their cloud storage. Offers point-in-time recovery and compliance features. Note: BackHub was acquired by Rewind and now operates as part of Rewind's backup lineup.
**Pros**:
- ✅ Fully managed (zero infrastructure)
@@ -237,7 +239,7 @@ Yes! The Docker setup takes 15 minutes. Test with a few repos, evaluate, then sc
### Does Gitea Mirror work with GitHub Enterprise Cloud?
Yes, as long as you have API access and a personal access token. The GitHub API endpoint is configurable.
Yes. GitHub Enterprise Cloud uses the standard github.com API, so a personal access token is all you need. For GitHub Enterprise Server, point the mirror at your instance's API with the `GH_API_URL` environment variable.
## Next steps
+212
View File
@@ -0,0 +1,212 @@
---
layout: '../../layouts/DocsLayout.astro'
title: Advanced Topics
description: Health checks, database management, crash recovery, graceful shutdown, and how the scheduler actually behaves.
---
# Advanced Topics
Operational detail for people running Gitea Mirror seriously: monitoring it, maintaining its database, and understanding what happens when it restarts mid-job.
## Health endpoint
`GET /api/health` returns a JSON summary of application health. It requires no authentication, which is what lets container orchestrators and uptime monitors poll it.
```bash
curl http://localhost:4321/api/health
```
A healthy instance responds with HTTP 200 and a body like this:
```json
{
"status": "ok",
"timestamp": "2026-08-03T12:00:00.000Z",
"version": "3.24.0",
"latestVersion": "3.24.0",
"updateAvailable": false,
"database": {
"connected": true
},
"recovery": {
"status": "healthy",
"jobsNeedingRecovery": 0
}
}
```
### Fields
`status` is the overall verdict and is one of three values. `ok` means everything is fine. `degraded` means the database is reachable but there are interrupted jobs waiting to be recovered and no recovery pass is currently running. `error` means the database connectivity check failed.
`timestamp` is when the check ran, in ISO 8601.
`version` is the running version, read from `npm_package_version`. It is the string `unknown` if that variable is not set, which happens when the process is started without the Docker entrypoint, since the entrypoint is what extracts it from `package.json`.
`latestVersion` is the newest published release, fetched from the GitHub releases API and cached in memory for one hour. It is `unknown` if the lookup fails, for example on an instance with no outbound internet access.
`updateAvailable` is `true` only when both versions are known and the running one compares as older.
`database.connected` is the result of a trivial query against `sqlite_master`.
`recovery.status` is `healthy` when nothing needs recovering, `jobs-pending` when interrupted jobs were found, or `error` if the check itself failed. `recovery.jobsNeedingRecovery` is a simplified flag rather than a true count: it is `1` when any job needs recovery, `0` when none do, and `-1` when the check errored.
If the handler throws, the endpoint returns HTTP 503 with a sanitized error body instead of the structure above.
> **Note:** The response deliberately contains no memory, CPU, uptime, or operating system details. The endpoint is unauthenticated, and exposing host telemetry on an unauthenticated route is an information disclosure risk. If you need those metrics, collect them from the container runtime rather than from the application.
### Container health checks
Both the `Dockerfile` and the compose files already wire this up, so `docker ps` reports health without any extra configuration. The check accounts for `BASE_URL`, polling `http://localhost:4321${BASE_URL}/api/health`. The image-level check runs every 30 seconds with a 5 second timeout, a 5 second start period, and 3 retries.
For Kubernetes, point both a readiness and a liveness probe at the same path:
```yaml
readinessProbe:
httpGet:
path: /api/health
port: 4321
initialDelaySeconds: 15
periodSeconds: 30
livenessProbe:
httpGet:
path: /api/health
port: 4321
initialDelaySeconds: 30
periodSeconds: 30
```
> **Note:** A `degraded` status still returns HTTP 200, so a liveness probe will not restart the pod over pending job recovery, which is the behavior you want since recovery resolves itself. Alert on the `status` field if you want visibility into `degraded`.
## Database management
Gitea Mirror uses SQLite through Drizzle ORM. Migrations live in the `drizzle/` directory and run automatically on startup, so a normal upgrade requires nothing from you.
### Schema and migrations
```bash
# Generate a migration from changes to src/lib/db/schema.ts
bun run db:generate
# Apply pending migrations
bun run db:migrate
# Push schema changes directly, skipping migration files (development only)
bun run db:push
# Verify schema consistency
bun run db:check
# Pull the schema from an existing database
bun run db:pull
# Open Drizzle Studio, a browser-based database explorer
bun run db:studio
```
> **Warning:** `db:push` writes schema changes without producing a migration file. It is convenient during development and wrong for anything shared, because the next person to run `db:migrate` will not get your change. Use `db:generate` followed by `db:migrate` for anything that ships.
### Maintenance commands
The `manage-db` script handles operational tasks:
```bash
bun run manage-db init # Initialize a fresh database
bun run manage-db check # Report on database health
bun run manage-db fix # Repair common integrity problems
bun run manage-db cleanup # Remove old events and stale records
bun run manage-db auto # Initialize if missing, otherwise check
bun run manage-db reset-users # Delete all user accounts
bun run manage-db reset-password # Reset a user's password
```
Several of these have shorthand aliases in `package.json`: `bun run init-db`, `bun run check-db`, `bun run fix-db`, `bun run reset-users`, and `bun run reset-password`.
To delete the database file entirely and start over:
```bash
bun run cleanup-db
```
> **Warning:** `cleanup-db` removes `data/gitea-mirror.db` outright, and `manage-db reset-users` deletes every account including the admin. Neither asks for confirmation. Back up the `data` directory first.
### Backups
The entire application state is one SQLite file plus the two generated secret files, all inside `data/`. Backing up that directory backs up everything: configuration, repository records, job history, and the keys needed to decrypt stored tokens.
Stop the container before copying the file, or use SQLite's own backup mechanism, so you do not capture a partially written page.
## Crash recovery
Mirroring a large repository with metadata can take a long time, and a restart in the middle of it would otherwise leave jobs stranded in a `mirroring` state forever. Gitea Mirror handles this in two places.
On startup, a recovery pass finds jobs that were interrupted and resumes or fails them cleanly. In Docker this runs automatically from the entrypoint with a 30 second timeout, before the server starts accepting requests. You can run it manually:
```bash
bun run startup-recovery # Normal recovery pass
bun run startup-recovery-force # Force recovery of all interrupted jobs
```
The entrypoint also runs a repository status repair pass, which reconciles repositories whose recorded state disagrees with what actually exists in Gitea.
To exercise the recovery path without waiting for a real crash:
```bash
bun run test-recovery
bun run test-recovery-cleanup # Remove the test artifacts afterwards
```
## Graceful shutdown
Gitea Mirror installs handlers for `SIGTERM`, `SIGINT`, and `SIGHUP`, plus uncaught exceptions and unhandled promise rejections. When any of these fires, a shutdown manager coordinates termination: it tracks running jobs, persists their progress to the database, runs cleanup callbacks that services have registered, and only then exits.
Two timeouts bound the process: 30 seconds for the shutdown overall, and 10 seconds per job to save its state. These prevent a hung job from blocking termination indefinitely.
The Docker entrypoint traps signals and forwards them to the application process rather than letting the shell absorb them, so `docker stop` and Kubernetes pod termination both produce a clean shutdown instead of a kill.
Jobs interrupted this way are marked as interrupted rather than failed, which is what the startup recovery pass looks for on the next boot.
To test it:
```bash
bun run test-shutdown
bun run test-shutdown-cleanup
```
## Scheduling behavior
The scheduler is more than a timer, and a few of its behaviors are worth knowing.
### Interval formats
Any interval setting accepts a duration string (`30m`, `8h`, `7d`), a five-field cron expression (`0 2 * * *`), or a plain number interpreted as seconds. The parser tries cron first when the value has five whitespace-separated fields, then falls back to duration parsing, then to a bare seconds value.
Recognized duration units are `ms`, `s`, `m`, `h`, `d`, and `w`, in both short and long spellings.
### What a scheduled run does
A run proceeds in stages, each gated by its own setting. First, if auto-import is enabled, it queries GitHub for repositories that are not yet tracked and adds them. Then, if auto-mirror is enabled, it mirrors repositories that need mirroring.
Auto-mirroring of starred repositories is governed separately from auto-mirroring of your own repositories, by the `AUTO_MIRROR_STARRED` setting. This exists because starred repositories are often imported purely for browsing, and mirroring hundreds of other people's projects is usually not what you want by default.
Existing mirrors are re-synced according to the configured interval, and optionally skipped when they were mirrored very recently or have no upstream changes.
### Auto-start
Setting `SCHEDULE_ENABLED=true`, or any of `SCHEDULE_INTERVAL`, `GITEA_MIRROR_INTERVAL`, or `DELAY`, enables the scheduler. When enabled, the first run happens shortly after boot rather than waiting a full interval, so a fresh container populates itself without anyone clicking a button.
### Mirror intervals versus sync intervals
There are two independent intervals and they are easy to confuse.
`GITEA_MIRROR_INTERVAL` is stored on each repository in Gitea and controls how often Gitea itself pulls from GitHub. Gitea's own default is 8 hours, and Gitea Mirror sets this value explicitly rather than leaving it to the server default.
The scheduler interval controls how often Gitea Mirror wakes up to discover new repositories, mirror pending ones, and run cleanup. It does not pull code itself.
You generally want the scheduler interval to be no shorter than the mirror interval, since running it more often will not make Gitea fetch any sooner.
## Rate limits
GitHub allows 5,000 authenticated API requests per hour on a personal access token. Gitea Mirror uses Octokit with the throttling plugin, which backs off automatically when it approaches the limit, and tracks remaining quota so the dashboard can show it.
The batch size and concurrency settings are the main levers if you are hitting limits: lower `SCHEDULE_BATCH_SIZE`, `MIRROR_ISSUE_CONCURRENCY`, and `MIRROR_PULL_REQUEST_CONCURRENCY`, or raise `SCHEDULE_PAUSE_BETWEEN_BATCHES`. Metadata mirroring is by far the most request-hungry operation, since it walks every issue, comment, and label individually.
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more