405 lines
15 KiB
Python
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)
|