"""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 "" 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("") > 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": ""}, {"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