From 6054b7a184acadcb49a90f029e92ca2674305c83 Mon Sep 17 00:00:00 2001
From: Selene29
Date: Sun, 5 Apr 2026 12:31:05 +0200
Subject: [PATCH 1/3] feat: add wiki ZIP export API endpoint
GET /api/repos/{repo_id}/export returns a ZIP archive with pages
organized as wiki/{page_type}/{target_path}.md. Includes tests for
success and 404 cases.
---
.../src/repowise/server/routers/repos.py | 43 +++++++++++++++++++
tests/unit/server/test_repos.py | 30 +++++++++++++
2 files changed, 73 insertions(+)
diff --git a/packages/server/src/repowise/server/routers/repos.py b/packages/server/src/repowise/server/routers/repos.py
index d2a1ecd..efab1f8 100644
--- a/packages/server/src/repowise/server/routers/repos.py
+++ b/packages/server/src/repowise/server/routers/repos.py
@@ -3,12 +3,16 @@
from __future__ import annotations
import asyncio
+import io
import logging
+import zipfile
+from pathlib import PurePosixPath
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi.responses import StreamingResponse
from repowise.core.persistence import crud
from repowise.core.persistence.models import (
DeadCodeFinding,
@@ -240,3 +244,42 @@ def _on_done(t: asyncio.Task) -> None:
logger.error("background_job_failed", exc_info=t.exception())
task.add_done_callback(_on_done)
+
+
+@router.get("/{repo_id}/export")
+async def export_wiki(
+ repo_id: str,
+ session: AsyncSession = Depends(get_db_session), # noqa: B008
+) -> StreamingResponse:
+ """Export all wiki pages as a ZIP of markdown files with folder structure."""
+ repo = await crud.get_repository(session, repo_id)
+ if repo is None:
+ raise HTTPException(status_code=404, detail="Repository not found")
+
+ pages = await crud.list_pages(session, repo_id, limit=10000)
+ if not pages:
+ raise HTTPException(status_code=404, detail="No pages to export")
+
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+ for page in pages:
+ target = page.target_path or page.id
+ safe = (
+ target.replace("::", "/")
+ .replace("->", "--")
+ .replace("\\", "/")
+ )
+ path = PurePosixPath("wiki") / page.page_type / safe
+ if path.suffix != ".md":
+ path = path.with_suffix(path.suffix + ".md")
+
+ content = f"# {page.title}\n\n{page.content}"
+ zf.writestr(str(path), content)
+
+ buf.seek(0)
+ filename = f"{repo.name}-wiki.zip"
+ return StreamingResponse(
+ buf,
+ media_type="application/zip",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
diff --git a/tests/unit/server/test_repos.py b/tests/unit/server/test_repos.py
index d0bb20a..700b138 100644
--- a/tests/unit/server/test_repos.py
+++ b/tests/unit/server/test_repos.py
@@ -125,3 +125,33 @@ async def test_full_resync_duplicate_returns_409(client: AsyncClient) -> None:
resp2 = await client.post(f"/api/repos/{repo['id']}/full-resync")
assert resp2.status_code == 409
+
+
+
+@pytest.mark.asyncio
+async def test_export_wiki_not_found(client: AsyncClient) -> None:
+ resp = await client.get("/api/repos/nonexistent/export")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_export_wiki_returns_zip(client: AsyncClient, session) -> None:
+ import zipfile
+ from io import BytesIO
+
+ from repowise.core.persistence.crud import upsert_page, upsert_repository
+ from tests.unit.persistence.helpers import make_page_kwargs
+
+ repo = await upsert_repository(session, name="export-test", local_path="/tmp/export-test")
+ await upsert_page(session, **make_page_kwargs(repo.id))
+ await session.commit()
+
+ resp = await client.get(f"/api/repos/{repo.id}/export")
+ assert resp.status_code == 200
+ assert resp.headers["content-type"] == "application/zip"
+
+ zf = zipfile.ZipFile(BytesIO(resp.content))
+ names = zf.namelist()
+ assert len(names) == 1
+ assert names[0].startswith("wiki/")
+ assert names[0].endswith(".md")
From bf5116cee20261bb5c499798b4f2bebb7038f123 Mon Sep 17 00:00:00 2001
From: Selene29
Date: Sun, 5 Apr 2026 12:31:26 +0200
Subject: [PATCH 2/3] feat: add ZIP download buttons to docs page and
operations panel
Add "Download ZIP" button next to "Export All" on the docs page, and
an "Export" button in the operations panel. Both link to the new
GET /api/repos/{repo_id}/export endpoint.
---
packages/web/src/app/repos/[id]/docs/page.tsx | 37 +++++++++++--------
.../src/components/repos/operations-panel.tsx | 12 +++++-
2 files changed, 33 insertions(+), 16 deletions(-)
diff --git a/packages/web/src/app/repos/[id]/docs/page.tsx b/packages/web/src/app/repos/[id]/docs/page.tsx
index e52d988..b075bc0 100644
--- a/packages/web/src/app/repos/[id]/docs/page.tsx
+++ b/packages/web/src/app/repos/[id]/docs/page.tsx
@@ -1,7 +1,7 @@
"use client";
import { use, useState } from "react";
-import { Download, Loader2 } from "lucide-react";
+import { Download, FolderArchive, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DocsExplorer } from "@/components/docs/docs-explorer";
import { listAllPages } from "@/lib/api/pages";
@@ -41,20 +41,27 @@ export default function DocsPage({
Browse AI-generated documentation for every file, module, and symbol.
-
+
{/* Explorer */}
diff --git a/packages/web/src/components/repos/operations-panel.tsx b/packages/web/src/components/repos/operations-panel.tsx
index 7a07d28..1b16421 100644
--- a/packages/web/src/components/repos/operations-panel.tsx
+++ b/packages/web/src/components/repos/operations-panel.tsx
@@ -2,7 +2,7 @@
import { useState } from "react";
import { toast } from "sonner";
-import { RefreshCw, Zap, ChevronDown, ChevronUp, AlertTriangle } from "lucide-react";
+import { RefreshCw, Zap, ChevronDown, ChevronUp, AlertTriangle, Download } from "lucide-react";
import { syncRepo, fullResyncRepo } from "@/lib/api/repos";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -125,6 +125,16 @@ export function OperationsPanel({ repoId, repoName }: Props) {
{loading === "resync" ? "Starting…" : "Full Resync"}
+
)}
From 13adc9e142517caf84a78d773737478dcecf9eaa Mon Sep 17 00:00:00 2001
From: Selene29
Date: Mon, 6 Apr 2026 12:47:13 +0200
Subject: [PATCH 3/3] fix: use direct query in export to avoid list_pages 10k
limit
Reviewer feedback: crud.list_pages silently truncates at the limit arg.
Use a direct select(Page) query instead so large repos export fully.
---
packages/server/src/repowise/server/routers/repos.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/server/src/repowise/server/routers/repos.py b/packages/server/src/repowise/server/routers/repos.py
index efab1f8..bd616ce 100644
--- a/packages/server/src/repowise/server/routers/repos.py
+++ b/packages/server/src/repowise/server/routers/repos.py
@@ -256,7 +256,7 @@ async def export_wiki(
if repo is None:
raise HTTPException(status_code=404, detail="Repository not found")
- pages = await crud.list_pages(session, repo_id, limit=10000)
+ pages = (await session.execute(select(Page).where(Page.repository_id == repo_id))).scalars().all()
if not pages:
raise HTTPException(status_code=404, detail="No pages to export")