Compare commits

...

24 Commits

Author SHA1 Message Date
ARUNAVO RAY 4ea62a9f3d chore: bump and digest-pin Bun base image to 1.3.14 (#295)
Same hardening principle as #293 for GitHub Actions: pin to an immutable
identifier so a future tag move can't silently change what we build against.

- Bumps oven/bun from 1.3.13 to 1.3.14 (released 2026-05-13)
- Pins to multi-arch digest sha256:9dba1a1b...db6f rather than just the tag
- Applies to both base and runner stages
2026-05-19 12:50:37 +05:30
ARUNAVO RAY 1f60b2cf39 feat: add Change password and Change email to account dropdown (#292)
Adds an account menu under the avatar in the header with Change password and
Change email actions, each opening a small dialog. Calls Better Auth's existing
change-password / change-email endpoints — no new API routes.

- Enables `user.changeEmail` in Better Auth with `updateEmailWithoutVerification`
  since the app runs with email verification disabled and no email sender is
  wired up
- Hides Change password for SSO-only users (no `credential` provider account),
  fails open if the listAccounts probe errors
- Resolves discussion #291 ("Change user password/email")
2026-05-19 12:45:04 +05:30
ARUNAVO RAY a02865a1aa ci: pin third-party GitHub Actions to commit SHAs (#293)
Tags are mutable. A compromised maintainer (or a maintainer's compromised
machine) can force-move v-tags to point at malicious commits, and any workflow
using `@vN` picks up the malicious code on its next run — see the recent
`actions-cool/issues-helper` / `maintain-one-comment` incident exfiltrating
credentials from `Runner.Worker` memory.

This commit pins every third-party action in the two workflows that handle
secrets (GHCR push, Docker Hub login, Scout token) to immutable 40-char SHAs,
with a trailing comment naming the release version for readability. SHAs are
the latest released tag at time of pin.

The two DeterminateSystems actions were on `@main` — a *branch* ref that moves
on every push, materially worse than a tag — and are now pinned to the latest
release SHAs (v22 / v13).

First-party `actions/*` and `github/codeql-action` are left on tags for now;
they're a separate, lower-risk follow-up.
2026-05-19 12:44:23 +05:30
github-actions[bot] ad549dad9b chore: sync version to 3.15.12 2026-05-16 06:50:50 +00:00
ARUNAVO RAY 20103220d9 chore: prune npm overrides that are no longer load-bearing (#290)
Removed 5 overrides whose constraints have since been picked up naturally
by the transitive dep graph. Verified by removing each and confirming the
resolved version (and the dep tree) is identical to what the override
produced:

- defu ^6.1.7 → still resolves 6.1.7
- fast-xml-parser ^5.5.6 → still resolves 5.5.6
- node-forge ^1.3.3 → package not in tree at all (override was dead)
- rollup >=4.59.0 → still resolves 4.59.0
- svgo ^4.0.1 → still resolves 4.0.1

Kept overrides that are still doing real work:

- @esbuild-kit/esm-loader → npm:tsx@^4.21.0 — deliberate replacement shim
- @xmldom/xmldom ^0.8.13, devalue ^5.8.1, fast-uri ^3.1.2,
  fast-xml-builder ^1.1.7, kysely ^0.28.17 — active CVE pins (#289)
- lodash ^4.18.1 — pins to the newer 4.18.x line over the legacy 4.17.x
  that transitive deps still pull
- picomatch ^4.0.4 — without it, picomatch@2.3.2 is added as a duplicate
  copy via a transitive that asks for 2.x

Future drift would be caught by Dependabot + the weekly Docker Scout
scan; the overrides above remain because they currently affect the tree.
2026-05-16 12:03:53 +05:30
ARUNAVO RAY fe2c825244 chore: bump npm overrides to patch HIGH-severity CVEs (#289)
Patches 9 Docker Scout HIGH alerts surfaced by the weekly image scan:

- @xmldom/xmldom 0.8.12 → 0.8.13 (CVE-2026-41672/3/4/5)
- devalue 5.6.4 → 5.8.1 (CVE-2026-42570)
- kysely 0.28.16 → 0.28.17 (CVE-2026-44635)
- fast-uri (new override) → ^3.1.2 (CVE-2026-6321, CVE-2026-6322)
- fast-xml-builder (new override) → ^1.1.7 (CVE-2026-44665)

All five resolve to fixed versions after `bun install`. Tests and astro
build pass locally.

Remaining open Docker Scout alerts (git-lfs Go stdlib, gnutls28, nghttp2)
are base-image or upstream-binary issues, not addressable via npm.
2026-05-16 11:42:53 +05:30
github-actions[bot] 4b858a0251 chore: sync version to 3.15.11 2026-05-16 04:28:38 +00:00
Eduardo Riguetto (Kralot) 585d2ceb84 fix: reconcile metadata on every sync instead of once per repo (#287)
Previously, mirror of issues/pull-requests/labels/milestones was
guarded by !metadataState.components.<component>, so once a repo had
been mirrored the metadata path was permanently skipped with logs
like "Issues already mirrored; skipping to avoid duplicates".

This meant title changes, new comments, label updates and milestone
edits on the source GitHub repo were never propagated, even when the
user explicitly clicked Sync.

The underlying mirror* functions already handle idempotent updates:
issues and PRs are matched by [GH-ISSUE #N] / [GH-PR #N] markers in
the title and PATCHed in place, labels are deduped by name, milestones
by title. The releases path already runs unconditionally for the same
reason ("always allowed to rerun for updates"); aligning the other
metadata paths with it.

Touched: mirrorGithubRepoToGitea, mirrorGitHubRepoToGiteaOrg, and the
syncGiteaRepoEnhanced path in gitea-enhanced.ts. Updated the
"already-synced-repo" test to assert reconciliation runs on resync.
2026-05-16 09:40:48 +05:30
Eduardo Riguetto (Kralot) 7c1f24dc2f fix: include organization_member in /user/repos affiliation (#286)
Affiliation was set to "owner,collaborator", omitting repos owned
by orgs the user belongs to. As a result:

- main sync, scheduler, and cleanup never saw org repos
- orgs appeared empty unless manually re-added via /api/sync/organization
- restart archived previously-mirrored org repos as orphans

GitHub's API default is owner,collaborator,organization_member;
restoring it fixes both symptoms with no other code changes.
2026-05-16 09:40:40 +05:30
github-actions[bot] 680b374c84 chore: sync version to 3.15.10 2026-05-04 08:37:38 +00:00
ARUNAVO RAY 088467a57d feat: add option to exclude collaborator repos from import (closes #279) (#283)
GitHub's listForAuthenticatedUser defaults to returning every repo the
user has access to (owner + collaborator + organization_member), which
imports a lot of noise for users who only want their own repos.

Adds an `includeCollaboratorRepos` toggle, defaulting to true to preserve
existing behavior. When disabled, the affiliation filter scopes the API
call to "owner" only.

The cleanup service overrides the filter to always include collaborator
repos when computing the "what's still on GitHub" list. Without this,
toggling the option off would mark previously-mirrored collab repos as
orphaned and archive/delete them from Gitea.

Wired through the schema, both UI<->DB mappers, the env-config loader
(with new INCLUDE_COLLABORATOR_REPOS env var), and the settings UI.
2026-05-04 14:00:10 +05:30
github-actions[bot] adb436444e chore: sync version to 3.15.9 2026-05-04 04:50:49 +00:00
ARUNAVO RAY cc635485f0 feat: surface auto-mirror toggle in automation settings (refs #278) (#282)
The fix in v3.15.8 made scheduleConfig.autoMirror an independent trigger
in the scheduler, but it remained reachable only via the AUTO_MIRROR_REPOS
env var. This adds a UI checkbox under the Automatic Syncing section so
the option can be toggled per-config without touching the environment.

The toggle is conditional on scheduling being enabled (since auto-mirror
without a scheduler is meaningless) and is independent of the existing
"Auto-mirror new starred repositories" toggle in GitHub settings. Together
they cover the full owned/starred matrix that the scheduler already
supports.

Plumbing: config-mapper.ts now round-trips autoMirror through the UI/DB
boundary, and ScheduleConfig in types/config.ts gets the matching field.
No schema or migration change — autoMirror was already in the zod schema.
2026-05-04 10:14:09 +05:30
github-actions[bot] 6f343de5fd chore: sync version to 3.15.8 2026-05-04 03:49:44 +00:00
ARUNAVO RAY a18f262ca7 fix: make autoMirrorStarred actually trigger auto-mirror (fixes #278) (#281)
The "Auto-mirror new starred repositories" checkbox in the GitHub settings
was a filter layered on top of scheduleConfig.autoMirror, which itself is
only settable via the AUTO_MIRROR_REPOS env var (no UI). So users who
checked the box saw their starred repos auto-imported but never mirrored.

Treat autoMirror and autoMirrorStarred as independent triggers in the
scheduler: autoMirror covers owned (and self-starred) repos, autoMirrorStarred
covers repos starred from other owners. Either flag on its own is enough
to enter the auto-mirror phase, and the filter scopes the work accordingly.

Also normalize the owner comparison to lowercase since GitHub usernames are
case-insensitive — previously a self-starred repo whose stored owner casing
differed from the configured owner would be misclassified as a third-party
star.

Behavior change worth flagging in release notes: anyone who currently has
the starred checkbox on (broken state) will start getting starred repos
mirrored on upgrade. AUTO_MIRROR_REPOS=true users see no change.
2026-05-04 09:12:57 +05:30
Arunavo Ray 3798456f5d chore: bump version to 3.15.7 2026-05-04 08:20:26 +05:30
ARUNAVO RAY 73f1609117 fix: unstick repos in 'mirroring' on transient errors (fixes #268) (#280)
* fix: hoist migrateSucceeded above try so catch can update DB on failure (fixes #268)

`let migrateSucceeded` was declared inside the try block of
mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg, but the catch
block referenced it. Block-scoping made it invisible to catch, so any
error inside the try (network timeout, transient 5xx, etc.) crashed the
catch with `ReferenceError: migrateSucceeded is not defined` before
reaching the DB update that marks the repo "failed". Result: repos
stuck in "mirroring" forever with no entry in the activity log.

Hoisting the declaration above the try restores the intended behavior:
catch updates the repo to failed, clears mirroredLocation when migrate
hadn't succeeded, writes a failed activity-log entry, and re-throws
with the original error message preserved.

TypeScript was flagging this as "Cannot find name 'migrateSucceeded'"
but esbuild stripped the types during build, so the bug shipped.

* test: replace integration test with structural source check (#268)

The behavioral version of this regression test passed locally but
failed in CI because of mock.module pollution between files: bun's
mock.module is process-wide, so my mock for @/lib/gitea-enhanced
leaked into gitea-enhanced.test.ts (its real-module assertions saw
my null-returning mocks), and gitea-enhanced.test.ts's own
@/lib/http-client mock could supersede mine depending on file
discovery order, causing my mirrorGithubRepoToGitea call to not
throw at all in CI.

Replace with a structural assertion that reads gitea.ts and verifies
`let migrateSucceeded` is declared before the outermost try in both
mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg. Verified the
new test fails on the pre-fix source with a clear error message
pointing to issue #268, and passes on the fixed source.
2026-05-04 08:08:22 +05:30
Arunavo Ray 588567931a chore: bump version to 3.15.6 2026-04-26 13:43:45 +05:30
Arunavo Ray 5c1317c759 feat: warn when Forgejo destination has known mirror-credential bug (refs #263)
Forgejo < 15.0.0 silently discards auth_username/auth_password sent to
/api/v1/repos/migrate, causing subsequent pull-mirror sync of private repos
to fail with `terminal prompts disabled`. Fix landed upstream in Forgejo
v15.0.0 via codeberg.org/forgejo/forgejo/pulls/11909 and was not backported
to v12/v13/v14.

Test-connection endpoint now also probes /api/v1/version, detects Forgejo
via the `+gitea-` suffix, and surfaces a warning Alert in the Gitea config
form when the connected server reports a major version below 15.
2026-04-26 13:43:40 +05:30
Arunavo Ray 5f1c37b320 fix: don't gate dashboard on optional username fields (refs #271, v3.15.5)
The useConfigStatus hook treated `githubConfig.username` and
`giteaConfig.username` as required for the dashboard to render. In
practice neither is required at runtime — the GitHub token is
self-authenticating via listForAuthenticatedUser, and a Gitea username
isn't needed under single-org or flat mirror strategies.

Users who configured via env vars without GITHUB_USERNAME / GITEA_USERNAME
set (or who left those blank in the form, which is only client-side
`required`) ended up with empty strings in their config row. Mirroring
ran fine — tokens alone are sufficient — but the dashboard refused to
fetch and rendered all zeros because useConfigStatus failed the gate.

Drop the username checks from the gate. The `githubOwner` field is still
exported for consumers that want to display an owner; only the gate is
relaxed. Cache-hit and fresh-fetch branches both updated.
2026-04-22 19:21:13 +05:30
Arunavo Ray 083b342f38 ci: bump bun 1.3.10/1.3.12 → 1.3.13 across CI and runtime
CI was on 1.3.10 while the Dockerfile runtime moved to 1.3.12 in v3.15.2,
so we were testing against an older runtime than we shipped. Align both
on 1.3.13 (latest stable). May also resolve the intermittent --coverage
instrumentation flake observed on 1.3.10 against http-client.ts.
2026-04-22 08:39:37 +05:30
Arunavo Ray 92bb38b122 chore: bump version to 3.15.4 2026-04-22 08:11:17 +05:30
dependabot[bot] e7ac54a72a build(deps): bump astro (#274)
Bumps the npm_and_yarn group with 1 update in the /www directory: [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro).


Updates `astro` from 6.0.4 to 6.1.6
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@6.1.6/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 6.1.6
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 08:10:43 +05:30
Arunavo Ray 2ea250f081 fix: prefer active config when reading user settings (fixes #271)
Multiple "select from configs where userId" queries had no ORDER BY,
so when a user's database accidentally contained more than one config
row for the same user (e.g. from an env-loader insert path or a partial
default-config create), SQLite returned a non-deterministic row.

In the reported case this caused /api/config to hand back an empty stub
while /api/dashboard's repo/org counts came from the populated active
row. The dashboard's useConfigStatus hook then saw missing username/
token, treated config as incomplete, and never fetched dashboard data —
the UI rendered with all zeros even though 868 repos were sitting in
the database, mirroring fine in the background.

Add `ORDER BY isActive DESC, updatedAt DESC` before LIMIT 1 to every
"fetch the user's config" query so the active and most-recently-updated
row consistently wins. Also order env-config-loader's first-user pick
by createdAt for deterministic behavior across restarts.

Already-safe call sites that explicitly filter on isActive=true or
iterate all active configs (cleanup/scheduler/repositories/orgs/cleanup
trigger/sync-organization) are left unchanged.

Updates the mirror-repo test mock to match the new orderBy().limit()
chain.

Closes #271
2026-04-22 08:01:22 +05:30
52 changed files with 1592 additions and 659 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: '1.3.10'
bun-version: '1.3.13'
- name: Check lockfile and install dependencies
run: |
+9 -9
View File
@@ -51,13 +51,13 @@ jobs:
ref: ${{ env.SHA }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
driver-opts: network=host
- name: Log into registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -66,7 +66,7 @@ jobs:
# Login to Docker Hub for Docker Scout (optional - provides better vulnerability data)
# Add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets to enable this
- name: Log into Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
continue-on-error: true
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -108,7 +108,7 @@ jobs:
# Extract metadata for Docker
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
labels: |
@@ -124,7 +124,7 @@ jobs:
# Build and push Docker image
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -139,7 +139,7 @@ jobs:
# Load image locally for security scanning (PRs only)
- name: Load image for scanning
if: github.event_name == 'pull_request'
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
platforms: linux/amd64
@@ -212,7 +212,7 @@ jobs:
# Docker Scout comprehensive security analysis
- name: Docker Scout - Vulnerability Analysis & Recommendations
uses: docker/scout-action@v1
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
if: github.event_name != 'pull_request'
with:
command: cves,recommendations
@@ -226,7 +226,7 @@ jobs:
# Docker Scout for Pull Requests (using local image)
- name: Docker Scout - Vulnerability Analysis (PR)
uses: docker/scout-action@v1
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
if: github.event_name == 'pull_request'
with:
command: cves,recommendations
@@ -240,7 +240,7 @@ jobs:
# Compare to latest for PRs and pushes
- name: Docker Scout - Compare to Latest
uses: docker/scout-action@v1
uses: docker/scout-action@bacf462e8d090c09660de30a6ccc718035f961e3 # v1.20.4
if: github.event_name == 'pull_request'
with:
command: compare
+1 -1
View File
@@ -40,7 +40,7 @@ env:
FAKE_GITHUB_PORT: 4580
GIT_SERVER_PORT: 4590
APP_PORT: 4321
BUN_VERSION: "1.3.10"
BUN_VERSION: "1.3.13"
jobs:
e2e-tests:
+2 -2
View File
@@ -38,10 +38,10 @@ jobs:
- uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
- name: Setup Nix Cache
uses: DeterminateSystems/magic-nix-cache-action@main
uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
- name: Regenerate bun.nix from bun.lock
run: nix run --accept-flake-config github:nix-community/bun2nix -- -o bun.nix
+2 -2
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.4
FROM oven/bun:1.3.12-debian AS base
FROM oven/bun:1.3.14-debian@sha256:9dba1a1b43ce28c9d7931bfc4eb00feb63b0114720a0277a8f939ae4dfc9db6f AS base
WORKDIR /app
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
python3 make g++ gcc wget sqlite3 openssl ca-certificates \
@@ -49,7 +49,7 @@ RUN git clone --branch "v${GIT_LFS_VERSION}" --depth 1 https://github.com/git-lf
&& install -m 755 /tmp/git-lfs/bin/git-lfs /usr/local/bin/git-lfs
# ----------------------------
FROM oven/bun:1.3.12-debian AS runner
FROM oven/bun:1.3.14-debian@sha256:9dba1a1b43ce28c9d7931bfc4eb00feb63b0114720a0277a8f939ae4dfc9db6f AS runner
WORKDIR /app
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
git wget sqlite3 openssl ca-certificates \
+14 -13
View File
@@ -82,16 +82,13 @@
},
"overrides": {
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
"@xmldom/xmldom": "^0.8.12",
"defu": "^6.1.7",
"devalue": "^5.6.4",
"fast-xml-parser": "^5.5.6",
"kysely": "^0.28.16",
"@xmldom/xmldom": "^0.8.13",
"devalue": "^5.8.1",
"fast-uri": "^3.1.2",
"fast-xml-builder": "^1.1.7",
"kysely": "^0.28.17",
"lodash": "^4.18.1",
"node-forge": "^1.3.3",
"picomatch": "^4.0.4",
"rollup": ">=4.59.0",
"svgo": "^4.0.1",
},
"packages": {
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
@@ -726,7 +723,7 @@
"@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -876,7 +873,7 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="],
"devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
@@ -960,9 +957,9 @@
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
"fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="],
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
"fast-xml-parser": ["fast-xml-parser@5.5.6", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw=="],
@@ -1090,7 +1087,7 @@
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="],
"kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -1662,6 +1659,8 @@
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"xpath": ["xpath@0.0.34", "", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="],
@@ -1760,6 +1759,8 @@
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"fast-xml-builder/path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
"hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+1
View File
@@ -100,6 +100,7 @@ Standard GitHub Enterprise Cloud on `github.com` works with the default — no o
| `PRIVATE_REPOSITORIES` | Include private repositories | `false` | `true`, `false` |
| `PUBLIC_REPOSITORIES` | Include public repositories | `true` | `true`, `false` |
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` |
| `INCLUDE_COLLABORATOR_REPOS` | Include repositories where you are a collaborator (not just owned). Set to `false` to limit imports to repos you own. | `true` | `true`, `false` |
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
| `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` |
| `MIRROR_STARRED_LISTS` | Optional comma-separated GitHub Star List names to mirror (only used when `MIRROR_STARRED=true`) | - | Comma-separated list names (empty = all starred repos) |
+7 -10
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.15.3",
"version": "3.15.12",
"engines": {
"bun": ">=1.2.9"
},
@@ -45,16 +45,13 @@
},
"overrides": {
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0",
"@xmldom/xmldom": "^0.8.12",
"defu": "^6.1.7",
"devalue": "^5.6.4",
"fast-xml-parser": "^5.5.6",
"kysely": "^0.28.16",
"@xmldom/xmldom": "^0.8.13",
"devalue": "^5.8.1",
"fast-uri": "^3.1.2",
"fast-xml-builder": "^1.1.7",
"kysely": "^0.28.17",
"lodash": "^4.18.1",
"node-forge": "^1.3.3",
"picomatch": "^4.0.4",
"rollup": ">=4.59.0",
"svgo": "^4.0.1"
"picomatch": "^4.0.4"
},
"dependencies": {
"@astrojs/check": "^0.9.7",
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { KeyRound, LogOut, Mail } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { authClient } from "@/lib/auth-client";
import { withBase } from "@/lib/base-path";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChangePasswordDialog } from "./ChangePasswordDialog";
import { ChangeEmailDialog } from "./ChangeEmailDialog";
export function AccountMenu() {
const { user, logout, refreshUser } = useAuth();
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
const [passwordOpen, setPasswordOpen] = useState(false);
const [emailOpen, setEmailOpen] = useState(false);
useEffect(() => {
if (!user) {
setHasPassword(null);
return;
}
let cancelled = false;
(async () => {
try {
const accounts = await authClient.listAccounts();
if (cancelled) return;
const list = Array.isArray(accounts) ? accounts : accounts?.data;
setHasPassword(
Array.isArray(list) && list.some((a) => a.providerId === "credential")
);
} catch {
// Fail open: if we can't check, show the option rather than locking the
// user out of changing their password.
if (!cancelled) setHasPassword(true);
}
})();
return () => {
cancelled = true;
};
}, [user?.id]);
if (!user) {
return (
<Button variant="outline" size="sm" asChild>
<a href={withBase("/login")}>Login</a>
</Button>
);
}
const handleLogout = async () => {
toast.success("Logged out successfully");
await new Promise((resolve) => setTimeout(resolve, 500));
logout();
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="lg"
className="relative h-10 w-10 rounded-full p-0"
>
<Avatar className="h-full w-full">
<AvatarImage src={user.image || ""} alt={user.name || user.email} />
<AvatarFallback>
{(user.name || user.email || "U").charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-0.5">
{user.name && (
<span className="text-sm font-medium leading-none">
{user.name}
</span>
)}
<span className="text-xs leading-none text-muted-foreground truncate">
{user.email}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{hasPassword && (
<DropdownMenuItem
onSelect={() => setPasswordOpen(true)}
className="cursor-pointer"
>
<KeyRound className="h-4 w-4 mr-2" />
Change password
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => setEmailOpen(true)}
className="cursor-pointer"
>
<Mail className="h-4 w-4 mr-2" />
Change email
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleLogout} className="cursor-pointer">
<LogOut className="h-4 w-4 mr-2" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{hasPassword && (
<ChangePasswordDialog
open={passwordOpen}
onOpenChange={setPasswordOpen}
/>
)}
<ChangeEmailDialog
open={emailOpen}
onOpenChange={setEmailOpen}
currentEmail={user.email}
onUpdated={refreshUser}
/>
</>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface ChangeEmailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentEmail: string;
onUpdated?: () => void;
}
export function ChangeEmailDialog({
open,
onOpenChange,
currentEmail,
onUpdated,
}: ChangeEmailDialogProps) {
const [newEmail, setNewEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const reset = () => {
setNewEmail("");
setIsSubmitting(false);
};
const handleOpenChange = (next: boolean) => {
if (!next) reset();
onOpenChange(next);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = newEmail.trim();
if (!trimmed) {
toast.error("Please enter a new email");
return;
}
if (trimmed.toLowerCase() === currentEmail.toLowerCase()) {
toast.error("New email must differ from current email");
return;
}
setIsSubmitting(true);
try {
const { error } = await authClient.changeEmail({ newEmail: trimmed });
if (error) {
toast.error(error.message || "Failed to change email");
return;
}
toast.success("Email updated.");
onUpdated?.();
handleOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to change email");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change email</DialogTitle>
<DialogDescription>
Current: <span className="font-medium">{currentEmail}</span>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-email">New email</Label>
<Input
id="new-email"
type="email"
autoComplete="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating..." : "Update email"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,158 @@
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface ChangePasswordDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ChangePasswordDialog({ open, onOpenChange }: ChangePasswordDialogProps) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [revokeOtherSessions, setRevokeOtherSessions] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const reset = () => {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setRevokeOtherSessions(true);
setIsSubmitting(false);
};
const handleOpenChange = (next: boolean) => {
if (!next) reset();
onOpenChange(next);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!currentPassword || !newPassword) {
toast.error("Please fill in all fields");
return;
}
if (newPassword !== confirmPassword) {
toast.error("New passwords do not match");
return;
}
if (newPassword === currentPassword) {
toast.error("New password must differ from current password");
return;
}
setIsSubmitting(true);
try {
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions,
});
if (error) {
toast.error(error.message || "Failed to change password");
return;
}
toast.success(
revokeOtherSessions
? "Password updated. Other sessions signed out."
: "Password updated."
);
handleOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to change password");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change password</DialogTitle>
<DialogDescription>
Enter your current password and a new one. You'll stay signed in on this device.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current password</Label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={isSubmitting}
required
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm new password</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isSubmitting}
required
minLength={8}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="revoke-sessions"
checked={revokeOtherSessions}
onCheckedChange={(checked) => setRevokeOtherSessions(checked === true)}
disabled={isSubmitting}
/>
<Label htmlFor="revoke-sessions" className="text-sm font-normal cursor-pointer">
Sign out other devices
</Label>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Updating..." : "Update password"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -269,6 +269,31 @@ export function AutomationSettings({
</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>
</div>
</div>
</div>
)}
@@ -35,6 +35,7 @@ import {
HardDrive,
FileCode2,
Plus,
Users,
X
} from "lucide-react";
import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config";
@@ -244,6 +245,26 @@ export function GitHubMirrorSettings({
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="collaborator-repos"
checked={githubConfig.includeCollaboratorRepos ?? true}
onCheckedChange={(checked) => handleGitHubChange('includeCollaboratorRepos', !!checked)}
/>
<div className="space-y-0.5 flex-1">
<Label
htmlFor="collaborator-repos"
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<Users className="h-3.5 w-3.5" />
Include collaborator repositories
</Label>
<p className="text-xs text-muted-foreground">
Also mirror repos where you're a collaborator but not the owner. Turn off to limit imports to repos you own.
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-start space-x-3">
<Checkbox
+32 -1
View File
@@ -6,7 +6,9 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { giteaApi } from "@/lib/api";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertTriangle } 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";
@@ -23,6 +25,7 @@ interface GiteaConfigFormProps {
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, githubUsername }: 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 => {
@@ -128,13 +131,16 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
try {
const result = await giteaApi.testConnection(config.url, config.token);
if (result.success) {
setServerInfo(result.serverInfo ?? null);
toast.success("Successfully connected to Gitea!");
} else {
setServerInfo(null);
toast.error(
"Failed to connect to Gitea. Please check your URL and token."
);
}
} catch (error) {
setServerInfo(null);
toast.error(
error instanceof Error ? error.message : "An unknown error occurred"
);
@@ -162,6 +168,31 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
</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"
+4 -43
View File
@@ -2,18 +2,11 @@ import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { ModeToggle } from "@/components/theme/ModeToggle";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { Menu, LogOut, PanelRightOpen, PanelRightClose } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { PanelRightOpen, PanelRightClose } from "lucide-react";
import { AccountMenu } from "@/components/auth/AccountMenu";
import { withBase } from "@/lib/base-path";
interface HeaderProps {
@@ -26,7 +19,7 @@ interface HeaderProps {
}
export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse, isSidebarCollapsed, isSidebarOpen }: HeaderProps) {
const { user, logout, isLoading } = useAuth();
const { isLoading } = useAuth();
const { isLiveEnabled, toggleLive } = useLiveRefresh();
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
@@ -47,13 +40,6 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
return isLiveEnabled ? 'Disable live refresh' : 'Enable live refresh';
};
const handleLogout = async () => {
toast.success("Logged out successfully");
// Small delay to show the toast before redirecting
await new Promise((resolve) => setTimeout(resolve, 500));
logout();
};
// Auth buttons skeleton loader
function AuthButtonsSkeleton() {
return (
@@ -141,32 +127,7 @@ export function Header({ currentPage, onNavigate, onMenuClick, onToggleCollapse,
<ModeToggle />
{isLoading ? (
<AuthButtonsSkeleton />
) : user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg" className="relative h-10 w-10 rounded-full p-0">
<Avatar className="h-full w-full">
<AvatarImage src={user.image || ""} alt={user.name || user.email} />
<AvatarFallback>
{(user.name || user.email || "U").charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={handleLogout} className="cursor-pointer">
<LogOut className="h-4 w-4 mr-2" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button variant="outline" size="sm" asChild>
<a href={withBase('/login')}>Login</a>
</Button>
)}
{isLoading ? <AuthButtonsSkeleton /> : <AccountMenu />}
</div>
</div>
</header>
+14 -10
View File
@@ -65,14 +65,16 @@ export function useConfigStatus(): ConfigStatus {
if (isCacheValid && hasCheckedRef.current) {
const configResponse = configCache.data!;
const isGitHubConfigured = !!(
configResponse?.githubConfig?.username &&
configResponse?.githubConfig?.token
);
// Only token/url are actually required at runtime: the GitHub token is
// self-authenticating for listForAuthenticatedUser, and a Gitea username
// isn't needed under single-org / flat mirror strategies. Users who
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
// (or who otherwise left those blank) were being locked out of the
// dashboard even though mirroring worked fine (see issue #271).
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
const isGiteaConfigured = !!(
configResponse?.giteaConfig?.url &&
configResponse?.giteaConfig?.username &&
configResponse?.giteaConfig?.token
);
@@ -108,14 +110,16 @@ export function useConfigStatus(): ConfigStatus {
userId: user.id
};
const isGitHubConfigured = !!(
configResponse?.githubConfig?.username &&
configResponse?.githubConfig?.token
);
// Only token/url are actually required at runtime: the GitHub token is
// self-authenticating for listForAuthenticatedUser, and a Gitea username
// isn't needed under single-org / flat mirror strategies. Users who
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
// (or who otherwise left those blank) were being locked out of the
// dashboard even though mirroring worked fine (see issue #271).
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
const isGiteaConfigured = !!(
configResponse?.giteaConfig?.url &&
configResponse?.giteaConfig?.username &&
configResponse?.giteaConfig?.token
);
+14 -4
View File
@@ -87,12 +87,22 @@ export const githubApi = {
};
// Gitea API
export interface GiteaServerInfo {
type: "forgejo" | "gitea";
version: string;
raw: string;
hasMirrorCredBug: boolean;
}
export const giteaApi = {
testConnection: (url: string, token: string) =>
apiRequest<{ success: boolean }>("/gitea/test-connection", {
method: "POST",
body: JSON.stringify({ url, token }),
}),
apiRequest<{ success: boolean; serverInfo?: GiteaServerInfo; message?: string }>(
"/gitea/test-connection",
{
method: "POST",
body: JSON.stringify({ url, token }),
}
),
};
// Health API
+6
View File
@@ -145,6 +145,12 @@ export const auth = betterAuth({
input: false, // Don't show in signup form - we'll derive from email
}
},
changeEmail: {
enabled: true,
// Email verification isn't wired up (sendResetPassword is a TODO),
// so allow direct updates. Safe here because emails stay unverified.
updateEmailWithoutVerification: true,
},
},
// Plugins configuration
+1
View File
@@ -23,6 +23,7 @@ export const githubConfigSchema = z.object({
includeArchived: z.boolean().default(false),
includePrivate: z.boolean().default(true),
includePublic: z.boolean().default(true),
includeCollaboratorRepos: z.boolean().default(true),
includeOrganizations: z.array(z.string()).default([]),
starredReposOrg: z.string().optional(),
starredReposMode: z.enum(["dedicated-org", "preserve-owner"]).default("dedicated-org"),
+15 -3
View File
@@ -4,7 +4,7 @@
*/
import { db, configs, users } from '@/lib/db';
import { eq, and } from 'drizzle-orm';
import { eq, and, sql } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { encrypt } from '@/lib/utils/encryption';
@@ -15,6 +15,7 @@ interface EnvConfig {
type?: 'personal' | 'organization';
privateRepositories?: boolean;
publicRepositories?: boolean;
includeCollaboratorRepos?: boolean;
mirrorStarred?: boolean;
skipForks?: boolean;
includeArchived?: boolean;
@@ -111,6 +112,11 @@ function parseEnvConfig(): EnvConfig {
type: process.env.GITHUB_TYPE as 'personal' | 'organization',
privateRepositories: process.env.PRIVATE_REPOSITORIES === 'true',
publicRepositories: process.env.PUBLIC_REPOSITORIES === 'true',
// Tri-state parse so unset falls through to existingConfig / schema default (true).
includeCollaboratorRepos:
process.env.INCLUDE_COLLABORATOR_REPOS === 'true' ? true
: process.env.INCLUDE_COLLABORATOR_REPOS === 'false' ? false
: undefined,
mirrorStarred: process.env.MIRROR_STARRED === 'true',
skipForks: process.env.SKIP_FORKS === 'true',
includeArchived: process.env.INCLUDE_ARCHIVED === 'true',
@@ -224,10 +230,12 @@ export async function initializeConfigFromEnv(): Promise<void> {
console.log('[ENV Config Loader] Found environment configuration, initializing...');
// Get the first user (admin user)
// Get the first user (admin user) — deterministic order so we always pick the
// same row across restarts even if multiple users exist.
const firstUser = await db
.select()
.from(users)
.orderBy(sql`${users.createdAt} ASC`)
.limit(1);
if (firstUser.length === 0) {
@@ -237,11 +245,14 @@ export async function initializeConfigFromEnv(): Promise<void> {
const userId = firstUser[0].id;
// Check if config already exists for this user
// Check if config already exists for this user — prefer the active config and
// fall back to most-recently-updated so we never write env values into a stale
// inactive stub while the populated active row sits untouched (see issue #271).
const existingConfig = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
// Determine mirror strategy based on environment variables or use explicit value
@@ -265,6 +276,7 @@ export async function initializeConfigFromEnv(): Promise<void> {
includeArchived: envConfig.github.includeArchived ?? existingConfig?.[0]?.githubConfig?.includeArchived ?? false,
includePrivate: envConfig.github.privateRepositories ?? existingConfig?.[0]?.githubConfig?.includePrivate ?? false,
includePublic: envConfig.github.publicRepositories ?? existingConfig?.[0]?.githubConfig?.includePublic ?? true,
includeCollaboratorRepos: envConfig.github.includeCollaboratorRepos ?? existingConfig?.[0]?.githubConfig?.includeCollaboratorRepos ?? true,
includeOrganizations: envConfig.github.mirrorOrganizations ? [] : (existingConfig?.[0]?.githubConfig?.includeOrganizations ?? []),
starredReposOrg: envConfig.github.starredReposOrg || existingConfig?.[0]?.githubConfig?.starredReposOrg || 'starred',
starredReposMode: envConfig.github.starredReposMode || existingConfig?.[0]?.githubConfig?.starredReposMode || 'dedicated-org',
+7 -4
View File
@@ -848,12 +848,15 @@ describe("Enhanced Gitea Operations", () => {
}
);
// All metadata components were previously synced, so none should be called again
// Metadata reconciliation now runs on every sync (mirror* functions
// are idempotent and PATCH existing entries by marker/name/title).
// Releases are still skipped here because the flag is off in this config.
// Labels are still skipped because the issues path also handles labels.
expect(mockMirrorGitHubReleasesToGitea).not.toHaveBeenCalled();
expect(mockMirrorGitRepoIssuesToGitea).not.toHaveBeenCalled();
expect(mockMirrorGitRepoPullRequestsToGitea).not.toHaveBeenCalled();
expect(mockMirrorGitRepoIssuesToGitea).toHaveBeenCalledTimes(1);
expect(mockMirrorGitRepoPullRequestsToGitea).toHaveBeenCalledTimes(1);
expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled();
expect(mockMirrorGitRepoMilestonesToGitea).not.toHaveBeenCalled();
expect(mockMirrorGitRepoMilestonesToGitea).toHaveBeenCalledTimes(1);
});
});
+9 -39
View File
@@ -601,25 +601,23 @@ export async function syncGiteaRepoEnhanced({
return metadataOctokit;
};
// Reconcile metadata on every sync (matches the release path).
// The underlying mirror* functions are idempotent: issues/PRs are
// matched via [GH-ISSUE #N] / [GH-PR #N] markers and PATCHed in
// place, labels are deduped by name, milestones by title.
const shouldMirrorReleases =
!!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred;
const shouldMirrorIssuesThisRun =
!!config.giteaConfig?.mirrorIssues &&
!skipMetadataForStarred &&
!metadataState.components.issues;
!!config.giteaConfig?.mirrorIssues && !skipMetadataForStarred;
const shouldMirrorPullRequests =
!!config.giteaConfig?.mirrorPullRequests &&
!skipMetadataForStarred &&
!metadataState.components.pullRequests;
!!config.giteaConfig?.mirrorPullRequests && !skipMetadataForStarred;
// Labels-only path; issues run already creates/reconciles labels.
const shouldMirrorLabels =
!!config.giteaConfig?.mirrorLabels &&
!skipMetadataForStarred &&
!shouldMirrorIssuesThisRun &&
!metadataState.components.labels;
!shouldMirrorIssuesThisRun;
const shouldMirrorMilestones =
!!config.giteaConfig?.mirrorMilestones &&
!skipMetadataForStarred &&
!metadataState.components.milestones;
!!config.giteaConfig?.mirrorMilestones && !skipMetadataForStarred;
if (shouldMirrorReleases) {
const octokit = ensureOctokit();
@@ -684,13 +682,6 @@ export async function syncGiteaRepoEnhanced({
);
}
}
} else if (
config.giteaConfig?.mirrorIssues &&
metadataState.components.issues
) {
console.log(
`[Sync] Issues already mirrored for ${repository.name}; skipping to avoid duplicates`
);
}
if (shouldMirrorPullRequests) {
@@ -721,13 +712,6 @@ export async function syncGiteaRepoEnhanced({
);
}
}
} else if (
config.giteaConfig?.mirrorPullRequests &&
metadataState.components.pullRequests
) {
console.log(
`[Sync] Pull requests already mirrored for ${repository.name}; skipping`
);
}
if (shouldMirrorLabels) {
@@ -760,13 +744,6 @@ export async function syncGiteaRepoEnhanced({
);
}
}
} else if (
config.giteaConfig?.mirrorLabels &&
metadataState.components.labels
) {
console.log(
`[Sync] Labels already mirrored for ${repository.name}; skipping`
);
}
if (shouldMirrorMilestones) {
@@ -799,13 +776,6 @@ export async function syncGiteaRepoEnhanced({
);
}
}
} else if (
config.giteaConfig?.mirrorMilestones &&
metadataState.components.milestones
) {
console.log(
`[Sync] Milestones already mirrored for ${repository.name}; skipping`
);
}
if (metadataUpdated) {
@@ -0,0 +1,132 @@
/**
* Regression test for issue #268.
*
* `let migrateSucceeded = false;` was declared *inside* the try block
* of mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg, but the
* catch block referenced it. `let` is block-scoped to the try, so any
* error inside try made the catch crash with `ReferenceError:
* migrateSucceeded is not defined` before reaching the DB update that
* marks the repo "failed". Result: repos stuck in "mirroring" forever
* with no entry in the activity log (see issue logs).
*
* This test asserts the declaration is hoisted above the try block in
* both functions. It deliberately reads the source rather than calling
* the functions, because behavioral tests for these functions require
* heavy module mocks that pollute other test files (bun's mock.module
* is process-wide and persists across files).
*/
import { describe, test, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const SOURCE = readFileSync(
join(import.meta.dir, "gitea.ts"),
"utf8"
);
/**
* Locate the body of a function declaration by name. Walks from the
* declaration, balances parens to skip the parameter list (which can
* contain destructured object literals with their own braces), then
* finds the body's opening brace and its matching close.
*/
function extractFunctionBody(source: string, declarationStart: RegExp): string {
const match = source.match(declarationStart);
if (!match) {
throw new Error(`Could not locate declaration ${declarationStart}`);
}
let i = match.index! + match[0].length;
// Skip whitespace until the opening paren of the parameter list.
while (i < source.length && source[i] !== "(") i++;
if (source[i] !== "(") {
throw new Error(`No '(' after ${declarationStart}`);
}
// Balance parens to find the end of the parameter list. Braces inside
// the parameter list (e.g. destructured `{ foo, bar }`) are allowed
// and ignored.
let parenDepth = 0;
for (; i < source.length; i++) {
if (source[i] === "(") parenDepth++;
else if (source[i] === ")") {
parenDepth--;
if (parenDepth === 0) {
i++;
break;
}
}
}
// Skip return-type annotation, => arrow, whitespace, until the body's `{`.
while (i < source.length && source[i] !== "{") i++;
if (source[i] !== "{") {
throw new Error(`No body '{' for ${declarationStart}`);
}
// Balance braces for the body.
let braceDepth = 0;
const startIdx = i;
for (; i < source.length; i++) {
if (source[i] === "{") braceDepth++;
else if (source[i] === "}") {
braceDepth--;
if (braceDepth === 0) {
return source.slice(startIdx, i + 1);
}
}
}
throw new Error(`Unterminated body for ${declarationStart}`);
}
/**
* Confirm that within a function body, the first `let migrateSucceeded`
* declaration occurs BEFORE the function's outermost `try {`.
*
* If the declaration is inside the try block, the catch block can't see
* it (ReferenceError in production = repo stuck mirroring).
*/
function assertMigrateSucceededDeclaredBeforeTry(body: string, label: string) {
const declIdx = body.indexOf("let migrateSucceeded");
expect(declIdx, `${label}: 'let migrateSucceeded' should exist`).toBeGreaterThanOrEqual(0);
// The function's outermost try is the first standalone `try {` in
// the body — assignments and inner try/catches don't share its name.
const tryIdx = body.search(/\btry\s*\{/);
expect(tryIdx, `${label}: outermost 'try {' should exist`).toBeGreaterThanOrEqual(0);
expect(
declIdx,
`${label}: 'let migrateSucceeded' must be declared BEFORE the try block ` +
`so the catch block can read it. If declared inside try, it's block-scoped ` +
`and the catch will throw ReferenceError, leaving repos stuck in 'mirroring'. ` +
`See issue #268.`
).toBeLessThan(tryIdx);
// And it should still be assigned to true after the migrate call —
// otherwise the catch can't tell whether to clear mirroredLocation.
expect(
body.includes("migrateSucceeded = true"),
`${label}: 'migrateSucceeded = true' assignment should exist after the migrate call`
).toBe(true);
// And the catch must read it.
expect(
body.includes("if (!migrateSucceeded)"),
`${label}: catch block should read 'migrateSucceeded' to decide whether to clear mirroredLocation`
).toBe(true);
}
describe("issue #268 — migrateSucceeded scoping regression", () => {
test("mirrorGithubRepoToGitea declares migrateSucceeded outside try", () => {
const body = extractFunctionBody(
SOURCE,
/export const mirrorGithubRepoToGitea = async\b/
);
assertMigrateSucceededDeclaredBeforeTry(body, "mirrorGithubRepoToGitea");
});
test("mirrorGitHubRepoToGiteaOrg declares migrateSucceeded outside try", () => {
const body = extractFunctionBody(
SOURCE,
/export async function mirrorGitHubRepoToGiteaOrg\b/
);
assertMigrateSucceededDeclaredBeforeTry(body, "mirrorGitHubRepoToGiteaOrg");
});
});
+32 -87
View File
@@ -539,6 +539,11 @@ export const mirrorGithubRepoToGitea = async ({
repository: Repository;
config: Partial<Config>;
}): Promise<any> => {
// Declared here (not inside try) so the catch block can read it.
// `let` is block-scoped — declaring inside try makes it inaccessible
// from catch, which previously caused a ReferenceError that swallowed
// the real error and left repos stuck in "mirroring" state.
let migrateSucceeded = false;
try {
if (!config.userId || !config.githubConfig || !config.giteaConfig) {
throw new Error("github config and gitea config are required.");
@@ -837,10 +842,6 @@ export const mirrorGithubRepoToGitea = async ({
);
}
// Track whether the Gitea migrate call succeeded so the catch block
// knows whether to clear mirroredLocation (only safe before migrate succeeds)
let migrateSucceeded = false;
const response = await httpPost(
apiUrl,
migratePayload,
@@ -897,14 +898,15 @@ export const mirrorGithubRepoToGitea = async ({
}
}
// Determine metadata operations to avoid duplicates
// Reconcile metadata on every sync (matches the release path above).
// The underlying mirror* functions are idempotent: issues/PRs are
// matched via [GH-ISSUE #N] / [GH-PR #N] markers and PATCHed in place,
// labels are deduped by name, milestones by title.
const shouldMirrorIssuesThisRun =
!!config.giteaConfig?.mirrorIssues &&
!skipMetadataForStarred &&
!metadataState.components.issues;
!!config.giteaConfig?.mirrorIssues && !skipMetadataForStarred;
console.log(
`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, alreadyMirrored=${metadataState.components.issues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssuesThisRun}`
`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssuesThisRun}`
);
if (shouldMirrorIssuesThisRun) {
@@ -930,19 +932,13 @@ export const mirrorGithubRepoToGitea = async ({
);
// Continue with other metadata operations even if issues fail
}
} else if (config.giteaConfig?.mirrorIssues && metadataState.components.issues) {
console.log(
`[Metadata] Issues already mirrored for ${repository.name}; skipping to avoid duplicates`
);
}
const shouldMirrorPullRequests =
!!config.giteaConfig?.mirrorPullRequests &&
!skipMetadataForStarred &&
!metadataState.components.pullRequests;
!!config.giteaConfig?.mirrorPullRequests && !skipMetadataForStarred;
console.log(
`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, alreadyMirrored=${metadataState.components.pullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`
`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`
);
if (shouldMirrorPullRequests) {
@@ -967,23 +963,16 @@ export const mirrorGithubRepoToGitea = async ({
);
// Continue with other metadata operations even if PRs fail
}
} else if (
config.giteaConfig?.mirrorPullRequests &&
metadataState.components.pullRequests
) {
console.log(
`[Metadata] Pull requests already mirrored for ${repository.name}; skipping`
);
}
// Labels-only path; issues run above already creates/reconciles labels.
const shouldMirrorLabels =
!!config.giteaConfig?.mirrorLabels &&
!skipMetadataForStarred &&
!shouldMirrorIssuesThisRun &&
!metadataState.components.labels;
!shouldMirrorIssuesThisRun;
console.log(
`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, alreadyMirrored=${metadataState.components.labels}, issuesRunning=${shouldMirrorIssuesThisRun}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`
`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, issuesRunning=${shouldMirrorIssuesThisRun}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`
);
if (shouldMirrorLabels) {
@@ -1008,19 +997,13 @@ export const mirrorGithubRepoToGitea = async ({
);
// Continue with other metadata operations even if labels fail
}
} else if (config.giteaConfig?.mirrorLabels && metadataState.components.labels) {
console.log(
`[Metadata] Labels already mirrored for ${repository.name}; skipping`
);
}
const shouldMirrorMilestones =
!!config.giteaConfig?.mirrorMilestones &&
!skipMetadataForStarred &&
!metadataState.components.milestones;
!!config.giteaConfig?.mirrorMilestones && !skipMetadataForStarred;
console.log(
`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, alreadyMirrored=${metadataState.components.milestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
);
if (shouldMirrorMilestones) {
@@ -1045,13 +1028,6 @@ export const mirrorGithubRepoToGitea = async ({
);
// Continue with other metadata operations even if milestones fail
}
} else if (
config.giteaConfig?.mirrorMilestones &&
metadataState.components.milestones
) {
console.log(
`[Metadata] Milestones already mirrored for ${repository.name}; skipping`
);
}
if (metadataUpdated) {
@@ -1321,6 +1297,9 @@ export async function mirrorGitHubRepoToGiteaOrg({
giteaOrgId: number;
orgName: string;
}) {
// Declared here (not inside try) so the catch block can read it.
// See note in mirrorGithubRepoToGitea for the scoping bug this prevents.
let migrateSucceeded = false;
try {
if (
!config.giteaConfig?.url ||
@@ -1528,8 +1507,6 @@ export async function mirrorGitHubRepoToGiteaOrg({
);
}
let migrateSucceeded = false;
const migrateRes = await httpPost(
apiUrl,
migratePayload,
@@ -1585,13 +1562,13 @@ export async function mirrorGitHubRepoToGiteaOrg({
}
}
// Reconcile metadata on every sync. See note in mirrorGithubRepoToGitea
// above. The underlying mirror* functions are idempotent.
const shouldMirrorIssuesThisRun =
!!config.giteaConfig?.mirrorIssues &&
!skipMetadataForStarred &&
!metadataState.components.issues;
!!config.giteaConfig?.mirrorIssues && !skipMetadataForStarred;
console.log(
`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, alreadyMirrored=${metadataState.components.issues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssuesThisRun}`
`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssuesThisRun}`
);
if (shouldMirrorIssuesThisRun) {
@@ -1617,22 +1594,13 @@ export async function mirrorGitHubRepoToGiteaOrg({
);
// Continue with other metadata operations even if issues fail
}
} else if (
config.giteaConfig?.mirrorIssues &&
metadataState.components.issues
) {
console.log(
`[Metadata] Issues already mirrored for ${repository.name}; skipping`
);
}
const shouldMirrorPullRequests =
!!config.giteaConfig?.mirrorPullRequests &&
!skipMetadataForStarred &&
!metadataState.components.pullRequests;
!!config.giteaConfig?.mirrorPullRequests && !skipMetadataForStarred;
console.log(
`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, alreadyMirrored=${metadataState.components.pullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`
`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`
);
if (shouldMirrorPullRequests) {
@@ -1657,23 +1625,16 @@ export async function mirrorGitHubRepoToGiteaOrg({
);
// Continue with other metadata operations even if PRs fail
}
} else if (
config.giteaConfig?.mirrorPullRequests &&
metadataState.components.pullRequests
) {
console.log(
`[Metadata] Pull requests already mirrored for ${repository.name}; skipping`
);
}
// Labels-only path; issues run above already creates/reconciles labels.
const shouldMirrorLabels =
!!config.giteaConfig?.mirrorLabels &&
!skipMetadataForStarred &&
!shouldMirrorIssuesThisRun &&
!metadataState.components.labels;
!shouldMirrorIssuesThisRun;
console.log(
`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, alreadyMirrored=${metadataState.components.labels}, issuesRunning=${shouldMirrorIssuesThisRun}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`
`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, issuesRunning=${shouldMirrorIssuesThisRun}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`
);
if (shouldMirrorLabels) {
@@ -1698,22 +1659,13 @@ export async function mirrorGitHubRepoToGiteaOrg({
);
// Continue with other metadata operations even if labels fail
}
} else if (
config.giteaConfig?.mirrorLabels &&
metadataState.components.labels
) {
console.log(
`[Metadata] Labels already mirrored for ${repository.name}; skipping`
);
}
const shouldMirrorMilestones =
!!config.giteaConfig?.mirrorMilestones &&
!skipMetadataForStarred &&
!metadataState.components.milestones;
!!config.giteaConfig?.mirrorMilestones && !skipMetadataForStarred;
console.log(
`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, alreadyMirrored=${metadataState.components.milestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
);
if (shouldMirrorMilestones) {
@@ -1738,13 +1690,6 @@ export async function mirrorGitHubRepoToGiteaOrg({
);
// Continue with other metadata operations even if milestones fail
}
} else if (
config.giteaConfig?.mirrorMilestones &&
metadataState.components.milestones
) {
console.log(
`[Metadata] Milestones already mirrored for ${repository.name}; skipping`
);
}
if (metadataUpdated) {
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, test, mock } from "bun:test";
import { getGithubRepositories } from "@/lib/github";
function makeRepo() {
return {
name: "demo",
full_name: "octo/demo",
html_url: "https://github.com/octo/demo",
clone_url: "https://github.com/octo/demo.git",
owner: { login: "octo", type: "User" },
private: false,
fork: false,
has_issues: true,
archived: false,
size: 1,
language: "TypeScript",
description: "",
default_branch: "main",
visibility: "public",
disabled: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
};
}
function makeOctokit() {
let captured: Record<string, unknown> | null = null;
const paginate = mock(async (_method: unknown, options?: Record<string, unknown>) => {
captured = options ?? null;
return [makeRepo()];
});
return {
octokit: {
paginate,
repos: { listForAuthenticatedUser: () => {} },
} as any,
getCaptured: () => captured,
};
}
describe("getGithubRepositories - affiliation", () => {
test("defaults to owner+collaborator+organization_member when field is unset (backward compat)", async () => {
const { octokit, getCaptured } = makeOctokit();
await getGithubRepositories({ octokit, config: { githubConfig: { owner: "octo" } as any } });
expect(getCaptured()?.affiliation).toBe("owner,collaborator,organization_member");
});
test("uses owner+organization_member when includeCollaboratorRepos is false", async () => {
const { octokit, getCaptured } = makeOctokit();
await getGithubRepositories({
octokit,
config: { githubConfig: { owner: "octo", includeCollaboratorRepos: false } as any },
});
expect(getCaptured()?.affiliation).toBe("owner,organization_member");
});
test("uses owner+collaborator+organization_member when includeCollaboratorRepos is true", async () => {
const { octokit, getCaptured } = makeOctokit();
await getGithubRepositories({
octokit,
config: { githubConfig: { owner: "octo", includeCollaboratorRepos: true } as any },
});
expect(getCaptured()?.affiliation).toBe("owner,collaborator,organization_member");
});
test("override forces owner+collaborator+organization_member regardless of config (used by cleanup)", async () => {
const { octokit, getCaptured } = makeOctokit();
await getGithubRepositories({
octokit,
config: { githubConfig: { owner: "octo", includeCollaboratorRepos: false } as any },
includeCollaboratorReposOverride: true,
});
expect(getCaptured()?.affiliation).toBe("owner,collaborator,organization_member");
});
test("always includes organization_member (regression guard for org-repo invisibility)", async () => {
const cases: Array<{ includeCollab?: boolean; override?: boolean }> = [
{},
{ includeCollab: true },
{ includeCollab: false },
{ override: true },
{ includeCollab: false, override: true },
];
for (const c of cases) {
const { octokit, getCaptured } = makeOctokit();
await getGithubRepositories({
octokit,
config: {
githubConfig: {
owner: "octo",
...(c.includeCollab !== undefined && { includeCollaboratorRepos: c.includeCollab }),
} as any,
},
...(c.override !== undefined && { includeCollaboratorReposOverride: c.override }),
});
const aff = String(getCaptured()?.affiliation ?? "");
expect(aff.split(",")).toContain("organization_member");
}
});
});
+18 -1
View File
@@ -235,14 +235,31 @@ export async function getGithubRepoCloneUrl({
export async function getGithubRepositories({
octokit,
config,
includeCollaboratorReposOverride,
}: {
octokit: Octokit;
config: Partial<Config>;
// Force-include collaborator repos regardless of user setting. Used by the
// cleanup service so we never mark a collab repo as orphaned just because
// the import filter is currently off.
includeCollaboratorReposOverride?: boolean;
}): Promise<GitRepo[]> {
try {
const includeCollab =
includeCollaboratorReposOverride ??
config.githubConfig?.includeCollaboratorRepos ??
true;
// Always include organization_member so repos owned by orgs the user
// belongs to are returned. Omitting it caused org repos to be invisible
// to the main sync, the scheduler, and the cleanup service (which then
// archived them on restart as if they had been deleted on GitHub).
const affiliation = includeCollab
? "owner,collaborator,organization_member"
: "owner,organization_member";
const repos = await octokit.paginate(
octokit.repos.listForAuthenticatedUser,
{ per_page: 100 },
{ per_page: 100, affiliation },
);
const skipForks = config.githubConfig?.skipForks ?? false;
+4 -2
View File
@@ -3,7 +3,7 @@ import type { NotificationEvent } from "./providers/ntfy";
import { sendNtfyNotification } from "./providers/ntfy";
import { sendAppriseNotification } from "./providers/apprise";
import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { decrypt } from "@/lib/utils/encryption";
function sanitizeTestNotificationError(error: unknown): string {
@@ -120,11 +120,13 @@ export async function triggerJobNotification({
return;
}
// Fetch user's config from database
// Fetch user's config from database — prefer active and most-recently-updated
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
const configResults = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (configResults.length === 0) {
+7 -3
View File
@@ -5,7 +5,7 @@
import { findInterruptedJobs, resumeInterruptedJob } from './helpers';
import { db, repositories, organizations, mirrorJobs, configs } from './db';
import { eq, and, lt, inArray } from 'drizzle-orm';
import { eq, and, lt, inArray, sql } from 'drizzle-orm';
import { mirrorGithubRepoToGitea, mirrorGitHubOrgRepoToGiteaOrg, syncGiteaRepo } from './gitea';
import { createGitHubClient } from './github';
import { processWithResilience } from './utils/concurrency';
@@ -216,11 +216,13 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
console.log(`Recovering mirror job ${job.id} with ${remainingItemIds.length} remaining items`);
try {
// Get the config for this user with better error handling
// Get the config for this user — prefer active and most-recently-updated
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
const userConfigs = await db
.select()
.from(configs)
.where(eq(configs.userId, job.userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (userConfigs.length === 0) {
@@ -347,11 +349,13 @@ async function recoverSyncJob(job: any, remainingItemIds: string[]) {
console.log(`Recovering sync job ${job.id} with ${remainingItemIds.length} remaining items`);
try {
// Get the config for this user with better error handling
// Get the config for this user — prefer active and most-recently-updated
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
const userConfigs = await db
.select()
.from(configs)
.where(eq(configs.userId, job.userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (userConfigs.length === 0) {
+5 -2
View File
@@ -33,9 +33,12 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
let githubApiAccessible = true;
try {
// Fetch GitHub data
// Fetch GitHub data. Always include collaborator repos here regardless
// of the user's import filter, otherwise repos previously mirrored as a
// collaborator would be flagged as orphaned and archived/deleted as soon
// as the user disables the filter.
const [basicAndForkedRepos, starredRepos] = await Promise.all([
getGithubRepositories({ octokit, config }),
getGithubRepositories({ octokit, config, includeCollaboratorReposOverride: true }),
config.githubConfig?.includeStarred
? getGithubStarredRepositories({ octokit, config })
: Promise.resolve([]),
+45
View File
@@ -58,6 +58,51 @@ describe("Scheduler Service - Ignored Repository Handling", () => {
expect(shouldMirrorRepository(oldSyncedRepo)).toBe(true);
});
test("auto-mirror filter respects autoMirror and autoMirrorStarred independently", () => {
// Mirrors the inline filter at scheduler-service.ts L228-233 / L609-614:
// a repo is "starred from another owner" iff isStarred && owner !== githubOwner.
// Such repos are gated by autoMirrorStarred; everything else is gated by autoMirror.
const githubOwner = "Alice".toLowerCase();
const filterRepos = (
repos: Array<{ name: string; isStarred: boolean; owner: string }>,
autoMirror: boolean,
autoMirrorStarred: boolean,
) =>
repos.filter(repo => {
const isStarredFromOther = repo.isStarred && repo.owner.toLowerCase() !== githubOwner;
return isStarredFromOther ? autoMirrorStarred : autoMirror;
});
// "ALICE" tests case-insensitive owner match — GitHub usernames are case-insensitive,
// so a self-starred repo stored with different casing must still count as owned.
const repos = [
{ name: "owned-repo", isStarred: false, owner: "alice" },
{ name: "self-starred", isStarred: true, owner: "ALICE" },
{ name: "starred-from-bob", isStarred: true, owner: "bob" },
];
// Both off: nothing mirrors
expect(filterRepos(repos, false, false).map(r => r.name)).toEqual([]);
// Only autoMirror: owned + self-starred, not third-party stars
expect(filterRepos(repos, true, false).map(r => r.name)).toEqual([
"owned-repo",
"self-starred",
]);
// Only autoMirrorStarred: just third-party stars (the bug fix — used to be empty)
expect(filterRepos(repos, false, true).map(r => r.name)).toEqual([
"starred-from-bob",
]);
// Both on: everything
expect(filterRepos(repos, true, true).map(r => r.name)).toEqual([
"owned-repo",
"self-starred",
"starred-from-bob",
]);
});
test("should validate all repository status enum values", () => {
const validStatuses = [
"imported",
+34 -30
View File
@@ -203,10 +203,14 @@ async function runScheduledSync(config: any): Promise<void> {
}
}
// Auto-mirror: Mirror imported/pending/failed repositories if enabled
if (scheduleConfig.autoMirror) {
// Auto-mirror: Mirror imported/pending/failed repositories if enabled.
// autoMirror covers owned repos; autoMirrorStarred covers starred repos from other owners.
// Either flag on its own is enough to enter this phase.
const autoMirrorOwned = !!scheduleConfig.autoMirror;
const autoMirrorStarred = !!config.githubConfig?.autoMirrorStarred;
if (autoMirrorOwned || autoMirrorStarred) {
try {
console.log(`[Scheduler] Auto-mirror enabled - checking for repositories to mirror for user ${userId}...`);
console.log(`[Scheduler] Auto-mirror enabled (owned=${autoMirrorOwned}, starred=${autoMirrorStarred}) - checking for repositories to mirror for user ${userId}...`);
let reposNeedingMirror = await db
.select()
.from(repositories)
@@ -221,17 +225,16 @@ async function runScheduledSync(config: any): Promise<void> {
)
);
// Filter out starred repos from auto-mirror when autoMirrorStarred is disabled
if (!config.githubConfig?.autoMirrorStarred) {
const githubOwner = config.githubConfig?.owner || '';
const beforeCount = reposNeedingMirror.length;
reposNeedingMirror = reposNeedingMirror.filter(
repo => !repo.isStarred || repo.owner === githubOwner
);
const skippedCount = beforeCount - reposNeedingMirror.length;
if (skippedCount > 0) {
console.log(`[Scheduler] Skipped ${skippedCount} starred repositories from auto-mirror (autoMirrorStarred is disabled)`);
}
const githubOwner = (config.githubConfig?.owner || '').toLowerCase();
const beforeCount = reposNeedingMirror.length;
reposNeedingMirror = reposNeedingMirror.filter(repo => {
// GitHub usernames are case-insensitive; lowercase both sides to avoid misclassifying self-starred repos.
const isStarredFromOther = repo.isStarred && repo.owner.toLowerCase() !== githubOwner;
return isStarredFromOther ? autoMirrorStarred : autoMirrorOwned;
});
const skippedCount = beforeCount - reposNeedingMirror.length;
if (skippedCount > 0) {
console.log(`[Scheduler] Skipped ${skippedCount} repositories from auto-mirror (autoMirror=${autoMirrorOwned}, autoMirrorStarred=${autoMirrorStarred})`);
}
if (reposNeedingMirror.length > 0) {
@@ -574,10 +577,12 @@ async function performInitialAutoStart(): Promise<void> {
continue;
}
// Step 2: Trigger mirror for all repositories that need mirroring
// Only auto-mirror if autoMirror is enabled in schedule config
if (!config.scheduleConfig?.autoMirror) {
console.log(`[Scheduler] Step 2: Skipping initial mirror - autoMirror is disabled for user ${config.userId}`);
// Step 2: Trigger mirror for all repositories that need mirroring.
// autoMirror covers owned repos; autoMirrorStarred covers starred repos from other owners.
const autoMirrorOwned = !!config.scheduleConfig?.autoMirror;
const autoMirrorStarred = !!config.githubConfig?.autoMirrorStarred;
if (!autoMirrorOwned && !autoMirrorStarred) {
console.log(`[Scheduler] Step 2: Skipping initial mirror - autoMirror and autoMirrorStarred are both disabled for user ${config.userId}`);
// Still update schedule config timestamps
const currentTime2 = new Date();
@@ -587,7 +592,7 @@ async function performInitialAutoStart(): Promise<void> {
continue;
}
console.log(`[Scheduler] Step 2: Triggering mirror for repositories that need mirroring...`);
console.log(`[Scheduler] Step 2: Triggering mirror for repositories that need mirroring (owned=${autoMirrorOwned}, starred=${autoMirrorStarred})...`);
let reposNeedingMirror = await db
.select()
.from(repositories)
@@ -602,17 +607,16 @@ async function performInitialAutoStart(): Promise<void> {
)
);
// Filter out starred repos from auto-mirror when autoMirrorStarred is disabled
if (!config.githubConfig?.autoMirrorStarred) {
const githubOwner = config.githubConfig?.owner || '';
const beforeCount = reposNeedingMirror.length;
reposNeedingMirror = reposNeedingMirror.filter(
repo => !repo.isStarred || repo.owner === githubOwner
);
const skippedCount = beforeCount - reposNeedingMirror.length;
if (skippedCount > 0) {
console.log(`[Scheduler] Skipped ${skippedCount} starred repositories from initial auto-mirror (autoMirrorStarred is disabled)`);
}
const githubOwner = (config.githubConfig?.owner || '').toLowerCase();
const beforeCount = reposNeedingMirror.length;
reposNeedingMirror = reposNeedingMirror.filter(repo => {
// GitHub usernames are case-insensitive; lowercase both sides to avoid misclassifying self-starred repos.
const isStarredFromOther = repo.isStarred && repo.owner.toLowerCase() !== githubOwner;
return isStarredFromOther ? autoMirrorStarred : autoMirrorOwned;
});
const skippedCount = beforeCount - reposNeedingMirror.length;
if (skippedCount > 0) {
console.log(`[Scheduler] Skipped ${skippedCount} repositories from initial auto-mirror (autoMirror=${autoMirrorOwned}, autoMirrorStarred=${autoMirrorStarred})`);
}
if (reposNeedingMirror.length > 0) {
+4 -2
View File
@@ -1,5 +1,5 @@
import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { encrypt } from "@/lib/utils/encryption";
import { getNextScheduledRun, normalizeTimezone } from "@/lib/utils/schedule-utils";
@@ -25,11 +25,13 @@ export interface DefaultConfigOptions {
* Environment variables can override these defaults
*/
export async function createDefaultConfig({ userId, envOverrides = {} }: DefaultConfigOptions) {
// Check if config already exists
// Check if config already exists — prefer active and most-recently-updated
// to avoid returning a stale inactive stub when multiple rows exist (see issue #271).
const existingConfig = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (existingConfig.length > 0) {
+92 -2
View File
@@ -1,6 +1,53 @@
import { expect, test } from "bun:test";
import { mapDbScheduleToUi, mapUiScheduleToDb } from "./config-mapper";
import { scheduleConfigSchema } from "@/lib/db/schema";
import {
mapDbScheduleToUi,
mapDbToUiConfig,
mapUiScheduleToDb,
mapUiToDbConfig,
} from "./config-mapper";
import { githubConfigSchema, scheduleConfigSchema } from "@/lib/db/schema";
import type {
AdvancedOptions,
GitHubConfig,
GiteaConfig,
MirrorOptions,
} from "@/types/config";
function buildMinimalUiConfigs(overrides: { includeCollaboratorRepos?: boolean } = {}) {
const githubConfig: GitHubConfig = {
username: "octo",
token: "ghp_x",
privateRepositories: false,
mirrorStarred: false,
...overrides,
};
const giteaConfig: GiteaConfig = {
url: "https://gitea.example",
username: "octo",
token: "g_x",
organization: "github-mirrors",
visibility: "public",
starredReposOrg: "starred",
preserveOrgStructure: false,
};
const mirrorOptions: MirrorOptions = {
mirrorReleases: false,
mirrorLFS: false,
mirrorMetadata: false,
metadataComponents: {
issues: false,
pullRequests: false,
labels: false,
milestones: false,
wiki: false,
},
};
const advancedOptions: AdvancedOptions = {
skipForks: false,
starredCodeOnly: false,
};
return { githubConfig, giteaConfig, mirrorOptions, advancedOptions };
}
test("mapUiScheduleToDb - builds cron from start time + frequency", () => {
const existing = scheduleConfigSchema.parse({});
@@ -34,3 +81,46 @@ test("mapDbScheduleToUi - infers clock mode for generated cron", () => {
expect(mapped.startTime).toBe("22:15");
expect(mapped.timezone).toBe("Asia/Kolkata");
});
test("includeCollaboratorRepos round-trips through UI -> DB -> UI when true", () => {
const ui = buildMinimalUiConfigs({ includeCollaboratorRepos: true });
const db = mapUiToDbConfig(
ui.githubConfig,
ui.giteaConfig,
ui.mirrorOptions,
ui.advancedOptions,
);
expect(db.githubConfig.includeCollaboratorRepos).toBe(true);
const roundTripped = mapDbToUiConfig({ githubConfig: db.githubConfig, giteaConfig: db.giteaConfig });
expect(roundTripped.githubConfig.includeCollaboratorRepos).toBe(true);
});
test("includeCollaboratorRepos round-trips through UI -> DB -> UI when false", () => {
const ui = buildMinimalUiConfigs({ includeCollaboratorRepos: false });
const db = mapUiToDbConfig(
ui.githubConfig,
ui.giteaConfig,
ui.mirrorOptions,
ui.advancedOptions,
);
expect(db.githubConfig.includeCollaboratorRepos).toBe(false);
const roundTripped = mapDbToUiConfig({ githubConfig: db.githubConfig, giteaConfig: db.giteaConfig });
expect(roundTripped.githubConfig.includeCollaboratorRepos).toBe(false);
});
test("DB row missing includeCollaboratorRepos defaults to true on read", () => {
// Existing rows from before this field existed have no value stored.
const ui = mapDbToUiConfig({ githubConfig: { owner: "octo", token: "" } });
expect(ui.githubConfig.includeCollaboratorRepos).toBe(true);
});
test("githubConfigSchema parses includeCollaboratorRepos with true default", () => {
const parsed = githubConfigSchema.parse({
owner: "octo",
type: "personal",
token: "",
});
expect(parsed.includeCollaboratorRepos).toBe(true);
});
+5
View File
@@ -50,6 +50,7 @@ export function mapUiToDbConfig(
// 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
@@ -142,6 +143,7 @@ export function mapDbToUiConfig(dbConfig: any): {
username: dbConfig.githubConfig?.owner || "", // Map owner to username
token: dbConfig.githubConfig?.token || "",
privateRepositories: dbConfig.githubConfig?.includePrivate || false, // Map includePrivate to privateRepositories
includeCollaboratorRepos: dbConfig.githubConfig?.includeCollaboratorRepos ?? true,
mirrorStarred: dbConfig.githubConfig?.includeStarred || false, // Map includeStarred to mirrorStarred
starredLists: normalizeStarredLists(dbConfig.githubConfig?.starredLists),
};
@@ -246,6 +248,7 @@ export function mapUiScheduleToDb(uiSchedule: any, existing?: DbScheduleConfig):
enabled: !!uiSchedule.enabled,
interval: intervalExpression,
timezone,
autoMirror: typeof uiSchedule.autoMirror === "boolean" ? uiSchedule.autoMirror : base.autoMirror,
nextRun: scheduleChanged ? undefined : base.nextRun,
} as DbScheduleConfig;
}
@@ -264,6 +267,7 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
clockFrequencyHours: 24,
startTime: "22:00",
timezone: "UTC",
autoMirror: false,
lastRun: null,
nextRun: null,
};
@@ -296,6 +300,7 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
clockFrequencyHours: parsedClockSchedule?.frequencyHours ?? 24,
startTime: parsedClockSchedule?.startTime ?? "22:00",
timezone: normalizeTimezone(dbSchedule.timezone || "UTC"),
autoMirror: dbSchedule.autoMirror ?? false,
lastRun: dbSchedule.lastRun || null,
nextRun: dbSchedule.nextRun || null,
};
+8 -3
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import { db, configs, users } from "@/lib/db";
import { v4 as uuidv4 } from "uuid";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import {
mapUiToDbConfig,
@@ -83,11 +83,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
}
// Fetch existing config
// Fetch existing config — prefer the active config; fall back to most-recently-updated
// so a stale inactive stub never wins over a populated active row (see issue #271).
const existingConfigResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const existingConfig = existingConfigResult[0];
@@ -255,11 +257,14 @@ export const GET: APIRoute = async ({ request, locals }) => {
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
// Fetch the configuration for the user
// Fetch the configuration for the user — prefer the active config; fall back to
// most-recently-updated so a stale inactive stub never wins over a populated
// active row (see issue #271).
const config = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (config.length === 0) {
+6 -1
View File
@@ -38,7 +38,12 @@ export const GET: APIRoute = async ({ request, locals }) => {
.where(eq(mirrorJobs.userId, userId))
.orderBy(sql`${mirrorJobs.timestamp} DESC`)
.limit(10),
db.select().from(configs).where(eq(configs.userId, userId)).limit(1),
db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1),
db
.select({ value: count() })
.from(repositories)
+32 -1
View File
@@ -2,6 +2,25 @@ import type { APIRoute } from 'astro';
import { httpGet, HttpError } from '@/lib/http-client';
import { createSecureErrorResponse } from '@/lib/utils';
// Forgejo reports `15.0.0+gitea-1.22.0`; pure Gitea reports just `1.22.0`.
// Forgejo < 15.0.0 has a known bug where pull-mirror credentials sent via
// /api/v1/repos/migrate are not persisted, so subsequent sync of private
// repos fails with `terminal prompts disabled`. Fixed upstream in v15.0.0
// via PR #11909 (codeberg.org/forgejo/forgejo/pulls/11909).
function parseServerInfo(versionString: string) {
const forgejoMatch = versionString.match(/^(\d+)\.(\d+)\.(\d+)\+gitea-/);
if (forgejoMatch) {
const major = Number(forgejoMatch[1]);
return {
type: 'forgejo' as const,
version: `${forgejoMatch[1]}.${forgejoMatch[2]}.${forgejoMatch[3]}`,
raw: versionString,
hasMirrorCredBug: major < 15,
};
}
return { type: 'gitea' as const, version: versionString, raw: versionString, hasMirrorCredBug: false };
}
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
@@ -49,7 +68,18 @@ export const POST: APIRoute = async ({ request }) => {
);
}
// Return success response with user data
let serverInfo: ReturnType<typeof parseServerInfo> | undefined;
try {
const versionResp = await httpGet(`${baseUrl}/api/v1/version`, {
'Accept': 'application/json',
});
if (typeof versionResp.data?.version === 'string') {
serverInfo = parseServerInfo(versionResp.data.version);
}
} catch {
// Version probe is best-effort; older or non-standard servers may not expose it.
}
return new Response(
JSON.stringify({
success: true,
@@ -59,6 +89,7 @@ export const POST: APIRoute = async ({ request }) => {
name: data.full_name,
avatar_url: data.avatar_url,
},
serverInfo,
}),
{
status: 200,
+4 -1
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import {
createGitHubClient,
getGithubStarredListNames,
@@ -15,10 +15,13 @@ export const GET: APIRoute = async ({ request, locals }) => {
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
// Prefer active and most-recently-updated config to avoid picking a stale
// inactive stub when multiple rows exist (see issue #271).
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (!config) {
+4 -2
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, configs, repositories } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { syncGiteaRepoEnhanced } from "@/lib/gitea-enhanced";
import { createSecureErrorResponse } from "@/lib/utils";
@@ -38,11 +38,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Fetch config
// Fetch config — prefer active and most-recently-updated to avoid picking
// a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -2
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import type { MirrorOrgRequest, MirrorOrgResponse } from "@/types/mirror";
import { db, configs, organizations } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { createGitHubClient } from "@/lib/github";
import { mirrorGitHubOrgToGitea } from "@/lib/gitea";
import { repoStatusEnum } from "@/types/Repository";
@@ -41,11 +41,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Fetch config
// Fetch config — prefer active and most-recently-updated to avoid picking
// a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+45 -38
View File
@@ -3,6 +3,46 @@ import type { MirrorRepoRequest } from "@/types/mirror";
import { POST } from "./mirror-repo";
// Mock the database module
const mockConfigRow = [{
id: "config-id",
userId: "user-id",
githubConfig: {
token: "github-token",
preserveOrgStructure: false,
mirrorIssues: false
},
giteaConfig: {
url: "https://gitea.example.com",
token: "gitea-token",
username: "giteauser"
}
}];
const mockRepoRows = [
{
id: "repo-id-1",
name: "test-repo-1",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
},
{
id: "repo-id-2",
name: "test-repo-2",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
}
];
const mockDb = {
select: mock(() => ({
from: mock((table: any) => ({
@@ -10,47 +50,14 @@ const mockDb = {
// Return config for configs table
if (table === mockConfigs) {
return {
limit: mock(() => Promise.resolve([{
id: "config-id",
userId: "user-id",
githubConfig: {
token: "github-token",
preserveOrgStructure: false,
mirrorIssues: false
},
giteaConfig: {
url: "https://gitea.example.com",
token: "gitea-token",
username: "giteauser"
}
}]))
orderBy: mock(() => ({
limit: mock(() => Promise.resolve(mockConfigRow))
})),
limit: mock(() => Promise.resolve(mockConfigRow))
};
}
// Return repositories for repositories table
return Promise.resolve([
{
id: "repo-id-1",
name: "test-repo-1",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
},
{
id: "repo-id-2",
name: "test-repo-2",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
}
]);
return Promise.resolve(mockRepoRows);
})
}))
}))
+4 -2
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
import { db, configs, repositories } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import {
mirrorGithubRepoToGitea,
@@ -43,11 +43,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Fetch config
// Fetch config — prefer active and most-recently-updated to avoid picking
// a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -1
View File
@@ -1,5 +1,5 @@
import type { APIRoute } from "astro";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { db, configs, repositories } from "@/lib/db";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import type { ResetMetadataRequest, ResetMetadataResponse } from "@/types/reset-metadata";
@@ -35,10 +35,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Prefer active and most-recently-updated config to avoid picking a stale
// inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -2
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, configs, repositories } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { getGiteaRepoOwnerAsync, isRepoPresentInGitea } from "@/lib/gitea";
import {
mirrorGithubRepoToGitea,
@@ -45,11 +45,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Fetch user config
// Fetch user config — prefer active and most-recently-updated to avoid picking
// a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -2
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, configs, repositories } from "@/lib/db";
import { and, eq, or } from "drizzle-orm";
import { and, eq, or, sql } from "drizzle-orm";
import { repoStatusEnum, repositoryVisibilityEnum } from "@/types/Repository";
import { isRepoPresentInGitea, syncGiteaRepo } from "@/lib/gitea";
import type {
@@ -19,11 +19,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
await request.json().catch(() => ({} as ScheduleSyncRepoRequest));
// Fetch config for the user
// Fetch config for the user — prefer active and most-recently-updated to avoid
// picking a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -2
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import type { MirrorRepoRequest } from "@/types/mirror";
import { db, configs, repositories } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { syncGiteaRepo } from "@/lib/gitea";
import type { SyncRepoResponse } from "@/types/sync";
@@ -38,11 +38,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Fetch config
// Fetch config — prefer active and most-recently-updated to avoid picking
// a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -1
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, rateLimits } from "@/lib/db";
import { eq, and, desc } from "drizzle-orm";
import { eq, and, desc, sql } from "drizzle-orm";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import { RateLimitManager } from "@/lib/rate-limit-manager";
import { createGitHubClient } from "@/lib/github";
@@ -19,10 +19,13 @@ export const GET: APIRoute = async ({ request, locals }) => {
try {
// If refresh is requested, fetch current rate limit from GitHub
if (refresh) {
// Prefer active and most-recently-updated config to avoid picking a stale
// inactive stub when multiple rows exist (see issue #271).
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (config && config.githubConfig?.token) {
+4 -1
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, organizations, repositories, configs } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { eq, and, sql } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { createMirrorJob } from "@/lib/helpers";
import {
@@ -21,10 +21,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
const userId = authResult.userId;
try {
// Prefer active and most-recently-updated config to avoid picking a stale
// inactive stub when multiple rows exist (see issue #271).
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (!config) {
+4 -2
View File
@@ -2,7 +2,7 @@ import type { APIRoute } from "astro";
import { Octokit } from "@octokit/rest";
import { configs, db, repositories } from "@/lib/db";
import { v4 as uuidv4 } from "uuid";
import { and, eq } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { type Repository } from "@/lib/db/schema";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import type {
@@ -72,11 +72,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
});
}
// Get user's active config
// Get user's active config — prefer active and most-recently-updated to avoid
// picking a stale inactive stub when multiple rows exist (see issue #271).
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (!config) {
+2
View File
@@ -36,6 +36,7 @@ export interface ScheduleConfig {
clockFrequencyHours?: number;
startTime?: string;
timezone?: string;
autoMirror?: boolean;
lastRun?: Date;
nextRun?: Date;
}
@@ -60,6 +61,7 @@ export interface GitHubConfig {
username: string;
token: string;
privateRepositories: boolean;
includeCollaboratorRepos?: boolean;
mirrorStarred: boolean;
starredLists?: string[];
starredDuplicateStrategy?: DuplicateNameStrategy;
+1 -1
View File
@@ -18,7 +18,7 @@
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"astro": "^6.0.4",
"astro": "^6.1.6",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+389 -326
View File
File diff suppressed because it is too large Load Diff