"""Übersicht „Repository“ eines Workspace: Neu, Branches, Commit-Details und Chats mit Bezug darauf.""" from datetime import date, timedelta from typing import Annotated from fastapi import APIRouter, Form, Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import HTMLResponse, Response from app import web from app.chat import service as chats from app.core.errors import Invalid from app.data_sources.connectors.git_repo import GitError, check_path, check_sha from app.repo import service as repo_service from app.workspaces import service as workspaces Text = Annotated[str, Form()] TABS = {"new": "Neu", "branches": "Branches", "prs": "Pull Requests", "files": "Dateien"} PERIODS = {"7": "7 Tage", "30": "30 Tage", "90": "90 Tage", "": "alle (letzte 200)"} def router() -> APIRouter: r = APIRouter() def base_context(session, principal, workspace_id: int, source_name: str | None) -> dict: membership = workspaces.get(session, principal, workspace_id) source = repo_service.source_for(session, principal, workspace_id, source_name) return {"workspace": membership.workspace, "current_workspace": membership.workspace, "membership": membership, "source": source, "sources": repo_service.git_sources(session, workspace_id), "error": ""} def overview(request: Request, principal, workspace_id: int, source: str | None, tab: str, period: str, author: str, path: str, force: bool, ref: str = "", at: str = "") -> HTMLResponse: settings, vault = web.settings(request), web.vault(request) with web.db(request).session() as session: context = base_context(session, principal, workspace_id, source) src = context["source"] session.expunge(src) tab = tab if tab in TABS else "new" context.update({"tab": tab, "tabs": TABS, "periods": PERIODS, "period": period if period in PERIODS else "30", "author": author.strip()[:100], "filter_path": path.strip()[:300]}) opened, error = repo_service.safe(repo_service.open_repo, src, settings, vault, force) if opened is None: context["error"] = f"Repository nicht erreichbar: {error}" return web.render(request, "repo.html", context, 502) repo, head = opened context["head"] = head context["age"] = repo_service.refreshed_at(src.id) with web.db(request).session() as session: if tab == "new": since = date.today() - timedelta(days=int(context["period"])) if context["period"] else None try: commits = repo_service.log_commits(repo, head, since, context["author"], context["filter_path"]) except (GitError, Invalid) as e: commits, context["error"] = [], str(e) filtered = bool(context["author"] or context["filter_path"]) news = repo_service.news(session, repo, principal.user_id, src.id, head) repo_service.mark_new(commits, news.base) context.update({"days": repo_service.by_day(commits), "news": news, "filtered": filtered, "count": len(commits), "range_start": repo_service.parent(repo, commits[-1].sha) if commits and not filtered else ""}) repo_service.remember(session, principal.user_id, src.id, head) elif tab == "branches": rows, error = repo_service.safe(repo_service.branches, session, repo, src, workspace_id) context.update({"branches": rows or [], "error": error}) elif tab == "prs": rows, error = repo_service.safe(repo_service.pull_requests, session, src, settings, vault, workspace_id) context.update({"prs": rows, "error": error, "pr_provider": src.config.get("pr_provider", "none")}) else: return files(request, context, repo, src, head, ref, at) return web.render(request, "repo.html", context) def files(request: Request, context: dict, repo, src, head: str, ref: str, at: str) -> HTMLResponse: try: ref_name, commit = repo_service.resolve_ref(repo, src, head, ref.strip()) at = check_path(at) is_file = bool(at) and repo.kind(commit, at) == "blob" context.update({"ref": ref_name, "ref_sha": commit, "at": at, "crumbs": repo_service.crumbs(at), "is_file": is_file, "refs": repo_service.branch_names(repo)}) if is_file: context["file"] = repo_service.file_view(repo, commit, at) else: context["entries"] = repo_service.tree(repo, commit, at) except (GitError, Invalid) as e: context.update({"error": str(e), "entries": [], "is_file": False, "crumbs": []}) return web.render(request, "repo.html", context, 400) return web.render(request, "repo.html", context) @r.get("/workspaces/{workspace_id}/repo") async def page(request: Request, principal: web.User, workspace_id: int, source: str | None = None, tab: str = "new", period: str = "30", author: str = "", path: str = "", ref: str = "", at: str = "") -> HTMLResponse: return await run_in_threadpool(overview, request, principal, workspace_id, source, tab, period, author, path, False, ref, at) @r.post("/workspaces/{workspace_id}/repo/refresh") async def refresh(request: Request, principal: web.Checked, workspace_id: int, source: Text = "", tab: Text = "new") -> HTMLResponse: return await run_in_threadpool(overview, request, principal, workspace_id, source or None, tab, "30", "", "", True) @r.get("/workspaces/{workspace_id}/repo/commits/{sha}") async def commit(request: Request, principal: web.User, workspace_id: int, sha: str, source: str | None = None) -> HTMLResponse: def work(): settings, vault = web.settings(request), web.vault(request) with web.db(request).session() as session: context = base_context(session, principal, workspace_id, source) session.expunge(context["source"]) opened, error = repo_service.safe(repo_service.open_repo, context["source"], settings, vault) if opened is None: context["error"] = f"Repository nicht erreichbar: {error}" return web.render(request, "repo_commit.html", context, 502) detail, error = repo_service.safe(repo_service.commit_detail, opened[0], sha) context.update({"detail": detail, "error": error}) return web.render(request, "repo_commit.html", context, 200 if detail else 404) return await run_in_threadpool(work) @r.get("/workspaces/{workspace_id}/repo/compare") async def compare(request: Request, principal: web.User, workspace_id: int, base: str, head: str, source: str | None = None, pr: str = "") -> HTMLResponse: def work(): settings, vault = web.settings(request), web.vault(request) with web.db(request).session() as session: context = base_context(session, principal, workspace_id, source) session.expunge(context["source"]) context["pr"] = pr if pr.isdigit() else "" opened, error = repo_service.safe(repo_service.open_repo, context["source"], settings, vault) if opened is None: context["error"] = f"Repository nicht erreichbar: {error}" return web.render(request, "repo_compare.html", context, 502) try: result, error = repo_service.safe(repo_service.compare, opened[0], base, head) except Invalid as e: result, error = None, str(e) context.update({"compare": result, "error": error}) return web.render(request, "repo_compare.html", context, 200 if result else 400) return await run_in_threadpool(work) @r.post("/workspaces/{workspace_id}/repo/chat") async def start_chat(request: Request, principal: web.Checked, workspace_id: int, action: Text, source: Text = "", start: Text = "", end: Text = "", sha: Text = "", path: Text = "", branch: Text = "", base: Text = "", pr: Text = "") -> Response: """Chat mit Bezug: zusammenfassen, Commit oder Datei erklären, auf einem Branch weiterarbeiten.""" def work() -> int: settings, vault = web.settings(request), web.vault(request) with web.db(request).session() as session: src = repo_service.source_for(session, principal, workspace_id, source or None) session.expunge(src) repo, head = repo_service.open_repo(src, settings, vault) title, text, git_base = prompt_for(src.name, src.config["branch"], action, start, end, sha, path, branch, repo, head, base, pr) with web.db(request).session() as session: chat = chats.create(session, principal, workspace_id, title) if git_base: chat.git_base = {str(src.id): git_base} if text: chats.send(session, principal, chat.id, text) return chat.id try: chat_id = await run_in_threadpool(work) except GitError as e: raise Invalid(f"Repository nicht erreichbar: {e}") from e return web.redirect(f"/chats/{chat_id}") return r def prompt_for(source: str, target: str, action: str, start: str, end: str, sha: str, path: str, branch: str, repo, head: str, base: str = "", pr: str = "") -> tuple[str, str, str | None]: """Titel, erste Nachricht (leer = keine) und Ausgangs-Branch des neuen Chats. Wer eine Datei oder einen Pull Request auf einem anderen Branch betrachtet, bekommt einen Chat, der diesen Branch liest.""" on = branch if branch and branch != target else None if on: repo_service.check_branch(repo, on) if action == "summarize": end = check_sha(end) if end else head if start: start = check_sha(start) span = f"zwischen Commit {start[:12]} (ausgeschlossen) und {end[:12]}" hint = f"Nutze git_diff von {start} bis {end} und git_log für die Commit-Nachrichten." else: span = "in den letzten Tagen" hint = "Nutze git_log und git_show_commit." return (f"Neu in {source}", f"Fasse zusammen, was sich in der Quelle {source} auf {target} {span} geändert hat. {hint} " "Gruppiere nach Themen (Funktionen, Fehlerbehebungen, Aufräumen, Abhängigkeiten), nenne jeweils die " "wichtigsten Commits und betroffenen Bereiche und weise auf Änderungen hin, die andere im Team " "kennen sollten (z. B. Schnittstellen, Konfiguration, Migrationen).", None) if action == "commit": sha = check_sha(sha) return (f"Commit {sha[:10]} erklären", f"Erkläre den Commit {sha} in der Quelle {source}: was er ändert, warum vermutlich, und worauf man " "beim Review achten sollte. Nutze git_show_commit und lies bei Bedarf den Kontext der geänderten " "Dateien.", None) if action == "file": path = check_path(path, allow_empty=False) return (f"{path} erklären", f"Erkläre die Datei {path} in der Quelle {source}{f' auf Branch {on}' if on else ''}: Zweck, Aufbau, " "wichtigste Stellen und wie sie mit dem Rest zusammenhängt. Nutze git_read_file und git_log für die " "jüngere Geschichte.", on) if action == "review": if not on: raise Invalid("Bitte den Branch mit den Änderungen angeben.") base = base or target result = repo_service.compare(repo, base, on) label = f"Pull Request #{pr}" if pr.isdigit() else f"Branch {on}" return (f"Review {label}"[:200], f"Reviewe {label} in der Quelle {source}: Änderungen von {on} gegenüber {base}, " f"{len(result.commits)} Commit(s), {len(result.files)} Datei(en). Nutze git_diff von " f"{result.merge_base} bis {result.head_sha} und lies bei Bedarf den Kontext der Dateien (die Tools " f"lesen in diesem Chat {on}). Prüfe Korrektheit, Randfälle, Sicherheit, Tests und Lesbarkeit. " "Gliedere nach Schwere (muss, sollte, Kleinigkeit) mit Pfad und Zeile. Ändere nichts, solange ich " "nicht darum bitte.", on) if action == "branch": repo_service.check_branch(repo, branch) if not on: return (f"Arbeit an {source}", "", None) return (f"Arbeit an {branch}", "", on) raise Invalid("Unbekannte Aktion.")