589 lines
29 KiB
Python
589 lines
29 KiB
Python
"""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<chat>-<titel>).
|
|
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)
|