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

405 lines
15 KiB
Python

"""Übersicht „Repository“: was neu ist, Branches, Commit-Details und Chats mit Bezug darauf.
Liest aus dem Klon der Plattform (RepoCache), der beim Öffnen höchstens einmal je REFRESH_SECONDS aktualisiert
wird; dabei holt er alle Branches des Servers nach refs/remotes/origin. Geschrieben wird hier nichts ins
Repository; Chats mit Bezug starten wie jeder andere Chat und unterliegen denselben Regeln.
"""
import logging
import time
from dataclasses import dataclass, field
from datetime import date, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.changes import service as changes
from app.changes.models import ChatBranch
from app.core.config import Settings
from app.core.crypto import Vault
from app.core.database import now
from app.core.errors import Invalid, NotFound
from app.data_sources import service as sources
from app.data_sources.connectors.git import BRANCH
from app.data_sources.connectors.git_repo import EMPTY_TREE, GitError, GitRepo, RepoCache, check_path, check_sha
from app.data_sources.connectors.pull_requests import PullRequest
from app.data_sources.models import DataSource
from app.identity import Principal
from app.repo.models import RepoVisit
from app.workspaces import service as workspaces
log = logging.getLogger(__name__)
REFRESH_SECONDS = 60
MAX_COMMITS = 200
MAX_BRANCHES = 100
MAX_DIFF_CHARS = 300_000
MAX_ENTRIES = 500
MAX_FILE_LINES = 5000
SEP, END = "\x1f", "\x1e"
# Zuletzt aktualisiert je Quelle (je Prozess); mehrere Prozesse aktualisieren schlimmstenfalls einmal mehr
_refreshed: dict[int, float] = {}
def git_sources(session: Session, workspace_id: int) -> list[DataSource]:
return list(session.scalars(select(DataSource).where(
DataSource.workspace_id == workspace_id, DataSource.kind == "git", DataSource.status == "ok")
.order_by(DataSource.name)))
def available(session: Session, workspace_id: int) -> bool:
return bool(git_sources(session, workspace_id))
def source_for(session: Session, principal: Principal, workspace_id: int, name: str | None) -> DataSource:
"""Gewählte Quelle (Name) oder die erste; nur Mitglieder des Workspace."""
workspaces.get(session, principal, workspace_id)
found = git_sources(session, workspace_id)
if not found:
raise NotFound
chosen = next((s for s in found if s.name == name), None) if name else found[0]
if chosen is None:
raise NotFound
return chosen
def open_repo(source: DataSource, settings: Settings, vault: Vault, force: bool = False) -> tuple[GitRepo, str]:
"""Klon holen bzw. aktualisieren (gedrosselt) und den Stand des Ziel-Branches liefern."""
repo = RepoCache(settings).repo(source.id, sources.connector(source, settings, vault))
branch = source.config["branch"]
fresh = time.monotonic() - _refreshed.get(source.id, -1e9) < REFRESH_SECONDS
if force or not fresh or not (repo.directory / "HEAD").exists():
head = repo.sync()
repo.fetch_heads()
_refreshed[source.id] = time.monotonic()
return repo, head
return repo, repo.run("rev-parse", "--verify", "--end-of-options", f"refs/heads/{branch}^{{commit}}").strip()
def refreshed_at(source_id: int) -> float | None:
"""Sekunden seit der letzten Aktualisierung in diesem Prozess (für die Anzeige)."""
at = _refreshed.get(source_id)
return None if at is None else time.monotonic() - at
# --- Neu: Commits auf dem Ziel-Branch ---
@dataclass
class Commit:
sha: str
author: str
email: str
date: datetime
subject: str
files: int = 0
insertions: int = 0
deletions: int = 0
new: bool = False # seit dem letzten Besuch
@property
def short(self) -> str:
return self.sha[:10]
@dataclass
class Day:
day: date
commits: list[Commit] = field(default_factory=list)
def log_commits(repo: GitRepo, ref: str, since: date | None = None, author: str = "", path: str = "",
limit: int = MAX_COMMITS) -> list[Commit]:
args = ["log", "--no-color", "--first-parent", f"--max-count={limit}",
f"--format={END}%H{SEP}%an{SEP}%ae{SEP}%aI{SEP}%s", "--shortstat"]
if since:
args.append(f"--since={since.isoformat()}")
if author:
args += ["--fixed-strings", f"--author={author}", "-i"]
args += ["--end-of-options", ref]
if path:
args += ["--", check_path(path)]
output = repo.run(*args)
commits = []
for block in output.split(END)[1:]:
head, _, stat = block.partition("\n")
sha, name, email, when, subject = (head.split(SEP) + [""] * 5)[:5]
commit = Commit(sha, name, email, datetime.fromisoformat(when), subject)
for part in stat.strip().split(","):
part = part.strip()
number = int(part.split(" ", 1)[0]) if part[:1].isdigit() else 0
if "file" in part:
commit.files = number
elif "insertion" in part:
commit.insertions = number
elif "deletion" in part:
commit.deletions = number
commits.append(commit)
return commits
def parent(repo: GitRepo, sha: str) -> str:
"""Erster Elternteil (für „diesen Zeitraum zusammenfassen“); leer beim ersten Commit."""
return repo.run("rev-parse", "--verify", "--quiet", "--end-of-options", f"{sha}^", ok_codes=(0, 1)).strip()
def by_day(commits: list[Commit]) -> list[Day]:
days: list[Day] = []
for commit in commits:
day = commit.date.date()
if not days or days[-1].day != day:
days.append(Day(day))
days[-1].commits.append(commit)
return days
@dataclass
class News:
"""Seit dem letzten Besuch: Zahl der Commits und geänderten Dateien; None beim ersten Besuch."""
since: datetime | None
base: str | None
commits: int
files: int
def news(session: Session, repo: GitRepo, user_id: int, source_id: int, head: str) -> News:
visit = session.get(RepoVisit, (user_id, source_id))
if visit is None or visit.commit == head:
return News(visit.seen if visit else None, visit.commit if visit else None, 0, 0)
if not repo.has(visit.commit) or not repo.is_ancestor(visit.commit, head):
return News(visit.seen, None, 0, 0) # Verlauf umgeschrieben: kein sinnvoller Vergleich
count = int(repo.run("rev-list", "--count", "--first-parent", "--end-of-options", f"{visit.commit}..{head}")
.strip() or 0)
return News(visit.seen, visit.commit, count, len(repo.changed_files(visit.commit, head)))
def mark_new(commits: list[Commit], base: str | None) -> None:
if not base:
return
for commit in commits:
if commit.sha == base:
break
commit.new = True
def remember(session: Session, user_id: int, source_id: int, head: str) -> None:
visit = session.get(RepoVisit, (user_id, source_id))
if visit is None:
session.add(RepoVisit(user_id=user_id, source_id=source_id, commit=head))
elif visit.commit != head:
visit.commit, visit.seen = head, now()
# --- Branches ---
@dataclass
class Branch:
name: str
sha: str
date: datetime
author: str
subject: str
ahead: int # Commits, die das Ziel nicht hat
behind: int # Commits des Ziels, die hier fehlen
target: bool = False
chat_id: int | None = None
chat_title: str = ""
pr_state: str = ""
pr_url: str = ""
def branches(session: Session, repo: GitRepo, source: DataSource, workspace_id: int) -> list[Branch]:
target_name = source.config["branch"]
output = repo.run("for-each-ref", "--sort=-committerdate", f"--count={MAX_BRANCHES}",
f"--format=%(refname:lstrip=3){SEP}%(objectname){SEP}%(committerdate:iso-strict){SEP}"
f"%(authorname){SEP}%(contents:subject)", "refs/remotes/origin/")
target = f"refs/remotes/origin/{target_name}"
has_target = bool(repo.run("rev-parse", "--verify", "--quiet", "--end-of-options", target, ok_codes=(0, 1)).strip())
chats = {b.name: b for b in session.scalars(select(ChatBranch).where(
ChatBranch.workspace_id == workspace_id, ChatBranch.source_id == source.id).order_by(ChatBranch.id))}
from app.chat.models import ChatSession
titles = dict(session.execute(select(ChatSession.id, ChatSession.title).where(
ChatSession.id.in_([b.chat_session_id for b in chats.values()]))).all()) if chats else {}
result = []
for line in output.splitlines():
name, sha, when, author, subject = (line.split(SEP) + [""] * 5)[:5]
if not name or name == "HEAD":
continue
ahead = behind = 0
if has_target and name != target_name:
counts = repo.run("rev-list", "--left-right", "--count", "--end-of-options",
f"{target}...refs/remotes/origin/{name}").split()
behind, ahead = (int(counts[0]), int(counts[1])) if len(counts) == 2 else (0, 0)
chat = chats.get(name)
result.append(Branch(name, sha, datetime.fromisoformat(when), author, subject, ahead, behind,
name == target_name, chat.chat_session_id if chat else None,
titles.get(chat.chat_session_id, "") if chat else "",
chat.pr_state if chat else "", chat.pr_url or "" if chat else ""))
result.sort(key=lambda b: (not b.target, -b.date.timestamp()))
return result
def branch_names(repo: GitRepo) -> list[str]:
"""Namen der Branches auf dem Server (Stand der letzten Aktualisierung), für die Auswahl."""
output = repo.run("for-each-ref", f"--count={MAX_BRANCHES}", "--format=%(refname:lstrip=3)", "refs/remotes/origin/")
return sorted(n for n in output.splitlines() if n and n != "HEAD")
def check_branch(repo: GitRepo, name: str) -> str:
"""Branch muss auf dem Server existieren (Stand der letzten Aktualisierung)."""
if not BRANCH.match(name):
raise Invalid("Ungültiger Branch-Name.")
sha = repo.run("rev-parse", "--verify", "--quiet", "--end-of-options", f"refs/remotes/origin/{name}^{{commit}}",
ok_codes=(0, 1)).strip()
if not sha:
raise Invalid(f"Den Branch {name} gibt es auf dem Git-Server nicht (mehr).")
return sha
# --- Ein Commit ---
@dataclass
class CommitDetail:
commit: Commit
body: str
parents: list[str]
files: list[str]
diff: str
truncated: bool
def commit_detail(repo: GitRepo, sha: str) -> CommitDetail:
sha = check_sha(sha)
if not repo.has(sha):
raise NotFound
head = repo.run("show", "--no-patch", f"--format=%H{SEP}%an{SEP}%ae{SEP}%aI{SEP}%s{SEP}%P{SEP}%b",
"--end-of-options", sha)
full, name, email, when, subject, parents, body = (head.split(SEP, 6) + [""] * 7)[:7]
parent_list = parents.split()
base = parent_list[0] if parent_list else EMPTY_TREE
diff = repo.run("diff", "--no-color", "--end-of-options", base, full)
commit = Commit(full, name, email, datetime.fromisoformat(when), subject)
return CommitDetail(commit, body.strip(), parent_list, repo.changed_files(base, full), diff[:MAX_DIFF_CHARS],
len(diff) > MAX_DIFF_CHARS)
# --- Pull Requests ---
@dataclass
class PullRequestRow:
pr: PullRequest
chat_id: int | None = None
chat_title: str = ""
def pull_requests(session: Session, source: DataSource, settings: Settings, vault: Vault,
workspace_id: int) -> list[PullRequestRow] | None:
"""Offene Pull Requests aus dem Git-Server; None, wenn an der Quelle kein Anbieter eingerichtet ist."""
if source.config.get("pr_provider", "none") == "none":
return None
found = changes.prs(source, settings, vault).open()
branches = {b.name: b for b in session.scalars(select(ChatBranch).where(
ChatBranch.workspace_id == workspace_id, ChatBranch.source_id == source.id))}
from app.chat.models import ChatSession
titles = dict(session.execute(select(ChatSession.id, ChatSession.title).where(
ChatSession.id.in_([b.chat_session_id for b in branches.values()]))).all()) if branches else {}
rows = []
for pr in found:
chat = branches.get(pr.source)
rows.append(PullRequestRow(pr, chat.chat_session_id if chat else None,
titles.get(chat.chat_session_id, "") if chat else ""))
return rows
# --- Vergleich zweier Branches (Diff eines Pull Requests) ---
@dataclass
class Compare:
base: str # Ziel-Branch
head: str # Branch mit den Änderungen
merge_base: str
head_sha: str
commits: list[Commit]
files: list[str]
diff: str
truncated: bool
def compare(repo: GitRepo, base: str, head: str) -> Compare:
"""Änderungen von head gegenüber base ab dem gemeinsamen Stand, wie im Pull Request."""
base_sha, head_sha = check_branch(repo, base), check_branch(repo, head)
start = repo.merge_base(base_sha, head_sha)
commits = log_commits(repo, f"{start}..{head_sha}", limit=MAX_COMMITS)
diff = repo.patch(start, head_sha)
return Compare(base, head, start, head_sha, commits, repo.changed_files(start, head_sha),
diff[:MAX_DIFF_CHARS], len(diff) > MAX_DIFF_CHARS)
# --- Dateien ---
def resolve_ref(repo: GitRepo, source: DataSource, head: str, ref: str) -> tuple[str, str]:
"""Branch-Name und Commit für die Dateiansicht; leer = Ziel-Branch."""
if not ref or ref == source.config["branch"]:
return source.config["branch"], head
return ref, check_branch(repo, ref)
@dataclass
class TreeEntry:
name: str
path: str
kind: str # blob | tree | commit (Submodul)
size: int | None
def tree(repo: GitRepo, commit: str, path: str) -> list[TreeEntry]:
path = check_path(path)
if path and repo.kind(commit, path) != "tree":
raise NotFound
entries = [TreeEntry(e.path.rsplit("/", 1)[-1], e.path, e.kind, e.size)
for e in repo.entries(commit, path)[:MAX_ENTRIES]]
return sorted(entries, key=lambda e: (e.kind != "tree", e.name.lower()))
@dataclass
class FileView:
path: str
size: int
lines: list[str] = field(default_factory=list)
binary: bool = False
too_large: str = ""
truncated: bool = False
history: list[Commit] = field(default_factory=list)
def file_view(repo: GitRepo, commit: str, path: str) -> FileView:
path = check_path(path, allow_empty=False)
if repo.kind(commit, path) != "blob":
raise NotFound
size = int(repo.run("cat-file", "-s", "--end-of-options", f"{commit}:{path}").strip())
view = FileView(path, size, history=log_commits(repo, commit, path=path, limit=20))
try:
data = repo.read(commit, path)
except GitError as e:
view.too_large = str(e)
return view
if b"\0" in data[:8000]:
view.binary = True
return view
lines = data.decode("utf-8", "replace").splitlines()
view.lines, view.truncated = lines[:MAX_FILE_LINES], len(lines) > MAX_FILE_LINES
return view
def crumbs(path: str) -> list[tuple[str, str]]:
"""Pfad als (Name, Teilpfad) für die Navigation."""
parts = [p for p in path.split("/") if p]
return [(part, "/".join(parts[:i + 1])) for i, part in enumerate(parts)]
def safe(fn, *args, **kwargs):
"""Git-Fehler als lesbare Meldung statt Fehlerseite."""
try:
return fn(*args, **kwargs), ""
except GitError as e:
log.warning("Repository nicht lesbar", extra={"event": "repo_read_failed", "error": str(e)})
return None, str(e)