"""Chat-Branches: Commits der KI, Push und Pull Request auf Klick (→ Git-Workflow). - Die KI committet mit commit_change auf den Branch ihres Chats (ai/c-). Er entsteht beim ersten Commit und bleibt im Klon der Plattform. - Pushen, Pull Request erstellen, als bereit markieren und Commits zurücknehmen löst nur ein Mitglied des Workspace aus, nie die KI. - Gepusht wird nie mit Überschreiben: neuer Branch nur, wenn es ihn noch nicht gibt, sonst nur, wenn er auf dem zuletzt übertragenen Stand steht. - Der Worker fragt offene Pull Requests regelmäßig ab; nach Merge oder Ablehnung ist der Branch geschlossen und der nächste Commit beginnt einen neuen. """ import logging import re import unicodedata from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import Any from sqlalchemy import func, select from sqlalchemy.orm import Session from app import audit from app.changes import checks from app.changes.models import DECLINED, MERGED, OPEN, BranchCommit, ChatBranch from app.chat.models import ChatSession from app.core.config import Settings from app.core.crypto import Vault from app.core.database import Database, now from app.core.errors import Invalid, NotFound from app.data_sources import service as sources from app.data_sources.connectors import pull_requests from app.data_sources.connectors.git_repo import GitError, GitRepo, MergeConflict, RepoCache, check_path from app.data_sources.models import DataSource from app.identity import Principal from app.identity.models import User from app.workspaces import service as workspaces log = logging.getLogger(__name__) MAX_DIFF_CHARS = 300_000 # --- Commits bauen (auch zum Nachbauen nach Verlust des Klons) --- def build(repo: GitRepo, parent: str, changes: list[dict[str, Any]], message: str, date: str, settings: Settings) -> str: if len(changes) > settings.git_max_change_files: raise GitError(f"Höchstens {settings.git_max_change_files} Dateien je Commit.") files: dict[str, bytes | None] = {} for change in changes: path = check_path(change["path"]) if not path or path in files: raise GitError(f"Pfad {change['path']!r} ist leer oder doppelt.") if change.get("delete"): if repo.kind(parent, path) != "blob": raise GitError(f"{path} gibt es nicht als Datei.") files[path] = None elif change.get("replace"): if repo.kind(parent, path) != "blob": raise GitError(f"{path} gibt es nicht als Datei; für neue Dateien content verwenden.") text = repo.read(parent, path).decode("utf-8") for item in change["replace"]: count = text.count(item["old"]) if count != 1: raise GitError(f"{path}: Der zu ersetzende Text kommt {count}-mal vor, nötig ist genau einmal.") text = text.replace(item["old"], item["new"]) files[path] = text.encode() else: if repo.kind(parent, path) == "tree": raise GitError(f"{path} ist ein Verzeichnis.") files[path] = change["content"].encode() if files[path] is not None and len(files[path]) > settings.git_max_file_kb * 1024: raise GitError(f"{path} wird größer als {settings.git_max_file_kb} KB.") return repo.build_commit(parent, files, message, date) def from_patch(repo: GitRepo, parent: str, patch: str) -> list[dict[str, Any]]: """Patch in Änderungen mit vollem Inhalt umsetzen; so gespeichert, baut build den Commit exakt nach.""" result = [] for path, content in repo.apply_patch(parent, patch).items(): if content is None: result.append({"path": path, "delete": True}) continue try: result.append({"path": path, "content": content.decode("utf-8")}) except UnicodeDecodeError as e: raise GitError(f"{path}: Binärdateien lassen sich nicht per Patch ändern.") from e return result def resolved(changes: list[dict[str, Any]]) -> dict[str, bytes | None]: return {c["path"]: None if c.get("delete") else c["content"].encode() for c in changes} def restore(repo: GitRepo, branch: ChatBranch, commits: list[BranchCommit], settings: Settings) -> None: """Commits des Branches im Klon sicherstellen: vorhanden, vom Server holen (wenn gepusht) oder aus den gespeicherten Änderungen exakt nachbauen.""" if repo.has(branch.head): return if branch.pushed and repo.fetch_branch(branch.name, branch.ref) and repo.has(branch.head): return parent = branch.base if not repo.has(parent): raise GitError("Der Ausgangsstand des Branches ist im Repository nicht mehr vorhanden.") for commit in commits: if commit.merged: if not repo.has(commit.merged): raise GitError("Der abgeglichene Stand des Ziel-Branches ist im Repository nicht mehr vorhanden.") sha = repo.merge(parent, commit.merged, commit.message, commit.date, resolved(commit.changes)) else: sha = build(repo, parent, commit.changes, commit.message, commit.date, settings) if sha != commit.sha: raise GitError("Der Branch lässt sich nicht exakt nachbauen.") parent = sha if parent != branch.head: raise GitError("Der Branch lässt sich nicht exakt nachbauen.") repo.keep(branch.ref, branch.head) def history(session: Session, branch_id: int) -> list[BranchCommit]: return list(session.scalars(select(BranchCommit).where(BranchCommit.branch_id == branch_id) .order_by(BranchCommit.id))) def active(session: Session, chat_id: int, source_id: int, lock: bool = False) -> ChatBranch | None: query = select(ChatBranch).where(ChatBranch.chat_session_id == chat_id, ChatBranch.source_id == source_id, ChatBranch.closed.is_(None)) return session.scalar(query.with_for_update() if lock else query) def head(db: Database, repo: GitRepo, chat_id: int, source_id: int, settings: Settings) -> str | None: """Stand, den die lesenden Tools in diesem Chat sehen: der Chat-Branch, falls es einen gibt.""" with db.session() as session: branch = active(session, chat_id, source_id) if branch is None: return None restore(repo, branch, history(session, branch.id), settings) return branch.head def slug(title: str) -> str: text = title.lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() return re.sub(r"[^a-z0-9]+", "-", text).strip("-")[:40].strip("-") or "aenderung" def branch_name(session: Session, chat: ChatSession, source_id: int) -> str: name = f"ai/c{chat.id}-{slug(chat.title)}" earlier = session.scalar(select(func.count()).select_from(ChatBranch).where( ChatBranch.chat_session_id == chat.id, ChatBranch.source_id == source_id)) or 0 return name if not earlier else f"{name}-{earlier + 1}" @dataclass(frozen=True) class Committed: branch: str sha: str files: list[str] stat: str warnings: list[str] def commit(db: Database, repo: GitRepo, source: DataSource, *, chat_id: int, workspace_id: int, user_id: int | None, run_id: Any, start: str, changes: list[dict[str, Any]], message: str, settings: Settings, patch: str = "", target: str | None = None) -> Committed: """Einen Commit der KI auf den Branch des Chats; legt den Branch beim ersten Commit an. start ist der Stand des Ziel-Branches, auf dem ein neuer Branch beginnt. Statt changes geht auch ein Unified Diff (patch). Die statische Prüfung warnt nur. target: Ausgangs-Branch, wenn der Chat nicht auf dem Ziel-Branch der Quelle arbeitet.""" date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S+0000") with db.session() as session: branch = active(session, chat_id, source.id, lock=True) if branch is not None: restore(repo, branch, history(session, branch.id), settings) parent = branch.head if branch else start if patch: changes = from_patch(repo, parent, patch) sha = build(repo, parent, changes, message, date, settings) files = repo.changed_files(parent, sha) warnings = checks.check({c["path"]: c["content"].encode() for c in changes if "content" in c} | {c["path"]: repo.read(sha, c["path"]) for c in changes if c.get("replace")}) if branch is None: chat = session.get(ChatSession, chat_id) branch = ChatBranch(workspace_id=workspace_id, chat_session_id=chat_id, source_id=source.id, name=branch_name(session, chat, source.id), target=target or source.config["branch"], base=start, head=start, created_by=user_id) session.add(branch) session.flush() session.add(BranchCommit(branch_id=branch.id, sha=sha, parent=parent, message=message, changes=changes, date=date, files=files, run_id=run_id, warnings=warnings)) branch.head, branch.updated = sha, now() repo.keep(branch.ref, sha) audit.record(session, "branch.committed", user_id=user_id, workspace_id=workspace_id, target_kind="branch", target_id=branch.id, branch=branch.name, commit=sha, files=len(files)) name = branch.name stat = repo.run("diff", "--no-color", "--stat", "--end-of-options", parent, sha).strip() return Committed(name, sha, files, stat, warnings) class Behind(Invalid): """Abgleich mit dem Ziel-Branch hat Konflikte; files mit Konfliktmarkern in conflicts.""" def __init__(self, target: str, conflicts: dict[str, str]): super().__init__(f"Konflikte mit {target} in: {', '.join(conflicts)}.") self.target, self.conflicts = target, conflicts def merge_target(db: Database, repo: GitRepo, source: DataSource, *, chat_id: int, user_id: int | None, run_id: Any, settings: Settings, resolutions: list[dict[str, Any]] | None = None) -> Committed: """Aktuellen Stand des Ziel-Branches in den Chat-Branch zusammenführen (Merge-Commit, kein Rebase: bereits übertragene Commits bleiben gültig). Bei Konflikten Behind mit den Dateien samt Markern; mit resolutions (Pfad + content bzw. delete) entsteht der Merge-Commit mit diesen Lösungen.""" with db.session() as session: branch = active(session, chat_id, source.id) target_branch = branch.target if branch else None target = repo.sync(target_branch) date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S+0000") resolutions = resolutions or [] with db.session() as session: branch = active(session, chat_id, source.id, lock=True) if branch is None: raise Invalid("Zu diesem Chat gibt es noch keinen Branch.") check_open(branch) restore(repo, branch, history(session, branch.id), settings) if repo.is_ancestor(target, branch.head): raise Invalid(f"Der Branch ist bereits auf dem aktuellen Stand von {branch.target}.") message = f"Merge {branch.target} in {branch.name}" try: sha = repo.merge(branch.head, target, message, date, resolved(resolutions)) except MergeConflict as e: conflicts = {} for path in e.files: try: conflicts[path] = repo.read(e.tree, path).decode("utf-8", "replace") except GitError: conflicts[path] = "(nicht lesbar, z. B. gelöscht auf einer Seite oder zu groß)" raise Behind(branch.target, conflicts) from e files = repo.changed_files(branch.head, sha) warnings = checks.check({c["path"]: c["content"].encode() for c in resolutions if "content" in c}) session.add(BranchCommit(branch_id=branch.id, sha=sha, parent=branch.head, merged=target, message=message, changes=resolutions, date=date, files=files, run_id=run_id, warnings=warnings)) parent = branch.head branch.head, branch.updated = sha, now() repo.keep(branch.ref, sha) audit.record(session, "branch.merged_target", user_id=user_id, workspace_id=branch.workspace_id, target_kind="branch", target_id=branch.id, branch=branch.name, commit=sha, target=target, resolved=len(resolutions)) name = branch.name stat = repo.run("diff", "--no-color", "--stat", "--end-of-options", parent, sha).strip() return Committed(name, sha, files, stat, warnings) def sync_target(db: Database, principal: Principal, settings: Settings, vault: Vault, branch_id: int) -> Committed: """Knopf „Mit Ziel-Branch abgleichen“: nur konfliktfrei; Konflikte löst die KI (merge_target).""" with db.session() as session: t = load(session, principal, settings, vault, branch_id, lock=False) chat_id, source = t.branch.chat_session_id, t.source session.expunge(source) repo = t.repo return merge_target(db, repo, source, chat_id=chat_id, user_id=principal.user_id, run_id=None, settings=settings) # --- Aktionen eines Menschen --- @dataclass class Target: """Branch mit allem, was für eine Aktion gebraucht wird.""" branch: ChatBranch source: DataSource repo: GitRepo chat: ChatSession def load(session: Session, principal: Principal, settings: Settings, vault: Vault, branch_id: int, lock: bool = True) -> Target: """Branch für eine Aktion: nur Mitglieder des Workspace, nur offene Branches.""" query = select(ChatBranch).where(ChatBranch.id == branch_id) branch = session.scalar(query.with_for_update() if lock else query) if branch is None: raise NotFound workspaces.get(session, principal, branch.workspace_id) source = session.get(DataSource, branch.source_id) repo = RepoCache(settings).repo(source.id, sources.connector(source, settings, vault)) return Target(branch, source, repo, session.get(ChatSession, branch.chat_session_id)) def prs(source: DataSource, settings: Settings, vault: Vault) -> pull_requests.PullRequestProvider: token = vault.decrypt(source.secret) if source.secret else None return pull_requests.provider(source.config.get("pr_provider", "none"), source.config["url"], token, settings.pr_timeout_seconds, source.config.get("username", "")) def check_open(branch: ChatBranch) -> None: if branch.closed is not None: raise Invalid("Der Pull Request dieses Branches ist abgeschlossen; neue Änderungen entstehen auf einem " "neuen Branch.") def push(session: Session, principal: Principal, settings: Settings, vault: Vault, branch_id: int) -> ChatBranch: """Branch pushen bzw. neue Commits in einen offenen Pull Request übertragen.""" t = load(session, principal, settings, vault, branch_id) check_open(t.branch) if not t.branch.unpushed: raise Invalid("Es gibt keine neuen Commits zum Übertragen.") transfer(session, t, principal) return t.branch def transfer(session: Session, t: Target, principal: Principal) -> None: branch = t.branch t.repo.sync() restore(t.repo, branch, history(session, branch.id), t.repo.settings) try: t.repo.push(branch.head, branch.name, expected=branch.pushed) except GitError as e: remote = t.repo.remote_head(branch.name) if remote == branch.head: # schon übertragen, z. B. nach einem Abbruch pass elif remote is not None and remote != branch.pushed: raise Invalid(f"Branch {branch.name} hat auf dem Server einen anderen Stand (z. B. Commits von " "außerhalb der Plattform). Es wurde nichts überschrieben.") from e else: raise previous, branch.pushed, branch.updated = branch.pushed, branch.head, now() audit.record(session, "branch.pushed", user_id=principal.user_id, workspace_id=branch.workspace_id, target_kind="branch", target_id=branch.id, branch=branch.name, commit=branch.head, previous=previous) def description(t: Target, text: str, author: str, settings: Settings, commits: list[BranchCommit]) -> str: lines = [text.strip()] if text.strip() else [] lines += ["", "---", f"Erstellt in Bifröst (AI Workspace) von {author} aus dem Chat „{t.chat.title}“. " "Die Änderungen hat die KI vorgeschlagen; geprüft werden sie in diesem Review."] if settings.public_url: lines.append(f"Chat: {settings.public_url.rstrip('/')}/chats/{t.chat.id}") if t.chat.plan: from app.chat import plan lines += ["", "Plan:", plan.as_text(t.chat.plan)] lines += ["", "Commits:"] + [f"- {c.sha[:10]} {c.message.splitlines()[0]}" for c in commits[-30:]] return "\n".join(lines).strip() def create_pull_request(session: Session, principal: Principal, settings: Settings, vault: Vault, branch_id: int, title: str, text: str, draft: bool) -> ChatBranch: t = load(session, principal, settings, vault, branch_id) check_open(t.branch) branch = t.branch if branch.pr_id: raise Invalid("Für diesen Branch gibt es schon einen Pull Request.") if branch.head == branch.base: raise Invalid("Der Branch enthält noch keine Änderung.") title = " ".join(title.split())[:200] if len(title) < 3: raise Invalid("Der Titel braucht mindestens 3 Zeichen.") if branch.unpushed: transfer(session, t, principal) provider = prs(t.source, settings, vault) user = session.get(User, principal.user_id) pr = provider.find(branch.name) or provider.create( branch.name, branch.target, title, description(t, text[:5000], user.display_name, settings, history(session, branch.id)), draft=draft) if pr is None: # Ohne Anbieter: der gepushte Branch ist das Ergebnis audit.record(session, "branch.pull_request_skipped", user_id=principal.user_id, workspace_id=branch.workspace_id, target_kind="branch", target_id=branch.id) return branch branch.pr_id, branch.pr_url, branch.pr_state, branch.pr_draft = pr.id, pr.url, pr.state, pr.draft branch.pr_info = {"version": pr.version, "reviewers": pr.reviewers, "comments": 0, "by": principal.user_id, "title": title} branch.pr_checked = now() audit.record(session, "branch.pull_request_created", user_id=principal.user_id, workspace_id=branch.workspace_id, target_kind="branch", target_id=branch.id, branch=branch.name, pull_request=pr.id, draft=pr.draft) return branch def publish(session: Session, principal: Principal, settings: Settings, vault: Vault, branch_id: int) -> ChatBranch: """Entwurf als bereit zum Review markieren.""" t = load(session, principal, settings, vault, branch_id) check_open(t.branch) branch = t.branch if not branch.pr_id or not branch.pr_draft: raise Invalid("Es gibt keinen Entwurf, der bereit gemeldet werden könnte.") provider = prs(t.source, settings, vault) current = provider.get(branch.pr_id) pr = provider.publish(branch.pr_id, current.version if current else branch.pr_info.get("version", 0)) if pr is not None: branch.pr_draft, branch.pr_info = pr.draft, {**branch.pr_info, "version": pr.version} audit.record(session, "branch.pull_request_ready", user_id=principal.user_id, workspace_id=branch.workspace_id, target_kind="branch", target_id=branch.id, pull_request=branch.pr_id) return branch def undo(session: Session, principal: Principal, settings: Settings, vault: Vault, branch_id: int) -> ChatBranch | None: """Letzten, noch nicht übertragenen Commit zurücknehmen. Ohne Commits verschwindet der Branch.""" t = load(session, principal, settings, vault, branch_id) check_open(t.branch) branch = t.branch commits = history(session, branch.id) if not commits or branch.head == branch.pushed: raise Invalid("Übertragene Commits lassen sich hier nicht zurücknehmen; das geht nur im Review " "bzw. mit einem weiteren Commit.") last = commits[-1] session.delete(last) audit.record(session, "branch.commit_undone", user_id=principal.user_id, workspace_id=branch.workspace_id, target_kind="branch", target_id=branch.id, commit=last.sha) if last.parent == branch.base and not branch.pushed: t.repo.drop(branch.ref) session.delete(branch) return None branch.head, branch.updated = last.parent, now() t.repo.keep(branch.ref, branch.head) return branch # --- Rückkanal: Status des Pull Requests --- def refresh(session: Session, settings: Settings, vault: Vault, branch: ChatBranch) -> list[str]: """Stand aus Bitbucket übernehmen; liefert Anlässe für Benachrichtigungen.""" source = session.get(DataSource, branch.source_id) provider = prs(source, settings, vault) pr = provider.get(branch.pr_id) branch.pr_checked = now() if pr is None: return [] events = [] comments = [c for c in provider.comments(branch.pr_id)] if pr.state == OPEN else [] known = branch.pr_info.get("comments", 0) if pr.state == OPEN and len(comments) > known: events.append("comments") if pr.state != branch.pr_state and pr.state in (MERGED, DECLINED): events.append(pr.state.lower()) branch.closed = now() clean_up(session, settings, vault, branch, source) branch.pr_state, branch.pr_draft = pr.state, pr.draft branch.pr_info = {**branch.pr_info, "version": pr.version, "reviewers": pr.reviewers, "comments": len(comments) if pr.state == OPEN else known, "new_comments": max(len(comments) - known, 0) if pr.state == OPEN else 0} return events def clean_up(session: Session, settings: Settings, vault: Vault, branch: ChatBranch, source: DataSource) -> None: """Abgeschlossenen Branch im Git-Server löschen (Einstellung der Quelle, Standard an). Nur wenn er noch auf dem zuletzt übertragenen Stand steht: Commits von außen bleiben erhalten. In der Plattform bleiben Branch, Commits und Diff sichtbar. Fehler halten den Abschluss nicht auf, sie stehen im Log und in der Ansicht. """ if not source.config.get("delete_branches", True) or not branch.pushed: return repo = RepoCache(settings).repo(source.id, sources.connector(source, settings, vault)) try: repo.sync() remote = repo.remote_head(branch.name) if remote is None: result = "gone" # z. B. beim Merge in Bitbucket gelöscht elif remote != branch.pushed: result = "changed" else: repo.delete_branch(branch.name, branch.pushed) result = "deleted" audit.record(session, "branch.deleted", workspace_id=branch.workspace_id, target_kind="branch", target_id=branch.id, branch=branch.name, commit=branch.pushed) except GitError as e: log.warning("Branch %s nicht gelöscht", branch.name, extra={"event": "branch_delete_failed", "error": str(e)}) result = "failed" branch.pr_info = {**branch.pr_info, "remote_branch": result} def poll(db: Database, settings: Settings) -> int: """Offene Pull Requests abfragen (Worker, im Aufräum-Takt). Fehler je Branch nur ins Log.""" vault = Vault(settings.secret_key) due = now() - timedelta(seconds=settings.pr_poll_seconds) with db.session() as session: ids = list(session.scalars(select(ChatBranch.id).where( ChatBranch.pr_id.is_not(None), ChatBranch.closed.is_(None), (ChatBranch.pr_checked.is_(None)) | (ChatBranch.pr_checked < due)).limit(20))) count = 0 for branch_id in ids: try: with db.session() as session: branch = session.get(ChatBranch, branch_id, with_for_update=True) if branch is None or branch.closed is not None: continue for event in refresh(session, settings, vault, branch): notify_event(session, branch, event) count += 1 except Exception: log.warning("Pull Request von Branch %s nicht abfragbar", branch_id, exc_info=True) with db.session() as session: branch = session.get(ChatBranch, branch_id) if branch is not None: branch.pr_checked = now() return count def notify_event(session: Session, branch: ChatBranch, event: str) -> None: from app.notify import service as notify from app.workspaces.models import Workspace chat = session.get(ChatSession, branch.chat_session_id) workspace = session.get(Workspace, branch.workspace_id) title = notify.clean(branch.pr_info.get("title") or branch.name) text = {"merged": "wurde gemergt", "declined": "wurde abgelehnt", "comments": f"hat {branch.pr_info.get('new_comments', 0)} neue Kommentare"}[event] subject = f"Pull Request #{branch.pr_id} {text}: {title}" body = (f"Pull Request #{branch.pr_id} „{title}“ im Workspace „{workspace.name}“ {text}.\n\n" f"Pull Request: {branch.pr_url}\nChat: {notify.link(f'/chats/{chat.id}')}" + ("\n\nIm Chat lassen sich die Kommentare mit „Kommentare einarbeiten“ übernehmen." if event == "comments" else "")) for user_id in {chat.created_by, branch.pr_info.get("by")} - {None}: user = session.get(User, user_id) if user is not None: notify.email(session, user, f"pull_request.{event}", subject, body, workspace.id) if event != "comments": notify.webhook(session, workspace, f"pull_request.{event}", f"{subject} ({branch.pr_url})") # --- Anzeige --- @dataclass class View: branch: ChatBranch source: DataSource commits: list[BranchCommit] files: list[str] diff: str conflict: bool truncated: bool behind: bool = False # Ziel-Branch hat neue Commits, die im Chat-Branch fehlen def views(session: Session, settings: Settings, vault: Vault, chat_id: int) -> list[View]: """Branches des Chats für die Ansicht „Änderungen“: offene und der zuletzt geschlossene je Quelle.""" branches = list(session.scalars(select(ChatBranch).where(ChatBranch.chat_session_id == chat_id) .order_by(ChatBranch.id.desc()))) shown, result = set(), [] for branch in branches: if branch.source_id in shown: continue shown.add(branch.source_id) source = session.get(DataSource, branch.source_id) commits = history(session, branch.id) files, diff, conflict, truncated, behind = [], "", False, False, False try: repo = RepoCache(settings).repo(source.id, sources.connector(source, settings, vault)) if (repo.directory / "HEAD").exists(): restore(repo, branch, commits, settings) target = repo.run("rev-parse", "--verify", "--end-of-options", f"refs/heads/{branch.target}^{{commit}}", ok_codes=(0, 128)).strip() # Nur die eigenen Änderungen zeigen, auch nach einem Abgleich (Diff ab dem gemeinsamen Stand). # Abgeschlossene Branches: ab dem zuletzt abgeglichenen Stand, sonst wäre nach dem Merge # der Branch selbst der gemeinsame Stand und der Diff leer. if target and branch.closed is None: start = repo.merge_base(target, branch.head) else: start = next((c.merged for c in reversed(commits) if c.merged), branch.base) files = repo.changed_files(start, branch.head) diff = repo.patch(start, branch.head) truncated = len(diff) > MAX_DIFF_CHARS diff = diff[:MAX_DIFF_CHARS] if target and branch.closed is None: behind = not repo.is_ancestor(target, branch.head) conflict = behind and not repo.applies_to(target, branch.head) except (GitError, Invalid): log.warning("Änderungen von Branch %s nicht lesbar", branch.id, exc_info=True) result.append(View(branch, source, commits, files, diff, conflict, truncated, behind)) return list(reversed(result)) def bases(chat: ChatSession) -> dict[int, str]: """Ausgangs-Branch je Quelle eines Chats, der auf einem bestehenden Branch arbeitet.""" return {int(k): v for k, v in (chat.git_base or {}).items()} def prompt_notes(session: Session, chat_id: int) -> str: """Stand der Chat-Branches für den System-Prompt.""" lines = [] for branch in session.scalars(select(ChatBranch).where(ChatBranch.chat_session_id == chat_id, ChatBranch.closed.is_(None))): source = session.get(DataSource, branch.source_id) count = session.scalar(select(func.count()).select_from(BranchCommit) .where(BranchCommit.branch_id == branch.id)) or 0 state = ("noch nicht übertragen" if not branch.pushed else "übertragen" + (", mit neuen, noch nicht übertragenen Commits" if branch.unpushed else "")) pr = f"; Pull Request #{branch.pr_id} ({'Entwurf' if branch.pr_draft else 'offen'})" if branch.pr_id else "" lines.append(f"- Quelle {source.name}: Branch {branch.name} mit {count} Commit(s), {state}{pr}") return "\n".join(lines)