-
+
+
+
+
+
+
+
+ {/* CSV export of every tracked repository */}
+
+
+
+ Export CSV
+
+
+
{/* Mirror All action */}
{
+ test("empty for null and undefined", () => {
+ expect(formatCsvValue(null)).toBe("");
+ expect(formatCsvValue(undefined)).toBe("");
+ });
+
+ test("dates become ISO strings", () => {
+ expect(formatCsvValue(new Date("2026-09-22T08:30:00.000Z"))).toBe(
+ "2026-09-22T08:30:00.000Z"
+ );
+ });
+
+ test("an invalid date is empty rather than 'Invalid Date'", () => {
+ expect(formatCsvValue(new Date("nope"))).toBe("");
+ });
+
+ test("booleans become true and false", () => {
+ expect(formatCsvValue(true)).toBe("true");
+ expect(formatCsvValue(false)).toBe("false");
+ });
+
+ test("numbers keep their text form, including zero", () => {
+ expect(formatCsvValue(0)).toBe("0");
+ expect(formatCsvValue(42)).toBe("42");
+ });
+
+ test("objects are stringified as JSON", () => {
+ expect(formatCsvValue({ a: 1 })).toBe('{"a":1}');
+ });
+});
+
+describe("escapeCsvValue", () => {
+ test("leaves a plain value alone", () => {
+ expect(escapeCsvValue("hello-world")).toBe("hello-world");
+ });
+
+ test("quotes a value with a comma", () => {
+ expect(escapeCsvValue("one, two")).toBe('"one, two"');
+ });
+
+ test("quotes a value with a quote and doubles the quote", () => {
+ expect(escapeCsvValue('say "hi"')).toBe('"say ""hi"""');
+ });
+
+ test("a value that is only quotes doubles every one of them", () => {
+ expect(escapeCsvValue('"""')).toBe('""""""""');
+ });
+
+ test("quotes a value with a line feed", () => {
+ expect(escapeCsvValue("first\nsecond")).toBe('"first\nsecond"');
+ });
+
+ test("quotes a value with a carriage return", () => {
+ expect(escapeCsvValue("first\r\nsecond")).toBe('"first\r\nsecond"');
+ });
+
+ test("keeps leading and trailing spaces without quoting", () => {
+ expect(escapeCsvValue(" padded ")).toBe(" padded ");
+ });
+});
+
+describe("toCsv", () => {
+ type Row = {
+ name: string;
+ description: string | null;
+ isPrivate: boolean;
+ size: number;
+ lastMirrored: Date | null;
+ };
+
+ const columns = [
+ "name",
+ "description",
+ "isPrivate",
+ "size",
+ "lastMirrored",
+ ] as const;
+
+ test("writes a header row from the column keys", () => {
+ const csv = toCsv([], columns);
+ expect(csv).toBe("name,description,isPrivate,size,lastMirrored\r\n");
+ });
+
+ test("writes one CRLF terminated line per row", () => {
+ const csv = toCsv(
+ [
+ {
+ name: "hello-world",
+ description: "A greeting",
+ isPrivate: false,
+ size: 12,
+ lastMirrored: new Date("2026-09-22T08:30:00.000Z"),
+ },
+ {
+ name: "second",
+ description: null,
+ isPrivate: true,
+ size: 0,
+ lastMirrored: null,
+ },
+ ],
+ columns
+ );
+
+ expect(csv).toBe(
+ "name,description,isPrivate,size,lastMirrored\r\n" +
+ "hello-world,A greeting,false,12,2026-09-22T08:30:00.000Z\r\n" +
+ "second,,true,0,\r\n"
+ );
+ });
+
+ test("escapes values that hold commas, quotes and newlines", () => {
+ const csv = toCsv(
+ [
+ {
+ name: "tricky",
+ description: 'Uses "quotes", commas\nand a newline',
+ isPrivate: false,
+ size: 1,
+ lastMirrored: null,
+ },
+ ],
+ columns
+ );
+
+ expect(csv.split("\r\n")[1]).toBe(
+ 'tricky,"Uses ""quotes"", commas\nand a newline",false,1,'
+ );
+ });
+
+ test("escapes a header that needs quoting too", () => {
+ const csv = toCsv<{ "size, bytes": number }>([{ "size, bytes": 3 }], [
+ "size, bytes",
+ ]);
+ expect(csv).toBe('"size, bytes"\r\n3\r\n');
+ });
+
+ test("a missing key on a row is an empty field", () => {
+ const rows = [{ name: "partial" }] as unknown as Row[];
+ const csv = toCsv(rows, columns);
+ expect(csv.split("\r\n")[1]).toBe("partial,,,,");
+ });
+
+ test("only the listed columns are written, in the listed order", () => {
+ const rows = [
+ { name: "a", secret: "token", description: "b" },
+ ] as unknown as Row[];
+ const csv = toCsv(rows, ["description", "name"]);
+ expect(csv).toBe("description,name\r\nb,a\r\n");
+ expect(csv).not.toContain("token");
+ });
+});
+
+describe("formula injection guard", () => {
+ test("prefixes strings a spreadsheet would run as a formula", () => {
+ expect(formatCsvValue("=1+1")).toBe("'=1+1");
+ expect(formatCsvValue("+SUM(A1)")).toBe("'+SUM(A1)");
+ expect(formatCsvValue("-2+3")).toBe("'-2+3");
+ expect(formatCsvValue("@cmd")).toBe("'@cmd");
+ expect(formatCsvValue("\t=1")).toBe("'\t=1");
+ });
+
+ test("leaves numbers, booleans and ordinary text alone", () => {
+ expect(formatCsvValue(-5)).toBe("-5");
+ expect(formatCsvValue(true)).toBe("true");
+ expect(formatCsvValue("a - b")).toBe("a - b");
+ expect(formatCsvValue("main")).toBe("main");
+ });
+
+ test("still quotes a guarded value that needs quoting", () => {
+ expect(escapeCsvValue('=HYPERLINK("x")')).toBe('"\'=HYPERLINK(""x"")"');
+ });
+});
diff --git a/src/lib/utils/csv.ts b/src/lib/utils/csv.ts
new file mode 100644
index 0000000..97811d4
--- /dev/null
+++ b/src/lib/utils/csv.ts
@@ -0,0 +1,76 @@
+/**
+ * Minimal CSV builder used by the repository and organization exports.
+ *
+ * Follows RFC 4180: a header row, CRLF line endings, and fields quoted when
+ * they hold a comma, a double quote, a carriage return or a line feed, with
+ * inner quotes doubled.
+ */
+
+/** Fields that need quoting per RFC 4180. */
+const NEEDS_QUOTING = /[",\r\n]/;
+
+/**
+ * Text that a spreadsheet would run as a formula. Repository descriptions
+ * and error messages come from third parties, so a value starting with one
+ * of these is prefixed with a single quote, which Excel and Sheets treat as
+ * "this is text". Only strings are affected; numbers stay as they are.
+ */
+const FORMULA_PREFIX = /^[=+\-@\t\r]/;
+
+/**
+ * Turn one value into its CSV text, before any quoting:
+ * null and undefined become empty, dates become ISO strings, booleans become
+ * "true" or "false", and anything else is stringified.
+ */
+export function formatCsvValue(value: unknown): string {
+ if (value === null || value === undefined) {
+ return "";
+ }
+
+ if (value instanceof Date) {
+ return Number.isNaN(value.getTime()) ? "" : value.toISOString();
+ }
+
+ if (typeof value === "boolean") {
+ return value ? "true" : "false";
+ }
+
+ if (typeof value === "object") {
+ return JSON.stringify(value) ?? "";
+ }
+
+ if (typeof value === "string" && FORMULA_PREFIX.test(value)) {
+ return `'${value}`;
+ }
+
+ return String(value);
+}
+
+/** Format a value and quote it when the content requires it. */
+export function escapeCsvValue(value: unknown): string {
+ const text = formatCsvValue(value);
+
+ if (!NEEDS_QUOTING.test(text)) {
+ return text;
+ }
+
+ return `"${text.replace(/"/g, '""')}"`;
+}
+
+/**
+ * Build a CSV document from rows and the column keys to export. The keys
+ * double as the header row, so the caller controls both the selection and the
+ * column order.
+ */
+export function toCsv(
+ rows: readonly Row[],
+ columns: readonly (keyof Row & string)[]
+): string {
+ const lines: string[] = [columns.map(escapeCsvValue).join(",")];
+
+ for (const row of rows) {
+ lines.push(columns.map((column) => escapeCsvValue(row[column])).join(","));
+ }
+
+ return `${lines.join("\r\n")}\r\n`;
+}
diff --git a/src/pages/api/organizations/export.test.ts b/src/pages/api/organizations/export.test.ts
new file mode 100644
index 0000000..e849586
--- /dev/null
+++ b/src/pages/api/organizations/export.test.ts
@@ -0,0 +1,223 @@
+/**
+ * Route tests for GET /api/organizations/export: the auth guard, the header
+ * row, RFC 4180 escaping of an error message and scoping to the signed in
+ * user (#428).
+ *
+ * Runs against the real schema in an in-memory SQLite database. The route
+ * imports @/lib/db, which is replaced with that database through
+ * mock.module; bun's mock.module is process-wide and leaks into other test
+ * files, so this file registers nothing in the shared process and re-runs
+ * itself in an isolated child (same harness as ./[id]/status.test.ts).
+ */
+import { describe, test, expect, mock, beforeEach } from "bun:test";
+import { Database } from "bun:sqlite";
+import { drizzle } from "drizzle-orm/bun-sqlite";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import * as schema from "@/lib/db/schema";
+
+const CHILD_FLAG = "GM_ORGANIZATIONS_EXPORT_ROUTE_ISOLATED";
+const isChild = !!process.env[CHILD_FLAG];
+
+if (!isChild) {
+ test("organizations CSV export route - isolated child suite", () => {
+ const res = Bun.spawnSync({
+ cmd: [process.execPath, "test", import.meta.path],
+ env: { ...process.env, [CHILD_FLAG]: "1" },
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ if (res.exitCode !== 0) {
+ console.error(res.stdout.toString());
+ console.error(res.stderr.toString());
+ }
+ expect(res.exitCode).toBe(0);
+ }, 60_000);
+}
+
+const sqlite = new Database(":memory:");
+const db = drizzle({ client: sqlite });
+
+/** Apply every migration in drizzle/ in journal order, the way the migrator would. */
+function applyMigrations(): void {
+ const folder = join(process.cwd(), "drizzle");
+ const journal = JSON.parse(readFileSync(join(folder, "meta", "_journal.json"), "utf8")) as {
+ entries: { tag: string }[];
+ };
+ for (const entry of journal.entries) {
+ const sql = readFileSync(join(folder, `${entry.tag}.sql`), "utf8");
+ for (const statement of sql.split("--> statement-breakpoint")) {
+ if (statement.trim()) sqlite.run(statement);
+ }
+ }
+}
+
+/** Flipped by the unauthenticated test. */
+let authenticatedUserId: string | null = "user-1";
+
+if (isChild) {
+ applyMigrations();
+ // The seed below only needs the organizations table.
+ sqlite.run("PRAGMA foreign_keys = OFF");
+
+ mock.module("@/lib/auth-guards", () => ({
+ requireAuthenticatedUserId: mock(async () =>
+ authenticatedUserId
+ ? { userId: authenticatedUserId }
+ : {
+ response: new Response(
+ JSON.stringify({ success: false, error: "Unauthorized" }),
+ { status: 401, headers: { "Content-Type": "application/json" } }
+ ),
+ }
+ ),
+ }));
+
+ mock.module("@/lib/db", () => ({ ...schema, db }));
+}
+
+const routeModule = isChild
+ ? await import("./export")
+ : ({} as Partial);
+const GET = routeModule.GET as NonNullable;
+const ORGANIZATION_EXPORT_COLUMNS = routeModule.ORGANIZATION_EXPORT_COLUMNS ?? [];
+
+const EXPECTED_HEADER =
+ "name,membershipRole,isIncluded,destinationOrg,status,repositoryCount," +
+ "publicRepositoryCount,privateRepositoryCount,forkRepositoryCount," +
+ "lastMirrored,errorMessage,createdAt,updatedAt";
+
+const CREATED_AT = 1_690_000_000;
+const MIRRORED_AT = 1_700_000_000;
+
+function iso(seconds: number): string {
+ return new Date(seconds * 1000).toISOString();
+}
+
+function get() {
+ return GET({
+ params: {},
+ request: new Request("http://localhost/api/organizations/export"),
+ locals: { session: { userId: "user-1" } },
+ } as any);
+}
+
+type OrgSeed = {
+ id: string;
+ userId?: string;
+ name: string;
+ errorMessage?: string | null;
+};
+
+function seedOrg({ id, userId = "user-1", name, errorMessage = null }: OrgSeed) {
+ sqlite.run(
+ `INSERT INTO organizations (
+ id, user_id, config_id, name, normalized_name, avatar_url, membership_role,
+ is_included, destination_org, status, repository_count, public_repository_count,
+ private_repository_count, fork_repository_count, last_mirrored, error_message,
+ created_at, updated_at
+ ) VALUES (?, ?, 'config-1', ?, ?, 'https://avatars.example.com/a.png', 'admin',
+ 1, 'mirrors', 'mirrored', 7, 5,
+ 2, 1, ?, ?,
+ ?, ?)`,
+ [
+ id,
+ userId,
+ name,
+ name.toLowerCase(),
+ MIRRORED_AT,
+ errorMessage,
+ CREATED_AT,
+ MIRRORED_AT,
+ ]
+ );
+}
+
+beforeEach(() => {
+ if (!isChild) return;
+ authenticatedUserId = "user-1";
+ sqlite.run("DELETE FROM organizations");
+});
+
+describe.skipIf(!isChild)("GET /api/organizations/export", () => {
+ test("answers 401 when the request is not authenticated", async () => {
+ authenticatedUserId = null;
+ const response = await get();
+ expect(response.status).toBe(401);
+ expect(response.headers.get("Content-Type")).toBe("application/json");
+ expect(await response.json()).toEqual({ success: false, error: "Unauthorized" });
+ });
+
+ test("sends a CSV attachment named after today", async () => {
+ const response = await get();
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Content-Type")).toBe("text/csv; charset=utf-8");
+ const today = new Date().toISOString().slice(0, 10);
+ expect(response.headers.get("Content-Disposition")).toBe(
+ `attachment; filename="gitea-mirror-organizations-${today}.csv"`
+ );
+ });
+
+ test("writes the header row and no internal fields", async () => {
+ const response = await get();
+ const csv = await response.text();
+ expect(csv.split("\r\n")[0]).toBe(EXPECTED_HEADER);
+ expect(ORGANIZATION_EXPORT_COLUMNS).not.toContain("id" as never);
+ expect(ORGANIZATION_EXPORT_COLUMNS).not.toContain("userId" as never);
+ expect(ORGANIZATION_EXPORT_COLUMNS).not.toContain("configId" as never);
+ expect(ORGANIZATION_EXPORT_COLUMNS).not.toContain("sourceId" as never);
+ expect(ORGANIZATION_EXPORT_COLUMNS).not.toContain("mirrorOverrides" as never);
+ });
+
+ test("writes one row per organization with dates, booleans and counts", async () => {
+ seedOrg({ id: "org-acme", name: "acme" });
+
+ const response = await get();
+ const csv = await response.text();
+
+ expect(csv.split("\r\n")[1]).toBe(
+ [
+ "acme",
+ "admin",
+ "true",
+ "mirrors",
+ "mirrored",
+ "7",
+ "5",
+ "2",
+ "1",
+ iso(MIRRORED_AT),
+ "",
+ iso(CREATED_AT),
+ iso(MIRRORED_AT),
+ ].join(",")
+ );
+ });
+
+ test("quotes an error message with commas, quotes and a newline", async () => {
+ seedOrg({
+ id: "org-acme",
+ name: "acme",
+ errorMessage: 'Gitea said "no", twice\nand gave up',
+ });
+
+ const response = await get();
+ const csv = await response.text();
+ expect(csv).toContain('"Gitea said ""no"", twice\nand gave up"');
+ });
+
+ test("exports only the signed in user's organizations, ordered by name", async () => {
+ seedOrg({ id: "org-b", name: "beta" });
+ seedOrg({ id: "org-a", name: "Acme" });
+ seedOrg({ id: "org-theirs", userId: "user-2", name: "secret-org" });
+
+ const response = await get();
+ const csv = await response.text();
+ const lines = csv.split("\r\n").filter((line) => line.length > 0);
+
+ expect(lines).toHaveLength(3);
+ expect(lines[1].startsWith("Acme,")).toBe(true);
+ expect(lines[2].startsWith("beta,")).toBe(true);
+ expect(csv).not.toContain("secret-org");
+ });
+});
diff --git a/src/pages/api/organizations/export.ts b/src/pages/api/organizations/export.ts
new file mode 100644
index 0000000..e98cba7
--- /dev/null
+++ b/src/pages/api/organizations/export.ts
@@ -0,0 +1,59 @@
+import type { APIRoute } from "astro";
+import { eq, sql } from "drizzle-orm";
+import { db, organizations } from "@/lib/db";
+import { createSecureErrorResponse } from "@/lib/utils";
+import { requireAuthenticatedUserId } from "@/lib/auth-guards";
+import { toCsv } from "@/lib/utils/csv";
+
+/**
+ * The exported organization columns, in the order they appear in the file. It
+ * is the organizations table without the internal fields: the ids, the avatar
+ * URL and the mirror option overrides (#428).
+ */
+const organizationExportSelection = {
+ name: organizations.name,
+ membershipRole: organizations.membershipRole,
+ isIncluded: organizations.isIncluded,
+ destinationOrg: organizations.destinationOrg,
+ status: organizations.status,
+ repositoryCount: organizations.repositoryCount,
+ publicRepositoryCount: organizations.publicRepositoryCount,
+ privateRepositoryCount: organizations.privateRepositoryCount,
+ forkRepositoryCount: organizations.forkRepositoryCount,
+ lastMirrored: organizations.lastMirrored,
+ errorMessage: organizations.errorMessage,
+ createdAt: organizations.createdAt,
+ updatedAt: organizations.updatedAt,
+};
+
+export const ORGANIZATION_EXPORT_COLUMNS = Object.keys(
+ organizationExportSelection
+) as (keyof typeof organizationExportSelection)[];
+
+export function organizationExportFilename(now: Date): string {
+ return `gitea-mirror-organizations-${now.toISOString().slice(0, 10)}.csv`;
+}
+
+export const GET: APIRoute = async ({ request, locals }) => {
+ try {
+ const authResult = await requireAuthenticatedUserId({ request, locals });
+ if ("response" in authResult) return authResult.response;
+
+ const rows = await db
+ .select(organizationExportSelection)
+ .from(organizations)
+ .where(eq(organizations.userId, authResult.userId))
+ .orderBy(sql`${organizations.name} COLLATE NOCASE`);
+
+ return new Response(toCsv(rows, ORGANIZATION_EXPORT_COLUMNS), {
+ status: 200,
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition": `attachment; filename="${organizationExportFilename(new Date())}"`,
+ "Cache-Control": "no-store",
+ },
+ });
+ } catch (error) {
+ return createSecureErrorResponse(error, "organizations CSV export", 500);
+ }
+};
diff --git a/src/pages/api/repositories/export.test.ts b/src/pages/api/repositories/export.test.ts
new file mode 100644
index 0000000..897fca0
--- /dev/null
+++ b/src/pages/api/repositories/export.test.ts
@@ -0,0 +1,274 @@
+/**
+ * Route tests for GET /api/repositories/export: the auth guard, the header
+ * row, RFC 4180 escaping of a description and scoping to the signed in user
+ * (#428).
+ *
+ * Runs against the real schema in an in-memory SQLite database. The route
+ * imports @/lib/db, which is replaced with that database through
+ * mock.module; bun's mock.module is process-wide and leaks into other test
+ * files, so this file registers nothing in the shared process and re-runs
+ * itself in an isolated child (same harness as ../organizations/[id]/status.test.ts).
+ */
+import { describe, test, expect, mock, beforeEach } from "bun:test";
+import { Database } from "bun:sqlite";
+import { drizzle } from "drizzle-orm/bun-sqlite";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import * as schema from "@/lib/db/schema";
+
+const CHILD_FLAG = "GM_REPOSITORIES_EXPORT_ROUTE_ISOLATED";
+const isChild = !!process.env[CHILD_FLAG];
+
+if (!isChild) {
+ test("repositories CSV export route - isolated child suite", () => {
+ const res = Bun.spawnSync({
+ cmd: [process.execPath, "test", import.meta.path],
+ env: { ...process.env, [CHILD_FLAG]: "1" },
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ if (res.exitCode !== 0) {
+ console.error(res.stdout.toString());
+ console.error(res.stderr.toString());
+ }
+ expect(res.exitCode).toBe(0);
+ }, 60_000);
+}
+
+const sqlite = new Database(":memory:");
+const db = drizzle({ client: sqlite });
+
+/** Apply every migration in drizzle/ in journal order, the way the migrator would. */
+function applyMigrations(): void {
+ const folder = join(process.cwd(), "drizzle");
+ const journal = JSON.parse(readFileSync(join(folder, "meta", "_journal.json"), "utf8")) as {
+ entries: { tag: string }[];
+ };
+ for (const entry of journal.entries) {
+ const sql = readFileSync(join(folder, `${entry.tag}.sql`), "utf8");
+ for (const statement of sql.split("--> statement-breakpoint")) {
+ if (statement.trim()) sqlite.run(statement);
+ }
+ }
+}
+
+/** Flipped by the unauthenticated test. */
+let authenticatedUserId: string | null = "user-1";
+
+if (isChild) {
+ applyMigrations();
+ // The seed below only needs the repositories table.
+ sqlite.run("PRAGMA foreign_keys = OFF");
+
+ mock.module("@/lib/auth-guards", () => ({
+ requireAuthenticatedUserId: mock(async () =>
+ authenticatedUserId
+ ? { userId: authenticatedUserId }
+ : {
+ response: new Response(
+ JSON.stringify({ success: false, error: "Unauthorized" }),
+ { status: 401, headers: { "Content-Type": "application/json" } }
+ ),
+ }
+ ),
+ }));
+
+ mock.module("@/lib/db", () => ({ ...schema, db }));
+}
+
+const routeModule = isChild
+ ? await import("./export")
+ : ({} as Partial);
+const GET = routeModule.GET as NonNullable;
+const REPOSITORY_EXPORT_COLUMNS = routeModule.REPOSITORY_EXPORT_COLUMNS ?? [];
+
+const EXPECTED_HEADER =
+ "name,fullName,url,cloneUrl,owner,organization,sourceProvider,sourceUrl," +
+ "destinationProvider,destinationUrl,destinationOrg,mirroredLocation,visibility," +
+ "isPrivate,isForked,forkedFrom,isStarred,isArchived,hasLFS,hasSubmodules,hasIssues," +
+ "language,description,defaultBranch,size,status,lastMirrored,errorMessage," +
+ "importedAt,createdAt,updatedAt";
+
+const IMPORTED_AT = 1_690_000_000;
+const MIRRORED_AT = 1_700_000_000;
+
+function iso(seconds: number): string {
+ return new Date(seconds * 1000).toISOString();
+}
+
+function get() {
+ return GET({
+ params: {},
+ request: new Request("http://localhost/api/repositories/export"),
+ locals: { session: { userId: "user-1" } },
+ } as any);
+}
+
+type RepoSeed = {
+ id: string;
+ userId?: string;
+ name: string;
+ fullName: string;
+ organization?: string | null;
+ description?: string | null;
+};
+
+function seedRepo({
+ id,
+ userId = "user-1",
+ name,
+ fullName,
+ organization = null,
+ description = null,
+}: RepoSeed) {
+ const owner = fullName.split("/")[0];
+ sqlite.run(
+ `INSERT INTO repositories (
+ id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url,
+ source_provider, source_url, destination_provider, destination_url, destination_org,
+ owner, organization, mirrored_location, visibility, is_private, is_fork, forked_from,
+ is_starred, is_archived, has_lfs, has_issues, language, description, default_branch,
+ size, status, last_mirrored, error_message, imported_at, created_at, updated_at
+ ) VALUES (?, ?, 'config-1', ?, ?, ?, ?, ?,
+ 'github', 'https://github.com', 'gitea', 'https://gitea.example.com', 'mirrors',
+ ?, ?, ?, 'public', 0, 0, NULL,
+ 1, 0, 0, 1, 'TypeScript', ?, 'main',
+ 128, 'mirrored', ?, NULL, ?, ?, ?)`,
+ [
+ id,
+ userId,
+ name,
+ fullName,
+ fullName.toLowerCase(),
+ `https://github.com/${fullName}`,
+ `https://github.com/${fullName}.git`,
+ owner,
+ organization,
+ `mirrors/${name}`,
+ description,
+ MIRRORED_AT,
+ IMPORTED_AT,
+ IMPORTED_AT,
+ MIRRORED_AT,
+ ]
+ );
+}
+
+beforeEach(() => {
+ if (!isChild) return;
+ authenticatedUserId = "user-1";
+ sqlite.run("DELETE FROM repositories");
+});
+
+describe.skipIf(!isChild)("GET /api/repositories/export", () => {
+ test("answers 401 when the request is not authenticated", async () => {
+ authenticatedUserId = null;
+ const response = await get();
+ expect(response.status).toBe(401);
+ expect(response.headers.get("Content-Type")).toBe("application/json");
+ expect(await response.json()).toEqual({ success: false, error: "Unauthorized" });
+ });
+
+ test("sends a CSV attachment named after today", async () => {
+ const response = await get();
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Content-Type")).toBe("text/csv; charset=utf-8");
+ const today = new Date().toISOString().slice(0, 10);
+ expect(response.headers.get("Content-Disposition")).toBe(
+ `attachment; filename="gitea-mirror-repositories-${today}.csv"`
+ );
+ });
+
+ test("writes the header row and no internal fields", async () => {
+ const response = await get();
+ const csv = await response.text();
+ expect(csv.split("\r\n")[0]).toBe(EXPECTED_HEADER);
+ expect(REPOSITORY_EXPORT_COLUMNS).not.toContain("id" as never);
+ expect(REPOSITORY_EXPORT_COLUMNS).not.toContain("userId" as never);
+ expect(REPOSITORY_EXPORT_COLUMNS).not.toContain("configId" as never);
+ expect(REPOSITORY_EXPORT_COLUMNS).not.toContain("sourceId" as never);
+ expect(REPOSITORY_EXPORT_COLUMNS).not.toContain("metadata" as never);
+ expect(REPOSITORY_EXPORT_COLUMNS).not.toContain("mirrorOverrides" as never);
+ });
+
+ test("writes one row per repository with dates, booleans and blanks", async () => {
+ seedRepo({
+ id: "repo-1",
+ name: "hello-world",
+ fullName: "octocat/hello-world",
+ description: "A greeting",
+ });
+
+ const response = await get();
+ const csv = await response.text();
+
+ expect(csv.split("\r\n")[1]).toBe(
+ [
+ "hello-world",
+ "octocat/hello-world",
+ "https://github.com/octocat/hello-world",
+ "https://github.com/octocat/hello-world.git",
+ "octocat",
+ "",
+ "github",
+ "https://github.com",
+ "gitea",
+ "https://gitea.example.com",
+ "mirrors",
+ "mirrors/hello-world",
+ "public",
+ "false",
+ "false",
+ "",
+ "true",
+ "false",
+ "false",
+ "false",
+ "true",
+ "TypeScript",
+ "A greeting",
+ "main",
+ "128",
+ "mirrored",
+ iso(MIRRORED_AT),
+ "",
+ iso(IMPORTED_AT),
+ iso(IMPORTED_AT),
+ iso(MIRRORED_AT),
+ ].join(",")
+ );
+ });
+
+ test("quotes a description with commas, quotes and a newline", async () => {
+ seedRepo({
+ id: "repo-1",
+ name: "tricky",
+ fullName: "octocat/tricky",
+ description: 'Uses "quotes", commas\nand a newline',
+ });
+
+ const response = await get();
+ const csv = await response.text();
+ expect(csv).toContain('"Uses ""quotes"", commas\nand a newline"');
+ });
+
+ test("exports only the signed in user's repositories, ordered by full name", async () => {
+ seedRepo({ id: "repo-b", name: "beta", fullName: "octocat/beta" });
+ seedRepo({ id: "repo-a", name: "Alpha", fullName: "octocat/Alpha" });
+ seedRepo({
+ id: "repo-theirs",
+ userId: "user-2",
+ name: "secret",
+ fullName: "someone/secret",
+ });
+
+ const response = await get();
+ const csv = await response.text();
+ const lines = csv.split("\r\n").filter((line) => line.length > 0);
+
+ expect(lines).toHaveLength(3);
+ expect(lines[1].startsWith("Alpha,octocat/Alpha,")).toBe(true);
+ expect(lines[2].startsWith("beta,octocat/beta,")).toBe(true);
+ expect(csv).not.toContain("someone/secret");
+ });
+});
diff --git a/src/pages/api/repositories/export.ts b/src/pages/api/repositories/export.ts
new file mode 100644
index 0000000..823fc75
--- /dev/null
+++ b/src/pages/api/repositories/export.ts
@@ -0,0 +1,77 @@
+import type { APIRoute } from "astro";
+import { eq, sql } from "drizzle-orm";
+import { db, repositories } from "@/lib/db";
+import { createSecureErrorResponse } from "@/lib/utils";
+import { requireAuthenticatedUserId } from "@/lib/auth-guards";
+import { toCsv } from "@/lib/utils/csv";
+
+/**
+ * The exported repository columns, in the order they appear in the file. It
+ * is the repositories table without the internal fields: the ids, the
+ * metadata sync state and the mirror option overrides (#428).
+ */
+const repositoryExportSelection = {
+ name: repositories.name,
+ fullName: repositories.fullName,
+ url: repositories.url,
+ cloneUrl: repositories.cloneUrl,
+ owner: repositories.owner,
+ organization: repositories.organization,
+ sourceProvider: repositories.sourceProvider,
+ sourceUrl: repositories.sourceUrl,
+ destinationProvider: repositories.destinationProvider,
+ destinationUrl: repositories.destinationUrl,
+ destinationOrg: repositories.destinationOrg,
+ mirroredLocation: repositories.mirroredLocation,
+ visibility: repositories.visibility,
+ isPrivate: repositories.isPrivate,
+ isForked: repositories.isForked,
+ forkedFrom: repositories.forkedFrom,
+ isStarred: repositories.isStarred,
+ isArchived: repositories.isArchived,
+ hasLFS: repositories.hasLFS,
+ hasSubmodules: repositories.hasSubmodules,
+ hasIssues: repositories.hasIssues,
+ language: repositories.language,
+ description: repositories.description,
+ defaultBranch: repositories.defaultBranch,
+ size: repositories.size,
+ status: repositories.status,
+ lastMirrored: repositories.lastMirrored,
+ errorMessage: repositories.errorMessage,
+ importedAt: repositories.importedAt,
+ createdAt: repositories.createdAt,
+ updatedAt: repositories.updatedAt,
+};
+
+export const REPOSITORY_EXPORT_COLUMNS = Object.keys(
+ repositoryExportSelection
+) as (keyof typeof repositoryExportSelection)[];
+
+export function repositoryExportFilename(now: Date): string {
+ return `gitea-mirror-repositories-${now.toISOString().slice(0, 10)}.csv`;
+}
+
+export const GET: APIRoute = async ({ request, locals }) => {
+ try {
+ const authResult = await requireAuthenticatedUserId({ request, locals });
+ if ("response" in authResult) return authResult.response;
+
+ const rows = await db
+ .select(repositoryExportSelection)
+ .from(repositories)
+ .where(eq(repositories.userId, authResult.userId))
+ .orderBy(sql`${repositories.fullName} COLLATE NOCASE`);
+
+ return new Response(toCsv(rows, REPOSITORY_EXPORT_COLUMNS), {
+ status: 200,
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition": `attachment; filename="${repositoryExportFilename(new Date())}"`,
+ "Cache-Control": "no-store",
+ },
+ });
+ } catch (error) {
+ return createSecureErrorResponse(error, "repositories CSV export", 500);
+ }
+};