bifroest/tests/test_branches.py
2026-10-27 08:34:17 +01:00

672 lines
34 KiB
Python

"""Git-Workflow: Chat = Branch, die KI committet, Menschen pushen und erstellen den Pull Request."""
import re
import shutil
import subprocess
import uuid
from pathlib import Path
import httpx
import pytest
from sqlalchemy import select
from app.changes import service as changes
from app.changes.models import BranchCommit, ChatBranch
from app.data_sources.connectors import pull_requests
from app.data_sources.connectors.git_repo import GitError
from app.identity import Principal
from app.notify.models import Notification
from app.tasks import states
from app.tasks.models import TaskRun
from app.tasks.worker import Worker
from app.tools import service as tools
from helpers import connect_repo, create_workspace
README = {"path": "README.md", "replace": [{"old": "# Test\n", "new": "# Test\n\nNeue Zeile.\n"}]}
def git(repo_url: str, *args: str) -> str:
path = repo_url.removeprefix("file://")
return subprocess.run(["git", "-C", path, *args], check=True, capture_output=True, text=True).stdout.strip()
def remote_branches(repo_url: str) -> list[str]:
return [b for b in git(repo_url, "branch", "--format=%(refname:short)").splitlines() if b.startswith("ai/")]
class FakePRs:
"""Bitbucket-Ersatz: merkt sich angelegte Pull Requests, Zustand und Kommentare lassen sich setzen."""
def __init__(self):
self.created, self.published = [], []
self.state, self.draft, self.version = "OPEN", False, 0
self.comments_list: list[pull_requests.Comment] = []
def pr(self) -> pull_requests.PullRequest:
return pull_requests.PullRequest("42", "https://bitbucket.intern/projects/P/repos/r/pull-requests/42",
self.state, self.draft, self.version,
[{"name": "Clara", "status": "NEEDS_WORK"}])
def find(self, branch):
return self.pr() if self.created else None
def create(self, branch, target, title, description, draft=False):
self.created.append({"branch": branch, "target": target, "title": title, "description": description,
"draft": draft})
self.draft = draft
return self.pr()
def get(self, pr_id):
return self.pr()
def publish(self, pr_id, version):
self.published.append(version)
self.draft, self.version = False, version + 1
return self.pr()
def comments(self, pr_id):
return self.comments_list
@pytest.fixture
def fake_prs(monkeypatch):
fake = FakePRs()
monkeypatch.setattr(changes, "prs", lambda *a: fake)
return fake
@pytest.fixture
def setup(browser, people, db, git_repo):
"""Workspace mit Repo; anna Admin, ben Mitglied und fragt im Chat; dora gehört nicht dazu."""
anna = browser("anna")
workspace_id = create_workspace(anna, people["team_demo"])
connect_repo(anna, workspace_id, git_repo)
anna.post(f"/workspaces/{workspace_id}/members", {"username": "ben", "role": "member"})
admin = Principal(user_id=people["anna"], username="anna", display_name="Anna", is_admin=True,
team_ids=frozenset({people["team_demo"]}))
with db.session() as session:
tools.classify_suggested(session, admin)
ben = browser("ben")
chat_id = int(ben.post(f"/workspaces/{workspace_id}/chats").headers["location"].rsplit("/", 1)[1])
ben.post(f"/chats/{chat_id}/rename", {"title": "README ergänzen"})
return {"anna": anna, "ben": ben, "dora": browser("dora"), "chat_id": chat_id, "workspace_id": workspace_id,
"repo": git_repo, "people": people}
def ask(b, chat_id: int, text: str = "Bitte README ergänzen") -> uuid.UUID:
response = b.post(f"/chats/{chat_id}/messages", {"content": text}, headers={"HX-Request": "true"})
return uuid.UUID(re.search(r"/runs/([0-9a-f-]+)/events", response.text).group(1))
def work(db, settings) -> None:
Worker(db, settings).run_once()
def commit(setup, db, settings, fake_provider, *changes_, message="README ergänzen", then="Erledigt.") -> None:
fake_provider.script = [[("commit_change", {"message": message, "changes": list(changes_) or [README]})], then]
run_id = ask(setup["ben"], setup["chat_id"])
work(db, settings)
with db.session() as session:
assert session.get(TaskRun, run_id).status == states.COMPLETED
def branch(db) -> ChatBranch:
with db.session() as session:
found = session.scalar(select(ChatBranch).order_by(ChatBranch.id.desc()))
session.expunge(found)
return found
def outputs(fake_provider, call: int = -1) -> list[str]:
return [i["output"] for i in fake_provider.calls[call]["input"] if i.get("type") == "function_call_output"]
def hx(b, url, data=None):
return b.post(url, data or {}, headers={"HX-Request": "true"})
def test_commit_legt_branch_an_und_bleibt_in_der_plattform(setup, db, settings, fake_provider):
main = git(setup["repo"], "rev-parse", "main")
fake_provider.script = [[("commit_change", {"message": "README ergänzen", "changes": [README]})],
[("git_read_file", {"path": "README.md"})], "Erledigt."]
ask(setup["ben"], setup["chat_id"])
work(db, settings)
b = branch(db)
assert b.name == f"ai/c{setup['chat_id']}-readme-ergaenzen" and b.base == main and b.head != main
assert b.pushed is None and remote_branches(setup["repo"]) == [] # nichts nach außen
assert git(setup["repo"], "rev-parse", "main") == main
first, second = outputs(fake_provider, 1)[-1], outputs(fake_provider, 2)[-1]
assert f"auf Branch {b.name}" in first and "nur ein Mensch" in first
assert "Neue Zeile." in second # lesende Tools sehen den Chat-Branch
# Keine Freigabe nötig, die Ansicht zeigt Branch, Commit und Diff
page = setup["ben"].get(f"/chats/{setup['chat_id']}").text
assert b.name in page and "nur in der Plattform" in page and "+Neue Zeile." in page
assert "Branch pushen" in page and "Pull Request erstellen" in page
# Der nächste Run kennt den Stand und baut darauf auf
commit(setup, db, settings, fake_provider, {"path": "neu.txt", "content": "x\n"}, message="Datei neu")
assert "Branch ai/c" in fake_provider.calls[-2]["instructions"] and "noch nicht übertragen" in \
fake_provider.calls[-2]["instructions"]
with db.session() as session:
commits = list(session.scalars(select(BranchCommit).order_by(BranchCommit.id)))
assert len(commits) == 2 and commits[1].parent == commits[0].sha == b.head
assert branch(db).head == commits[1].sha
def test_pushen_und_uebertragen(setup, db, settings, fake_provider):
commit(setup, db, settings, fake_provider)
b = branch(db)
response = hx(setup["ben"], f"/branches/{b.id}/push")
assert response.status_code == 200 and "Übertragen." in response.text
assert remote_branches(setup["repo"]) == [b.name]
assert git(setup["repo"], "rev-parse", b.name) == b.head
assert "Neue Zeile." not in git(setup["repo"], "show", "main:README.md") # Ziel unverändert
assert hx(setup["ben"], f"/branches/{b.id}/push").status_code == 400 # nichts Neues
commit(setup, db, settings, fake_provider, {"path": "neu.txt", "content": "x\n"}, message="Datei neu")
page = setup["ben"].get(f"/chats/{setup['chat_id']}/changes").text
assert "neue Commits nicht übertragen" in page and "Branch pushen" in page
hx(setup["ben"], f"/branches/{branch(db).id}/push")
assert git(setup["repo"], "rev-parse", b.name) == branch(db).head == branch(db).pushed
def test_fremder_stand_auf_dem_server_wird_nicht_ueberschrieben(setup, db, settings, fake_provider):
commit(setup, db, settings, fake_provider)
b = branch(db)
hx(setup["ben"], f"/branches/{b.id}/push")
# Jemand pusht von außen auf den Branch
git(setup["repo"], "branch", "-f", b.name, "main")
commit(setup, db, settings, fake_provider, {"path": "neu.txt", "content": "x\n"}, message="Datei neu")
response = hx(setup["ben"], f"/branches/{b.id}/push")
assert response.status_code == 400 and "nichts überschrieben" in response.text
assert git(setup["repo"], "rev-parse", b.name) == git(setup["repo"], "rev-parse", "main")
def test_pull_request_als_entwurf_und_bereit(setup, db, settings, fake_provider, fake_prs):
settings.public_url = "https://aiw.intern"
commit(setup, db, settings, fake_provider)
b = branch(db)
response = hx(setup["ben"], f"/branches/{b.id}/pull-request",
{"title": "README ergänzen", "description": "Kleiner Hinweis.", "draft": "true"})
assert response.status_code == 200 and "Pull Request erstellt." in response.text
[created] = fake_prs.created
assert created["branch"] == b.name and created["target"] == "main" and created["draft"] is True
assert "Kleiner Hinweis." in created["description"] and "von Ben" in created["description"]
assert b.head[:10] in created["description"]
assert remote_branches(setup["repo"]) == [b.name] # vorher gepusht
b = branch(db)
assert b.pr_id == "42" and b.pr_draft and b.pushed == b.head
page = setup["ben"].get(f"/chats/{setup['chat_id']}/changes").text
assert "PR #42 Entwurf" in page and "Als bereit markieren" in page and "Pull Request erstellen" not in page
assert hx(setup["ben"], f"/branches/{b.id}/pull-request", {"title": "Nochmal"}).status_code == 400
assert hx(setup["ben"], f"/branches/{b.id}/ready").status_code == 200
assert fake_prs.published == [0] and not branch(db).pr_draft
# Weitere Commits gehen erst auf Klick in den offenen Pull Request
commit(setup, db, settings, fake_provider, {"path": "neu.txt", "content": "x\n"}, message="Datei neu")
assert "Änderungen übertragen" in setup["ben"].get(f"/chats/{setup['chat_id']}/changes").text
assert git(setup["repo"], "rev-parse", b.name) != branch(db).head
hx(setup["ben"], f"/branches/{b.id}/push")
assert git(setup["repo"], "rev-parse", b.name) == branch(db).head
def test_ohne_anbieter_wird_nur_gepusht(setup, db, settings, fake_provider):
commit(setup, db, settings, fake_provider)
b = branch(db)
response = hx(setup["ben"], f"/branches/{b.id}/pull-request", {"title": "README ergänzen"})
assert response.status_code == 400 and "kein Pull-Request-Anbieter" in response.text
assert remote_branches(setup["repo"]) == [b.name] and branch(db).pr_id is None
def test_commit_zuruecknehmen(setup, db, settings, fake_provider):
commit(setup, db, settings, fake_provider)
commit(setup, db, settings, fake_provider, {"path": "neu.txt", "content": "x\n"}, message="Datei neu")
b = branch(db)
assert hx(setup["ben"], f"/branches/{b.id}/undo").status_code == 200
with db.session() as session:
assert session.scalar(select(BranchCommit.message).order_by(BranchCommit.id.desc())) == "README ergänzen"
hx(setup["ben"], f"/branches/{b.id}/push")
response = hx(setup["ben"], f"/branches/{b.id}/undo")
assert response.status_code == 400 and "Übertragene Commits" in response.text
# Ein einziger, nie gepushter Commit: danach gibt es keinen Branch mehr
other = int(setup["ben"].post(f"/workspaces/{setup['workspace_id']}/chats").headers["location"].rsplit("/", 1)[1])
setup["chat_id"] = other
commit(setup, db, settings, fake_provider)
hx(setup["ben"], f"/branches/{branch(db).id}/undo")
with db.session() as session:
assert session.scalar(select(ChatBranch).where(ChatBranch.chat_session_id == other)) is None
def test_branch_wird_nach_verlust_des_klons_nachgebaut(setup, db, settings, fake_provider):
commit(setup, db, settings, fake_provider)
commit(setup, db, settings, fake_provider, {"path": "neu.txt", "content": "x\n"}, message="Datei neu")
head = branch(db).head
shutil.rmtree(Path(settings.git_cache_dir))
fake_provider.script = [[("git_read_file", {"path": "neu.txt"})], "Gelesen."]
ask(setup["ben"], setup["chat_id"])
work(db, settings)
assert "x" in outputs(fake_provider)[-1] and "neu.txt @ " + head[:12] in outputs(fake_provider)[-1]
hx(setup["ben"], f"/branches/{branch(db).id}/push")
assert git(setup["repo"], "rev-parse", branch(db).name) == head
def test_merge_schliesst_branch_und_naechster_commit_beginnt_neu(setup, db, settings, fake_provider, fake_prs):
from app.identity.models import User
with db.session() as session:
session.get(User, setup["people"]["ben"]).email = "ben@firma.de"
commit(setup, db, settings, fake_provider)
first = branch(db)
hx(setup["ben"], f"/branches/{first.id}/pull-request", {"title": "README ergänzen"})
# Kommentare: werden beim Abfragen gezählt und gemeldet
fake_prs.comments_list = [pull_requests.Comment("1", "Clara", "Bitte Punkt am Ende.", None, "README.md", 3)]
assert changes.poll(db, settings.model_copy(update={"pr_poll_seconds": 0})) == 1
assert branch(db).pr_info["comments"] == 1
assert "Kommentare einarbeiten" in setup["ben"].get(f"/chats/{setup['chat_id']}/changes").text
# Merge in Bitbucket (simuliert): Branch geschlossen, Benachrichtigung
git(setup["repo"], "merge", "--ff-only", first.name)
fake_prs.state = "MERGED"
changes.poll(db, settings.model_copy(update={"pr_poll_seconds": 0}))
closed = branch(db)
assert closed.closed is not None and closed.pr_state == "MERGED"
with db.session() as session:
events = {n.event for n in session.scalars(select(Notification))}
assert {"pull_request.comments", "pull_request.merged"} <= events
page = setup["ben"].get(f"/chats/{setup['chat_id']}/changes").text
assert "gemergt" in page and "Branch pushen" not in page
assert hx(setup["ben"], f"/branches/{closed.id}/push").status_code == 400
# Der Branch ist im Git-Server aufgeräumt, in der Plattform bleibt er sichtbar
assert remote_branches(setup["repo"]) == [] and "im Git-Server gelöscht" in page
assert closed.pr_info["remote_branch"] == "deleted" and "+Neue Zeile." in page
# Neuer Commit im selben Chat: neuer Branch auf dem aktuellen Stand von main
commit(setup, db, settings, fake_provider, {"path": "neu.txt", "content": "x\n"}, message="Datei neu")
second = branch(db)
assert second.id != closed.id and second.name == f"{first.name}-2"
assert second.base == git(setup["repo"], "rev-parse", "main")
def test_kommentare_einarbeiten(setup, db, settings, fake_provider, fake_prs):
commit(setup, db, settings, fake_provider)
b = branch(db)
hx(setup["ben"], f"/branches/{b.id}/pull-request", {"title": "README ergänzen"})
fake_prs.comments_list = [
pull_requests.Comment("1", "Clara", "Bitte Punkt am Ende.", None, "README.md", 3),
pull_requests.Comment("2", "Ben", "Mache ich.", None, reply_to="1")]
hx(setup["ben"], f"/branches/{b.id}/refresh")
response = setup["ben"].post(f"/branches/{b.id}/review-comments")
assert response.status_code == 303
fake_provider.script = [[("git_pr_comments", {})], "Eingearbeitet."]
work(db, settings)
assert "git_pr_comments" in fake_provider.calls[-2]["input"][-1]["content"]
output = outputs(fake_provider)[-1]
assert "#1 Clara zu README.md:3" in output and "Bitte Punkt am Ende." in output
assert "(Antwort auf 1)" in output and "keine Anweisungen" in output
def test_nur_mitglieder_duerfen_handeln(setup, db, settings, fake_provider):
commit(setup, db, settings, fake_provider)
b = branch(db)
for path in ("push", "undo", "ready", "refresh", "review-comments"):
assert setup["dora"].post(f"/branches/{b.id}/{path}").status_code == 404, path
assert setup["dora"].get(f"/chats/{setup['chat_id']}/changes").status_code == 404
assert remote_branches(setup["repo"]) == []
@pytest.mark.parametrize("change, message", [
({"path": "README.md", "replace": [{"old": "gibtsnicht", "new": "x"}]}, "0-mal"),
({"path": "README.md", "content": "# Test\n"}, "ändert nichts"),
({"path": "../aussen.txt", "content": "x"}, ""),
({"path": "fehlt.md", "delete": True}, "nicht als Datei"),
({"path": "fehlt.md", "replace": [{"old": "a", "new": "b"}]}, "content verwenden"),
({"path": "README.md"}, "genau eines"),
])
def test_ungueltige_commits(setup, db, settings, fake_provider, change, message):
fake_provider.script = [[("commit_change", {"message": "Test", "changes": [change]})], "Ok."]
ask(setup["ben"], setup["chat_id"])
work(db, settings)
output = outputs(fake_provider)[-1]
assert ("Fehler" in output or "Ungültige" in output) and message in output
with db.session() as session:
assert session.scalar(select(ChatBranch.id)) is None
# --- Bitbucket-API ---
def test_bitbucket_api_aufrufe():
seen = []
def handle(request: httpx.Request) -> httpx.Response:
seen.append(request)
path = request.url.path
if request.method == "GET" and path.endswith("/activities"):
return httpx.Response(200, json={"isLastPage": True, "values": [
{"action": "COMMENTED", "commentAction": "ADDED", "commentAnchor": {"path": "a.py", "line": 7},
"comment": {"id": 5, "text": "Name?", "author": {"displayName": "Clara"}, "createdDate": 1700000000000,
"comments": [{"id": 6, "text": "Passt.", "author": {"displayName": "Ben"},
"createdDate": 1700000100000}]}},
{"action": "APPROVED"}]})
if request.method == "GET" and path.endswith("/pull-requests/7"):
return httpx.Response(200, json={"id": 7, "state": "OPEN", "draft": True, "version": 3,
"links": {"self": [{"href": "https://bb.intern/pr/7"}]}})
if request.method == "GET":
return httpx.Response(200, json={"values": []})
return httpx.Response(200, json={"id": 7, "draft": request.method == "POST", "version": 4,
"links": {"self": [{"href": "https://bb.intern/pr/7"}]}})
client = pull_requests.BitbucketDataCenter("https://bb.intern/scm/PROJ/repo.git", "tok", 5,
transport=httpx.MockTransport(handle))
assert client.find("ai/x") is None
pr = client.create("ai/x", "main", "Titel", "Text", draft=True)
assert pr.id == "7" and pr.url == "https://bb.intern/pr/7" and pr.draft
assert str(seen[1].url) == "https://bb.intern/rest/api/1.0/projects/PROJ/repos/repo/pull-requests"
assert b'"draft":true' in seen[1].content.replace(b" ", b"") and seen[1].headers["Authorization"] == "Bearer tok"
assert client.get("7").version == 3
assert not client.publish("7", 3).draft and seen[-1].method == "PUT" and b'"version":3' in seen[-1].content.replace(b" ", b"")
first, reply = client.comments("7")
assert (first.author, first.path, first.line, first.text) == ("Clara", "a.py", 7, "Name?")
assert reply.reply_to == "5" and reply.path == ""
with pytest.raises(GitError, match="Ungültige Pull-Request-ID"):
client.get("7/../../x")
with pytest.raises(GitError, match="Bitbucket meldet 409"):
pull_requests.BitbucketDataCenter(
"https://bb.intern/scm/PROJ/repo.git", None, 5,
transport=httpx.MockTransport(lambda r: httpx.Response(409, json={"errors": [{"message": "x"}]}))
).create("ai/x", "main", "T", "")
with pytest.raises(GitError, match="scm/PROJEKT"):
pull_requests.BitbucketDataCenter("https://bb.intern/repo.git", None, 5)
def test_pr_anbieter_braucht_passende_url(setup):
response = setup["anna"].post(f"/workspaces/{setup['workspace_id']}/sources",
{"name": "BB", "url": "https://bitbucket.intern/repo.git", "branch": "main",
"pr_provider": "bitbucket_dc", "exposure_confirmed": "1"})
assert response.status_code == 400 and "scm/PROJEKT" in response.text
def test_branchname():
assert changes.slug("Größe & Übersicht für Q3!") == "groesse-uebersicht-fuer-q3"
assert changes.slug("???") == "aenderung"
# --- Projektregeln (AGENTS.md) ---
def add_to_main(repo_url: str, path: str, content: str) -> None:
(Path(repo_url.removeprefix("file://")) / path).write_text(content)
git(repo_url, "add", path)
git(repo_url, "-c", "user.name=T", "-c", "user.email=t@example.org", "commit", "-m", f"{path} anlegen")
def test_projektregeln_aus_agents_md(setup, db, settings, fake_provider):
fake_provider.script = ["Ohne Regeln."]
ask(setup["ben"], setup["chat_id"])
work(db, settings)
assert "<projektregeln>" not in fake_provider.calls[-1]["instructions"]
add_to_main(setup["repo"], "AGENTS.md", "# Konventionen\n\n- Commit-Nachrichten auf Englisch\n")
fake_provider.script = ["Mit Regeln."]
ask(setup["ben"], setup["chat_id"])
work(db, settings)
prompt = fake_provider.calls[-1]["instructions"]
assert "Projektregeln aus AGENTS.md der Quelle" in prompt and "Commit-Nachrichten auf Englisch" in prompt
assert prompt.index("<projektregeln>") > prompt.index("Du bist der KI-Assistent") # nachrangig
page = setup["ben"].get(f"/chats/{setup['chat_id']}").text
assert "Regeln: " in page and "/AGENTS.md" in page
# Ändert die KI die Regeln auf dem Chat-Branch, gilt im nächsten Run der neue Stand
commit(setup, db, settings, fake_provider,
{"path": "AGENTS.md", "content": "# Konventionen\n\n- Tabs statt Leerzeichen\n"}, message="Regeln")
fake_provider.script = ["Neue Regeln."]
ask(setup["ben"], setup["chat_id"])
work(db, settings)
prompt = fake_provider.calls[-1]["instructions"]
assert "Tabs statt Leerzeichen" in prompt and "auf Englisch" not in prompt
def test_lange_projektregeln_werden_gekuerzt(setup, db, settings, fake_provider):
add_to_main(setup["repo"], "AGENTS.md", "- Regel\n" * 2000)
fake_provider.script = ["Ok."]
ask(setup["ben"], setup["chat_id"])
work(db, settings)
prompt = fake_provider.calls[-1]["instructions"]
assert "gekürzt nach 8000 Bytes" in prompt and prompt.count("- Regel") <= 1000
# --- Patch und statische Prüfung ---
PATCH = """diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1 +1,3 @@
# Test
+
+Per Patch.
diff --git a/src/neu.py b/src/neu.py
new file mode 100644
--- /dev/null
+++ b/src/neu.py
@@ -0,0 +1,2 @@
+def f():
+ return 1
"""
def patch_commit(setup, db, settings, fake_provider, patch, message="Per Patch") -> str:
fake_provider.script = [[("commit_change", {"message": message, "patch": patch})], "Ok."]
ask(setup["ben"], setup["chat_id"])
work(db, settings)
return outputs(fake_provider)[-1]
def test_commit_per_patch_ueber_mehrere_dateien(setup, db, settings, fake_provider):
output = patch_commit(setup, db, settings, fake_provider, PATCH)
assert "Commit " in output and "(2 Datei(en))" in output and "Warnungen" not in output
b = branch(db)
assert git(setup["repo"], "rev-parse", "main") != b.head
hx(setup["ben"], f"/branches/{b.id}/push")
assert git(setup["repo"], "show", f"{b.name}:README.md") == "# Test\n\nPer Patch."
assert git(setup["repo"], "show", f"{b.name}:src/neu.py") == "def f():\n return 1"
# Gespeichert als volle Inhalte: nach Verlust des Klons exakt nachbaubar
with db.session() as session:
stored = session.scalar(select(BranchCommit.changes))
assert {c["path"] for c in stored} == {"README.md", "src/neu.py"} and all("content" in c for c in stored)
# Löschen und Umbenennen per Patch
rename = ("diff --git a/src/neu.py b/src/alt.py\nsimilarity index 100%\nrename from src/neu.py\n"
"rename to src/alt.py\n")
assert "Commit " in patch_commit(setup, db, settings, fake_provider, rename, "Umbenennen")
head = branch(db).head
shutil.rmtree(Path(settings.git_cache_dir))
hx(setup["ben"], f"/branches/{b.id}/push")
assert git(setup["repo"], "rev-parse", b.name) == head
@pytest.mark.parametrize("patch, message", [
("diff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-# Anders\n+# Neu\n",
"lässt sich nicht anwenden"),
("diff --git a/../x b/../x\nnew file mode 100644\n--- /dev/null\n+++ b/../x\n@@ -0,0 +1 @@\n+x\n",
"lässt sich nicht anwenden"),
("diff --git a/link b/link\nnew file mode 120000\n--- /dev/null\n+++ b/link\n@@ -0,0 +1 @@\n+/etc/passwd\n",
"keine normale Datei"),
("kein patch", "lässt sich nicht anwenden"),
])
def test_ungueltige_patches(setup, db, settings, fake_provider, patch, message):
output = patch_commit(setup, db, settings, fake_provider, patch)
assert "Fehler" in output and message in output
with db.session() as session:
assert session.scalar(select(ChatBranch.id)) is None
def test_changes_oder_patch_genau_eines(setup, db, settings, fake_provider):
fake_provider.script = [[("commit_change", {"message": "Beides", "changes": [README], "patch": PATCH})], "Ok."]
ask(setup["ben"], setup["chat_id"])
work(db, settings)
assert "genau eines von changes oder patch" in outputs(fake_provider)[-1]
def test_syntaxpruefung_warnt_nur(setup, db, settings, fake_provider):
commit(setup, db, settings, fake_provider,
{"path": "app.py", "content": "def f(:\n pass\n"},
{"path": "config.json", "content": '{"a": 1,}'},
{"path": "pyproject.toml", "content": "[tool\nx = 1\n"},
{"path": "pom.xml", "content": "<project><a></project>"},
{"path": "deploy.yaml", "content": "a: 1\n b: 2\n"},
{"path": "cfn.yml", "content": "Wert: !Ref Bucket\n---\nzweites: dokument\n"},
{"path": "ok.py", "content": "x = 1\n"},
{"path": "notizen.txt", "content": "def f(:\n"}, message="Mit Fehlern")
output = outputs(fake_provider)[-1]
assert "Warnungen der Syntaxprüfung" in output and "der Commit ist trotzdem angelegt" in output
for expected in ("app.py:1: Python-Syntaxfehler", "config.json:1: ungültiges JSON", "pyproject.toml:?: ungültiges TOML",
"pom.xml:1: ungültiges XML", "deploy.yaml:2: ungültiges YAML"):
assert expected in output, expected
rest = output.split("Warnungen")[1]
assert "ok.py" not in rest and "notizen.txt" not in rest and "cfn.yml" not in rest # Tags, mehrere Dokumente ok
with db.session() as session:
assert len(session.scalar(select(BranchCommit.warnings))) == 5
page = setup["ben"].get(f"/chats/{setup['chat_id']}/changes").text
assert "Syntaxwarnung im letzten Commit" in page and "5 Syntaxwarnungen" in page
# Ersetzung, die Python bricht, wird ebenfalls erkannt; eine Korrektur nimmt die Warnung weg
commit(setup, db, settings, fake_provider,
{"path": "ok.py", "replace": [{"old": "x = 1", "new": "x = (1"}]}, message="Kaputt")
assert "ok.py:1: Python-Syntaxfehler" in outputs(fake_provider)[-1]
commit(setup, db, settings, fake_provider, {"path": "ok.py", "content": "x = 1\n"},
{"path": "app.py", "content": "def f():\n pass\n"}, message="Repariert")
assert "Warnungen" not in outputs(fake_provider)[-1]
assert "Syntaxwarnung im letzten Commit" not in setup["ben"].get(f"/chats/{setup['chat_id']}/changes").text
def test_datei_loeschen(setup, db, settings, fake_provider):
commit(setup, db, settings, fake_provider, {"path": "README.md", "delete": True}, message="README weg")
b = branch(db)
hx(setup["ben"], f"/branches/{b.id}/push")
assert "README.md" not in git(setup["repo"], "ls-tree", "--name-only", b.name)
# --- Mit dem Ziel-Branch abgleichen ---
def test_abgleich_ohne_konflikt_per_knopf(setup, db, settings, fake_provider):
commit(setup, db, settings, fake_provider)
b = branch(db)
hx(setup["ben"], f"/branches/{b.id}/push")
assert hx(setup["ben"], f"/branches/{b.id}/sync").status_code == 400 # schon aktuell
add_to_main(setup["repo"], "neu-auf-main.txt", "von main\n")
response = hx(setup["ben"], f"/branches/{b.id}/sync")
assert response.status_code == 200 and "Mit dem Ziel-Branch abgeglichen" in response.text
merged = branch(db)
main = git(setup["repo"], "rev-parse", "main")
with db.session() as session:
last = session.scalar(select(BranchCommit).order_by(BranchCommit.id.desc()))
assert last.merged == main and last.parent == b.head and last.changes == []
assert "Abgleich" in response.text and "Merge main in" in response.text
# Diff zeigt weiter nur die eigenen Änderungen, nicht die aus main
assert "+Neue Zeile." in response.text and "von main" not in response.text
assert "neue Commits nicht übertragen" in response.text
# Übertragen ist ein Fast-Forward des gepushten Branches (kein Überschreiben)
hx(setup["ben"], f"/branches/{b.id}/push")
assert git(setup["repo"], "rev-parse", b.name) == merged.head
assert git(setup["repo"], "merge-base", "--is-ancestor", main, b.name) == ""
# Nachbau nach Verlust des Klons ergibt denselben Merge-Commit
commit(setup, db, settings, fake_provider, {"path": "danach.txt", "content": "x\n"}, message="Danach")
head = branch(db).head
shutil.rmtree(Path(settings.git_cache_dir))
with db.session() as session:
session.execute(ChatBranch.__table__.update().values(pushed=None)) # erzwingt Nachbau statt Fetch
setup["ben"].get(f"/chats/{setup['chat_id']}/changes")
from app.core.crypto import Vault
from app.data_sources import service as sources_service
from app.data_sources.connectors.git_repo import RepoCache
from app.data_sources.models import DataSource
with db.session() as session:
source = session.get(DataSource, b.source_id)
repo = RepoCache(settings).repo(source.id, sources_service.connector(source, settings, Vault(settings.secret_key)))
repo.sync()
changes.restore(repo, branch(db), changes.history(session, b.id), settings)
assert repo.has(head)
def conflicting(setup, db, settings, fake_provider):
commit(setup, db, settings, fake_provider, {"path": "README.md", "content": "# Unser Titel\n"}, message="Titel")
add_to_main(setup["repo"], "README.md", "# Titel aus main\n")
return branch(db)
def test_konflikt_knopf_und_loesung_durch_die_ki(setup, db, settings, fake_provider):
b = conflicting(setup, db, settings, fake_provider)
response = hx(setup["ben"], f"/branches/{b.id}/sync")
assert response.status_code == 400 and "Konflikte mit main in: README.md." in response.text
assert "Konflikt mit main" in response.text and "Konflikte von der KI lösen lassen" in response.text
assert branch(db).head == b.head # nichts passiert
assert setup["ben"].post(f"/branches/{b.id}/resolve").status_code == 303
fake_provider.script = [
[("merge_target", {})],
[("merge_target", {"resolutions": [{"path": "README.md", "content": "<<<<<<< x\n"}]})],
[("merge_target", {"resolutions": [{"path": "README.md", "content": "# Titel aus main (unser Titel)\n"}]})],
"Gelöst."]
work(db, settings)
assert "merge_target" in fake_provider.calls[-4]["input"][-1]["content"]
first, second, third = outputs(fake_provider)[-3:]
assert "Konflikte mit main in 1 Datei(en)" in first and "<<<<<<<" in first and "# Titel aus main" in first
assert "Konfliktmarker" in second and "Fehler" in second
assert "Merge-Commit" in third and "1 Konflikt(e) gelöst" in third
merged = branch(db)
hx(setup["ben"], f"/branches/{b.id}/push")
assert git(setup["repo"], "show", f"{merged.name}:README.md") == "# Titel aus main (unser Titel)"
page = setup["ben"].get(f"/chats/{setup['chat_id']}/changes").text
assert "1 Konflikt gelöst" in page and "Konflikt mit main" not in page
def test_abgleich_nur_fuer_mitglieder_und_offene_branches(setup, db, settings, fake_provider):
commit(setup, db, settings, fake_provider)
b = branch(db)
assert setup["dora"].post(f"/branches/{b.id}/sync").status_code == 404
assert setup["dora"].post(f"/branches/{b.id}/resolve").status_code == 404
fake_provider.script = [[("merge_target", {})], "Ok."]
ask(setup["ben"], setup["chat_id"])
work(db, settings)
assert "bereits auf dem aktuellen Stand von main" in outputs(fake_provider)[-1]
def close_pr(setup, db, settings, fake_provider, fake_prs, state="DECLINED") -> ChatBranch:
commit(setup, db, settings, fake_provider)
b = branch(db)
hx(setup["ben"], f"/branches/{b.id}/pull-request", {"title": "README ergänzen"})
return b
def poll_closed(db, settings, fake_prs, state="DECLINED") -> ChatBranch:
fake_prs.state = state
changes.poll(db, settings.model_copy(update={"pr_poll_seconds": 0}))
return branch(db)
def test_branch_mit_commits_von_aussen_bleibt(setup, db, settings, fake_provider, fake_prs, tmp_path):
b = close_pr(setup, db, settings, fake_provider, fake_prs)
# Jemand pusht von außen auf den Branch
clone = tmp_path / "clone"
subprocess.run(["git", "clone", "-q", "-b", b.name, setup["repo"], str(clone)], check=True)
(clone / "extra.txt").write_text("von außen\n")
for args in (("add", "."), ("-c", "user.name=T", "-c", "user.email=t@example.org", "commit", "-qm", "extra"),
("push", "-q", "origin", b.name)):
subprocess.run(["git", "-C", str(clone), *args], check=True)
closed = poll_closed(db, settings, fake_prs)
assert closed.closed is not None and closed.pr_info["remote_branch"] == "changed"
assert remote_branches(setup["repo"]) == [b.name]
assert "Branch im Git-Server behalten" in setup["ben"].get(f"/chats/{setup['chat_id']}/changes").text
def test_loeschen_abschaltbar_und_fehler_halten_nicht_auf(setup, db, settings, fake_provider, fake_prs, monkeypatch):
from app.data_sources.models import DataSource
b = close_pr(setup, db, settings, fake_provider, fake_prs)
source_id = b.source_id
edit = setup["anna"].get(f"/sources/{source_id}/edit").text
assert 'name="delete_branches" value="1" checked' in edit
setup["anna"].post(f"/sources/{source_id}", {"name": "repo", "branch": "main", "delete_branches": "0"})
with db.session() as session:
assert session.get(DataSource, source_id).config["delete_branches"] is False
closed = poll_closed(db, settings, fake_prs)
assert closed.closed is not None and "remote_branch" not in closed.pr_info
assert remote_branches(setup["repo"]) == [b.name]
def test_fehler_beim_loeschen(setup, db, settings, fake_provider, fake_prs, monkeypatch):
b = close_pr(setup, db, settings, fake_provider, fake_prs)
def denied(self, *args):
raise GitError("Keine Berechtigung, den Branch zu löschen.")
monkeypatch.setattr(changes.GitRepo, "delete_branch", denied)
closed = poll_closed(db, settings, fake_prs, "MERGED")
assert closed.closed is not None and closed.pr_info["remote_branch"] == "failed"
assert remote_branches(setup["repo"]) == [b.name]
assert "Branch nicht gelöscht" in setup["ben"].get(f"/chats/{setup['chat_id']}/changes").text