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")