Compare commits

...

12 Commits

Author SHA1 Message Date
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
Arunavo Ray c1712bc670 chore: bump version to 3.15.3 2026-04-20 13:03:28 +05:30
ARUNAVO RAY c4550196e9 fix: honor GH_API_URL across all Octokit call sites (#269) (#273)
* fix: honor GH_API_URL across all Octokit call sites

Six Octokit call sites constructed `new Octokit(...)` directly instead of
going through `createGitHubClient()`, so `GH_API_URL` (and the
`GITHUB_API_URL` fallback) only applied to the handful of flows that used
the helper. For GHES / GHEC-with-data-residency users this surfaced most
visibly as the "Test Connection" button hitting `api.github.com/user`
and failing with 401 even when `GH_API_URL` was set correctly (#269).

Route everything through `createGitHubClient()`:
- src/pages/api/github/test-connection.ts (the reported failure)
- src/pages/api/sync/repository.ts (public-repo sync)
- src/lib/gitea-enhanced.ts (force-push detection + metadata octokit)
- src/lib/scheduler-service.ts (auto-discovery, auto-mirror, auto-start)
- src/tests/test-metadata-mirroring.ts (dev harness, for consistency)

Side benefit: scheduler + sync paths now also get throttling, rate-limit
tracking, and the standard User-Agent, which they were missing.

`createGitHubClient`'s `token` parameter is made optional so the
public-repo sync path (`new Octokit()` with no auth) can keep working.

Fixes #269

* fix: address review findings

- scheduler: pass config.githubConfig?.owner (the real DB field) instead
  of ?.username, which doesn't exist on the DB row and was silently
  resolving to undefined — matches every other DB-reading call site.
- sync/repository.ts: revert to bare Octokit for the unauthenticated
  public-repo lookup to preserve fast-fail on the 60 req/hr limit.
  Still reads GH_API_URL / GITHUB_API_URL inline so GHES / GHEC
  data-residency users benefit. The throttling plugin's retry-with-
  backoff is wrong UX for a one-shot button click.
- github.ts: revert createGitHubClient token back to required (no
  remaining callers pass undefined after the above).
- gitea-enhanced.ts: make the leftover Octokit import type-only.
- test-connection.test.ts: replace mid-test mock.module re-call with a
  mutable stub reference — safer against ESM live-binding semantics.
2026-04-20 13:02:34 +05:30
Arunavo Ray 8cb8fd6fe1 docs: document GH_API_URL for GitHub Enterprise and SERVER_CERT_PATH/SERVER_KEY_PATH for native HTTPS
- README + env reference + .env.example now cover using GH_API_URL to
  target GitHub Enterprise Server or GHEC with data residency.
- Env reference + .env.example now cover SERVER_CERT_PATH and
  SERVER_KEY_PATH, which @astrojs/node reads at runtime to terminate
  TLS directly without a reverse proxy.

Closes #269
Closes #272
2026-04-20 09:30:08 +05:30
38 changed files with 903 additions and 499 deletions
+14
View File
@@ -46,6 +46,14 @@ BETTER_AUTH_URL=http://localhost:4321
PUBLIC_BETTER_AUTH_URL=http://localhost:4321
# BETTER_AUTH_TRUSTED_ORIGINS=
# ===========================================
# HTTPS / TLS (Optional)
# ===========================================
# Set BOTH to have the server terminate TLS directly (no reverse proxy needed).
# Leave unset when TLS is handled upstream by Nginx/Traefik/Caddy.
# SERVER_CERT_PATH=/etc/ssl/gitea-mirror/cert.pem
# SERVER_KEY_PATH=/etc/ssl/gitea-mirror/key.pem
# ===========================================
# DOCKER CONFIGURATION (Optional)
# ===========================================
@@ -65,6 +73,12 @@ DOCKER_TAG=latest
# GITHUB_TOKEN=your-github-personal-access-token
# GITHUB_TYPE=personal # Options: personal, organization
# GitHub Enterprise (GHES / GHEC with data residency)
# Leave unset for standard github.com. Examples:
# GHES (self-hosted): https://ghe.example.com/api/v3
# GHEC data residency: https://api.TENANT.ghe.com
# GH_API_URL=https://ghe.example.com/api/v3
# Repository Selection
# PRIVATE_REPOSITORIES=false
# PUBLIC_REPOSITORIES=true
+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: |
+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
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.4
FROM oven/bun:1.3.12-debian AS base
FROM oven/bun:1.3.13-debian 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.13-debian 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 \
+15
View File
@@ -29,6 +29,7 @@ First user signup becomes admin. Configure GitHub and Gitea/Forgejo through the
## ✨ Features
- 🔁 Mirror public, private, and starred GitHub repos to Gitea/Forgejo
- 🏛️ **GitHub Enterprise support** - Works with GHES and GHEC with data residency via `GH_API_URL`
- 🏢 Mirror entire organizations with flexible strategies
- 🎯 Custom destination control for repos and organizations
- 📦 **Git LFS support** - Mirror large files with Git LFS
@@ -296,6 +297,20 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes
- **The Whole Point of Backups**: Your Gitea/Forgejo mirrors are preserved even when GitHub sources disappear - that's why you have backups!
- **Strongly Recommended**: Always use `CLEANUP_ORPHANED_REPO_ACTION=archive` (default) instead of `delete`
### GitHub Enterprise (GHES / GHEC with Data Residency)
Gitea Mirror works with non-`github.com` GitHub deployments. Point the client at your Enterprise API via the `GH_API_URL` environment variable:
```bash
# GitHub Enterprise Server (self-hosted)
GH_API_URL=https://ghe.example.com/api/v3
# GitHub Enterprise Cloud with data residency
GH_API_URL=https://api.TENANT.ghe.com
```
Standard GitHub Enterprise Cloud on `github.com` needs no override. Use a token issued by the target Enterprise instance for `GITHUB_TOKEN`.
## Troubleshooting
### Reverse Proxy Configuration
+40
View File
@@ -16,6 +16,7 @@ When environment variables are set:
## Table of Contents
- [Core Configuration](#core-configuration)
- [HTTPS / TLS](#https--tls)
- [GitHub Configuration](#github-configuration)
- [Gitea Configuration](#gitea-configuration)
- [Mirror Options](#mirror-options)
@@ -41,6 +42,30 @@ Essential application settings required for running Gitea Mirror.
| `BETTER_AUTH_TRUSTED_ORIGINS` | Trusted origins for authentication requests. Comma-separated list of URLs. Use this to specify additional access URLs (e.g., local IP + domain: `http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld`), SSO providers, reverse proxies, etc. | - | No |
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
## HTTPS / TLS
Gitea Mirror can terminate TLS directly via the underlying `@astrojs/node` adapter — useful when you don't want a separate reverse proxy. When both variables below are set, the server starts as a real HTTPS listener instead of HTTP.
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `SERVER_CERT_PATH` | Absolute path to the TLS certificate (PEM). Set together with `SERVER_KEY_PATH` to enable HTTPS. | - | No |
| `SERVER_KEY_PATH` | Absolute path to the TLS private key (PEM). Set together with `SERVER_CERT_PATH` to enable HTTPS. | - | No |
**Example (systemd or `.env`):**
```bash
SERVER_CERT_PATH=/etc/ssl/gitea-mirror/cert.pem
SERVER_KEY_PATH=/etc/ssl/gitea-mirror/key.pem
PORT=443
BETTER_AUTH_URL=https://mirror.example.com
BETTER_AUTH_TRUSTED_ORIGINS=https://mirror.example.com
```
Notes:
- The process must have read access to both files. When binding to `PORT=443`, grant the binary the `CAP_NET_BIND_SERVICE` capability (or run as a user allowed to bind privileged ports) rather than running as root.
- If you already terminate TLS at a reverse proxy (nginx, Traefik, Caddy), leave these unset and let the proxy handle certificates.
- Works in Docker too — mount your certs and set both paths to locations inside the container.
## GitHub Configuration
Settings for connecting to and configuring GitHub repository sources.
@@ -52,6 +77,21 @@ Settings for connecting to and configuring GitHub repository sources.
| `GITHUB_USERNAME` | Your GitHub username | - | - |
| `GITHUB_TOKEN` | GitHub personal access token (requires repo and admin:org scopes) | - | - |
| `GITHUB_TYPE` | GitHub account type | `personal` | `personal`, `organization` |
| `GH_API_URL` | GitHub API base URL. Override this to point at GitHub Enterprise Server or Enterprise Cloud with data residency. | `https://api.github.com` | e.g. `https://ghe.example.com/api/v3`, `https://api.TENANT.ghe.com` |
### GitHub Enterprise (GHES / GHEC with data residency)
Set `GH_API_URL` to point Octokit at a non-`github.com` API endpoint:
```bash
# GitHub Enterprise Server (self-hosted)
GH_API_URL=https://ghe.example.com/api/v3
# GitHub Enterprise Cloud with data residency
GH_API_URL=https://api.TENANT.ghe.com
```
Standard GitHub Enterprise Cloud on `github.com` works with the default — no override needed. Use a personal access token issued by the target Enterprise instance for `GITHUB_TOKEN`.
### Repository Selection
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.15.2",
"version": "3.15.7",
"engines": {
"bun": ">=1.2.9"
},
+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"
+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
+8 -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';
@@ -224,10 +224,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 +239,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
+4 -5
View File
@@ -8,7 +8,8 @@
import type { Config } from "@/types/config";
import type { Repository } from "./db/schema";
import { Octokit } from "@octokit/rest";
import type { Octokit } from "@octokit/rest";
import { createGitHubClient } from "./github";
import { createMirrorJob } from "./helpers";
import { decryptConfigTokens } from "./utils/config-encryption";
import { httpPost, httpGet, httpPatch, HttpError } from "./http-client";
@@ -431,7 +432,7 @@ export async function syncGiteaRepoEnhanced({
try {
const decryptedGithubToken = decryptedConfig.githubConfig?.token;
if (decryptedGithubToken) {
const fpOctokit = new Octokit({ auth: decryptedGithubToken });
const fpOctokit = createGitHubClient(decryptedGithubToken);
const detectionResult = await detectForcePush({
giteaUrl: config.giteaConfig.url,
giteaToken: decryptedConfig.giteaConfig.token,
@@ -596,9 +597,7 @@ export async function syncGiteaRepoEnhanced({
if (!decryptedConfig.githubConfig?.token) {
return null;
}
metadataOctokit = new Octokit({
auth: decryptedConfig.githubConfig.token,
});
metadataOctokit = createGitHubClient(decryptedConfig.githubConfig.token);
return metadataOctokit;
};
@@ -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");
});
});
+8 -6
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,
@@ -1321,6 +1322,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 +1532,6 @@ export async function mirrorGitHubRepoToGiteaOrg({
);
}
let migrateSucceeded = false;
const migrateRes = await httpPost(
apiUrl,
migratePayload,
+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) {
+13 -15
View File
@@ -99,15 +99,14 @@ async function runScheduledSync(config: any): Promise<void> {
if (scheduleConfig.autoImport !== false) {
console.log(`[Scheduler] Checking for new GitHub repositories for user ${userId}...`);
try {
const { getGithubRepositories, getGithubStarredRepositories } = await import('@/lib/github');
const { getGithubRepositories, getGithubStarredRepositories, createGitHubClient } = await import('@/lib/github');
const { v4: uuidv4 } = await import('uuid');
const { getDecryptedGitHubToken } = await import('@/lib/utils/config-encryption');
// Create GitHub client
// Create GitHub client (honors GH_API_URL for GHES / GHEC data residency)
const decryptedToken = getDecryptedGitHubToken(config);
const { Octokit } = await import('@octokit/rest');
const octokit = new Octokit({ auth: decryptedToken });
const octokit = createGitHubClient(decryptedToken, userId, config.githubConfig?.owner);
// Fetch GitHub data
const [basicAndForkedRepos, starredRepos] = await Promise.all([
getGithubRepositories({ octokit, config }),
@@ -117,7 +116,7 @@ async function runScheduledSync(config: any): Promise<void> {
]);
const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos);
const mirrorableGithubRepos = allGithubRepos.filter(isMirrorableGitHubRepo);
// Check for new repositories
const existingRepos = await db
.select({ normalizedFullName: repositories.normalizedFullName })
@@ -238,10 +237,10 @@ async function runScheduledSync(config: any): Promise<void> {
if (reposNeedingMirror.length > 0) {
console.log(`[Scheduler] Found ${reposNeedingMirror.length} repositories that need initial mirroring`);
// Prepare Octokit client
// Prepare Octokit client (honors GH_API_URL for GHES / GHEC data residency)
const decryptedToken = getDecryptedGitHubToken(config);
const { Octokit } = await import('@octokit/rest');
const octokit = new Octokit({ auth: decryptedToken });
const { createGitHubClient } = await import('@/lib/github');
const octokit = createGitHubClient(decryptedToken, userId, config.githubConfig?.owner);
// Process repositories in batches
const batchSize = scheduleConfig.batchSize || 10;
@@ -482,13 +481,12 @@ async function performInitialAutoStart(): Promise<void> {
try {
// Step 1: Import repositories from GitHub
console.log(`[Scheduler] Step 1: Importing repositories from GitHub for user ${config.userId}...`);
const { getGithubRepositories, getGithubStarredRepositories } = await import('@/lib/github');
const { getGithubRepositories, getGithubStarredRepositories, createGitHubClient } = await import('@/lib/github');
const { v4: uuidv4 } = await import('uuid');
// Create GitHub client
// Create GitHub client (honors GH_API_URL for GHES / GHEC data residency)
const decryptedToken = getDecryptedGitHubToken(config);
const { Octokit } = await import('@octokit/rest');
const octokit = new Octokit({ auth: decryptedToken });
const octokit = createGitHubClient(decryptedToken, config.userId, config.githubConfig?.owner);
// Fetch GitHub data
const [basicAndForkedRepos, starredRepos] = await Promise.all([
+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) {
+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) {
+46 -44
View File
@@ -1,39 +1,51 @@
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
import { POST } from "./test-connection";
import { Octokit } from "@octokit/rest";
// Mock the Octokit class
mock.module("@octokit/rest", () => {
// createGitHubClient returns this stub. Tests mutate `getAuthenticatedImpl`
// to steer the behavior without re-calling mock.module (which is fragile
// once the route module has already captured a live binding).
let getAuthenticatedImpl: () => Promise<any> = () =>
Promise.resolve({
data: {
login: "testuser",
name: "Test User",
avatar_url: "https://example.com/avatar.png",
},
});
mock.module("@/lib/github", () => {
return {
Octokit: mock(function() {
return {
users: {
getAuthenticated: mock(() => Promise.resolve({
data: {
login: "testuser",
name: "Test User",
avatar_url: "https://example.com/avatar.png"
}
}))
}
};
})
createGitHubClient: mock(() => ({
users: {
getAuthenticated: mock(() => getAuthenticatedImpl()),
},
})),
};
});
import { POST } from "./test-connection";
describe("GitHub Test Connection API", () => {
// Mock console.error to prevent test output noise
let originalConsoleError: typeof console.error;
beforeEach(() => {
originalConsoleError = console.error;
console.error = mock(() => {});
// Reset to the success stub before each test so tests are independent
getAuthenticatedImpl = () =>
Promise.resolve({
data: {
login: "testuser",
name: "Test User",
avatar_url: "https://example.com/avatar.png",
},
});
});
afterEach(() => {
console.error = originalConsoleError;
});
test("returns 400 if token is missing", async () => {
const request = new Request("http://localhost/api/github/test-connection", {
method: "POST",
@@ -42,16 +54,16 @@ describe("GitHub Test Connection API", () => {
},
body: JSON.stringify({})
});
const response = await POST({ request } as any);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toBe("GitHub token is required");
});
test("returns 200 with user data on successful connection", async () => {
const request = new Request("http://localhost/api/github/test-connection", {
method: "POST",
@@ -62,11 +74,11 @@ describe("GitHub Test Connection API", () => {
token: "valid-token"
})
});
const response = await POST({ request } as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.message).toBe("Successfully connected to GitHub as testuser");
@@ -76,7 +88,7 @@ describe("GitHub Test Connection API", () => {
avatar_url: "https://example.com/avatar.png"
});
});
test("returns 400 if username doesn't match authenticated user", async () => {
const request = new Request("http://localhost/api/github/test-connection", {
method: "POST",
@@ -88,29 +100,19 @@ describe("GitHub Test Connection API", () => {
username: "differentuser"
})
});
const response = await POST({ request } as any);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toBe("Token belongs to testuser, not differentuser");
});
test("handles authentication errors", async () => {
// Mock Octokit to throw an error
mock.module("@octokit/rest", () => {
return {
Octokit: mock(function() {
return {
users: {
getAuthenticated: mock(() => Promise.reject(new Error("Bad credentials")))
}
};
})
};
});
// Swap the stub to throw an auth error for this test only
getAuthenticatedImpl = () => Promise.reject(new Error("Bad credentials"));
const request = new Request("http://localhost/api/github/test-connection", {
method: "POST",
+5 -5
View File
@@ -1,5 +1,5 @@
import type { APIRoute } from "astro";
import { Octokit } from "@octokit/rest";
import { createGitHubClient } from "@/lib/github";
import { createSecureErrorResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request }) => {
@@ -22,10 +22,10 @@ export const POST: APIRoute = async ({ request }) => {
);
}
// Create an Octokit instance with the provided token
const octokit = new Octokit({
auth: token,
});
// Create an Octokit instance with the provided token.
// Uses createGitHubClient so GH_API_URL / GITHUB_API_URL routes the call
// to the correct endpoint for GHES / GHEC with data residency.
const octokit = createGitHubClient(token);
// Test the connection by fetching the authenticated user
const { data } = await octokit.users.getAuthenticated();
+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) {
+14 -3
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) {
@@ -88,7 +90,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
const configId = config.id;
const octokit = new Octokit(); // No auth for public repos
// Unauthenticated one-shot lookup for public repos.
// Uses bare Octokit (not createGitHubClient) to preserve fast-fail on the
// 60 req/hr public rate limit — this endpoint is user-facing, we don't
// want the throttling plugin to wait multiple retry-after windows.
// Still respects GH_API_URL / GITHUB_API_URL for GHES / GHEC data residency.
const baseUrl =
process.env.GH_API_URL ||
process.env.GITHUB_API_URL ||
"https://api.github.com";
const octokit = new Octokit({ baseUrl });
const { data: repoData } = await octokit.rest.repos.get({
owner: trimmedOwner,
+3 -5
View File
@@ -11,7 +11,7 @@ import { validateGiteaAuth } from "@/lib/gitea-auth-validator";
import { getConfigsByUserId } from "@/lib/db/queries/configs";
import { db, users, repositories } from "@/lib/db";
import { eq } from "drizzle-orm";
import { Octokit } from "@octokit/rest";
import { createGitHubClient } from "@/lib/github";
import type { Repository } from "@/lib/db/schema";
async function testMetadataMirroringAuth() {
@@ -108,10 +108,8 @@ async function testMetadataMirroringAuth() {
console.log("\n🔄 Test 4: Testing metadata mirroring authentication...");
try {
// Create Octokit instance
const octokit = new Octokit({
auth: config.githubConfig.token,
});
// Create Octokit instance (honors GH_API_URL for GHES / GHEC data residency)
const octokit = createGitHubClient(config.githubConfig.token);
// Test by attempting to fetch labels (lightweight operation)
const { httpGet } = await import("@/lib/http-client");
+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