672 lines
34 KiB
Python
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
|