bifroest/app/repo/routes.py
2026-10-27 08:34:17 +01:00

222 lines
13 KiB
Python

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